Ship Composio as a built-in integration and trim Settings
- Resolve the API key from cred.txt at the project root in development and the packaged resources directory in production; the file is the source of truth, so a rotated key replaces the stored one. Fixes the 401 packaged builds hit when a stale database key won. - Remove the user-facing Composio key editor; the key ships with the product and is not configurable. - Remove the Migration console from Settings. - Remove the Connectors and Home refresh buttons; connector statuses sync when the page opens.
This commit is contained in:
1 parent
947cd8605b
commit
256e0680c6
16 files changed
+53
-396
No files matched your search
@@ -8,6 +8,9 @@ files:
|
||||
- out/**/*
|
||||
- package.json
|
||||
- resources/icon.png
|
||||
extraResources:
|
||||
- from: cred.txt
|
||||
to: cred.txt
|
||||
asar: true
|
||||
mac:
|
||||
category: public.app-category.business
|
||||
|
||||
@@ -321,7 +321,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
}
|
||||
});
|
||||
|
||||
const composioConnections = new ComposioConnectionService(deps.connection.db, process.cwd());
|
||||
const composioConnections = new ComposioConnectionService(deps.connection.db);
|
||||
const connectorGrants = new ConnectorGrantsService(deps.connection.db);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.listComposioToolkits, async (_event, input) => {
|
||||
@@ -362,19 +362,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
toolkitSlug: input.toolkitSlug,
|
||||
})));
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.saveComposioSettings, (_event, input) => mutate(() => {
|
||||
const now = new Date().toISOString();
|
||||
const db = deps.connection.db;
|
||||
const upsert = db.prepare(`
|
||||
insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)
|
||||
on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at
|
||||
`);
|
||||
upsert.run(input.orgId, "api_key", input.apiKey, now);
|
||||
upsert.run(input.orgId, "toolkits", JSON.stringify(input.toolkits), now);
|
||||
upsert.run(input.orgId, "entity_id", input.orgId, now);
|
||||
return null;
|
||||
}));
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize());
|
||||
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize());
|
||||
ipcMain.handle(IPC_CHANNELS.windowClose, () => deps.mainWindow.close());
|
||||
|
||||
@@ -16,7 +16,7 @@ let catalogCache: { fetchedAt: number; toolkits: ComposioToolkitSummary[] } | nu
|
||||
* Connectors page read local state instead of calling out on every frame.
|
||||
*/
|
||||
export class ComposioConnectionService {
|
||||
constructor(private readonly db: DatabaseSync, private readonly credentialFileDir: string) {}
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
listToolkits(orgId: string): Promise<ComposioToolkitSummaryDto[]> {
|
||||
const client = this.requireClient(orgId);
|
||||
@@ -81,7 +81,7 @@ export class ComposioConnectionService {
|
||||
}
|
||||
|
||||
private requireClient(orgId: string): ComposioClient {
|
||||
const apiKey = resolveComposioApiKey(this.db, orgId, this.credentialFileDir);
|
||||
const apiKey = resolveComposioApiKey(this.db, orgId);
|
||||
if (!apiKey) throw new Error("Composio API key is not configured for this company.");
|
||||
return new ComposioClient(apiKey);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@ import { join } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* The org's Composio API key. A developer credential file next to the app
|
||||
* (cred.txt, first line "Composio apikey: ak_...", gitignored) is the source
|
||||
* of truth whenever it exists: rotating the key in the file takes effect on
|
||||
* the next resolution, replacing whatever the database held. Packaged
|
||||
* installs have no credential file and manage the key in Settings instead.
|
||||
* The Composio integration key ships with the product: cred.txt (first line
|
||||
* "Composio apikey: ak_...", gitignored) sits at the project root in
|
||||
* development and inside the packaged app's resources directory in
|
||||
* production. Wherever the file exists it is the source of truth — rotating
|
||||
* the key in the file replaces whatever the database held on the next
|
||||
* resolution. It is not user-configurable.
|
||||
*/
|
||||
export function resolveComposioApiKey(db: DatabaseSync, orgId: string, credentialFileDir: string = process.cwd()): string | null {
|
||||
export function resolveComposioApiKey(db: DatabaseSync, orgId: string, credentialFileDirs: string[] = defaultCredentialDirs()): string | null {
|
||||
const stored = db.prepare(
|
||||
"select value from composio_settings where org_id = ? and key = 'api_key' limit 1",
|
||||
).get(orgId) as { value: string } | undefined;
|
||||
|
||||
const fileKey = readCredentialFile(credentialFileDir);
|
||||
const fileKey = readCredentialFile(credentialFileDirs);
|
||||
if (!fileKey) return stored?.value ?? null;
|
||||
if (stored?.value === fileKey) return fileKey;
|
||||
|
||||
@@ -28,10 +29,20 @@ export function resolveComposioApiKey(db: DatabaseSync, orgId: string, credentia
|
||||
return fileKey;
|
||||
}
|
||||
|
||||
function readCredentialFile(dir: string): string | null {
|
||||
const path = join(dir, "cred.txt");
|
||||
if (!existsSync(path)) return null;
|
||||
const firstLine = readFileSync(path, "utf8").split("\n")[0] ?? "";
|
||||
const key = firstLine.split(":").slice(1).join(":").trim();
|
||||
return key || null;
|
||||
/** Project root in dev; the app's resources directory when packaged. */
|
||||
export function defaultCredentialDirs(): string[] {
|
||||
const dirs = [process.cwd()];
|
||||
if (process.resourcesPath) dirs.push(process.resourcesPath);
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function readCredentialFile(dirs: string[]): string | null {
|
||||
for (const dir of dirs) {
|
||||
const path = join(dir, "cred.txt");
|
||||
if (!existsSync(path)) continue;
|
||||
const firstLine = readFileSync(path, "utf8").split("\n")[0] ?? "";
|
||||
const key = firstLine.split(":").slice(1).join(":").trim();
|
||||
if (key) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -82,7 +82,6 @@ const api: DesktopApi = {
|
||||
setLegalHoldState: (input) => invoke(IPC_CHANNELS.setLegalHoldState, input),
|
||||
createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input),
|
||||
setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input),
|
||||
saveComposioSettings: (input) => invoke(IPC_CHANNELS.saveComposioSettings, input),
|
||||
listComposioToolkits: (input) => invoke(IPC_CHANNELS.listComposioToolkits, input),
|
||||
connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input),
|
||||
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
|
||||
|
||||
@@ -66,6 +66,12 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
if (pollTimer.current) clearTimeout(pollTimer.current);
|
||||
}, []);
|
||||
|
||||
// Account statuses sync from Composio when the page opens; a failure here
|
||||
// is already expressed by the catalog error state.
|
||||
useEffect(() => {
|
||||
void window.agentCompany.refreshComposioAccounts({ orgId });
|
||||
}, [orgId]);
|
||||
|
||||
const refreshAccounts = () =>
|
||||
actions.run(() => window.agentCompany.refreshComposioAccounts({ orgId }));
|
||||
|
||||
@@ -104,17 +110,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1060 }}>
|
||||
<div className="row" style={{ marginBottom: 18 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>Connectors</h2>
|
||||
<div style={{ fontSize: 12.5, color: "var(--text-3)", marginTop: 2 }}>
|
||||
Company connections your agents can be granted to use.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Button size="small" onClick={() => void refreshAccounts()}>Refresh</Button>
|
||||
</div>
|
||||
</div>
|
||||
<h2 style={{ margin: "0 0 18px", fontSize: 16, fontWeight: 600 }}>Connectors</h2>
|
||||
|
||||
<ConnectedList accounts={accounts} grants={grants} agents={agents} onGrant={grant} onRevoke={revoke} />
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
||||
return (
|
||||
<div className="page-wide" style={{ maxWidth: 1240 }}>
|
||||
<TopbarActions>
|
||||
<Button tone="ghost" onClick={() => void actions.reload()} title="Refresh"><Icon name="refresh" size={16} /></Button>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={16} /> New task</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">{greeting}, {firstName}</h1>
|
||||
|
||||
@@ -43,8 +43,6 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
const workspaces = useMemo(() => snapshot.workspaces.filter((item) => item.orgId === orgId), [snapshot.workspaces, orgId]);
|
||||
const providerRequests = useMemo(() => snapshot.providerRequests.filter((item) => item.orgId === orgId), [snapshot.providerRequests, orgId]);
|
||||
const hiringPolicies = useMemo(() => snapshot.hiringPolicies.filter((item) => item.orgId === orgId), [snapshot.hiringPolicies, orgId]);
|
||||
const migrationReports = useMemo(() => snapshot.migrationReports.filter((report) => report.orgId === orgId), [snapshot.migrationReports, orgId]);
|
||||
const rollbackSnapshots = useMemo(() => snapshot.rollbackSnapshots.filter((item) => item.orgId === orgId), [snapshot.rollbackSnapshots, orgId]);
|
||||
|
||||
return (
|
||||
<div className="split-main">
|
||||
@@ -83,17 +81,12 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
{tab === "advanced" && (
|
||||
<AdvancedPanel
|
||||
orgId={orgId}
|
||||
employeeId={employee.id}
|
||||
agents={agents}
|
||||
providers={providers}
|
||||
catalog={snapshot.providerCatalog}
|
||||
workspaces={workspaces}
|
||||
providerRequests={providerRequests}
|
||||
hiringPolicies={hiringPolicies}
|
||||
composioSettings={snapshot.composioSettings}
|
||||
migrationReports={migrationReports}
|
||||
rollbackSnapshots={rollbackSnapshots}
|
||||
reconciliationItems={snapshot.migrationReconciliationItems.filter((item) => item.orgId === orgId)}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,49 +1,33 @@
|
||||
import type {
|
||||
AgentRecord,
|
||||
HiringPolicyRecord,
|
||||
MigrationReportRecord,
|
||||
ProviderCatalogEntry,
|
||||
ProviderRecord,
|
||||
ProviderRequestRecord,
|
||||
RollbackSnapshotRecord,
|
||||
WorkspaceRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import type { MigrationReconciliationItemRecord } from "../../../../shared/rollback-domain";
|
||||
import type { ComposioSettingRecord } from "../../../../shared/snapshot-domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { BillingPanel } from "./BillingPanel";
|
||||
import { ComposioPanel } from "./ComposioPanel";
|
||||
import { MigrationPanel } from "./MigrationPanel";
|
||||
import { ProviderRequestPanel } from "./ProviderRequestPanel";
|
||||
|
||||
/** The low-frequency operator surfaces, one section each. */
|
||||
export function AdvancedPanel({
|
||||
orgId,
|
||||
employeeId,
|
||||
agents,
|
||||
providers,
|
||||
catalog,
|
||||
workspaces,
|
||||
providerRequests,
|
||||
hiringPolicies,
|
||||
composioSettings,
|
||||
migrationReports,
|
||||
rollbackSnapshots,
|
||||
reconciliationItems,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
agents: AgentRecord[];
|
||||
providers: ProviderRecord[];
|
||||
catalog: ProviderCatalogEntry[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
providerRequests: ProviderRequestRecord[];
|
||||
hiringPolicies: HiringPolicyRecord[];
|
||||
composioSettings: ComposioSettingRecord[];
|
||||
migrationReports: MigrationReportRecord[];
|
||||
rollbackSnapshots: RollbackSnapshotRecord[];
|
||||
reconciliationItems: MigrationReconciliationItemRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
const connected = providers.filter((provider) => provider.enabled);
|
||||
@@ -51,11 +35,8 @@ export function AdvancedPanel({
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Advanced</h1>
|
||||
|
||||
<div className="section-label" style={{ margin: "16px 0 10px" }}>Composio</div>
|
||||
<ComposioPanel orgId={orgId} settings={composioSettings} actions={actions} />
|
||||
|
||||
{connected.length > 0 && (
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<ProviderRequestPanel
|
||||
orgId={orgId}
|
||||
providers={connected}
|
||||
@@ -70,17 +51,6 @@ export function AdvancedPanel({
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<BillingPanel agents={agents} providers={providers} providerRequests={providerRequests} hiringPolicies={hiringPolicies} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<MigrationPanel
|
||||
orgId={orgId}
|
||||
employeeId={employeeId}
|
||||
reports={migrationReports}
|
||||
rollbackSnapshots={rollbackSnapshots}
|
||||
reconciliationItems={reconciliationItems}
|
||||
actions={actions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { Button } from "../../shared/ui";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import type { ComposioSettingRecord } from "../../../../shared/snapshot-domain";
|
||||
|
||||
function hasSavedKey(settings: ComposioSettingRecord[], orgId: string): boolean {
|
||||
return settings.some((s) => s.orgId === orgId && s.key === "api_key" && s.value.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Composio project API key. Which toolkits agents may use is decided by
|
||||
* connections and per-agent grants on the Connectors page, not here.
|
||||
*/
|
||||
export function ComposioPanel({
|
||||
orgId,
|
||||
settings,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
settings: ComposioSettingRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
const keyConfigured = useMemo(() => hasSavedKey(settings, orgId), [settings, orgId]);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
|
||||
const handleSave = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const key = apiKey.trim();
|
||||
if (!key) return;
|
||||
void actions.run(() => window.agentCompany.saveComposioSettings({
|
||||
orgId,
|
||||
apiKey: key,
|
||||
toolkits: [],
|
||||
})).then(() => setApiKey(""));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="row" style={{ gap: 8, maxWidth: 480 }}>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
aria-label="Composio API key"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={keyConfigured ? "••••••••" : "ak_..."}
|
||||
autoComplete="off"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button tone="primary" type="submit" disabled={!apiKey.trim()}>Save</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Button, Field, Icon, Pill } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import type { MigrationReconciliationItemRecord, MigrationReportRecord, RollbackSnapshotRecord } from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { formatRelative } from "../types";
|
||||
|
||||
interface ParsedReport {
|
||||
tableCounts: Record<string, number>;
|
||||
tableChecksums: Record<string, string>;
|
||||
idMaps: Record<string, string[]>;
|
||||
failures: Array<{ table: string; reason: string }>;
|
||||
rollbackPack?: { sourceDatabasePath: string; capturedAt: string; restoreStrategy: string };
|
||||
}
|
||||
|
||||
export function MigrationPanel({
|
||||
orgId,
|
||||
employeeId,
|
||||
reports,
|
||||
rollbackSnapshots,
|
||||
reconciliationItems,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
reports: MigrationReportRecord[];
|
||||
rollbackSnapshots: RollbackSnapshotRecord[];
|
||||
reconciliationItems: MigrationReconciliationItemRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.createMigrationReport({
|
||||
orgId,
|
||||
sourceDatabasePath: String(form.get("sourceDatabasePath") ?? "").trim(),
|
||||
})).then((result) => { if (result) setOpen(false); });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="reveal-row">
|
||||
<div className="row" style={{ marginBottom: 10 }}>
|
||||
<div className="section-label">Migration</div>
|
||||
<div className="reveal" style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" size={16} /> Report</Button>
|
||||
</div>
|
||||
</div>
|
||||
{reports.length > 0 && (
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
{reports.map((report, index) => (
|
||||
<ReportRow
|
||||
key={report.id}
|
||||
orgId={orgId}
|
||||
employeeId={employeeId}
|
||||
report={report}
|
||||
actions={actions}
|
||||
last={index === reports.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<section className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
|
||||
<div className="row" style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<Icon name="refresh" size={14} color="var(--text-3)" />
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>Rollback snapshots</div>
|
||||
</div>
|
||||
{rollbackSnapshots.length > 0 ? rollbackSnapshots.map((snapshot, index) => (
|
||||
<RollbackRow
|
||||
key={snapshot.id}
|
||||
orgId={orgId}
|
||||
employeeId={employeeId}
|
||||
snapshot={snapshot}
|
||||
actions={actions}
|
||||
last={index === rollbackSnapshots.length - 1}
|
||||
/>
|
||||
)) : <div style={{ padding: 16 }}><Pill>0 rollback snapshots</Pill></div>}
|
||||
</section>
|
||||
<section className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
|
||||
<div className="row" style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<Icon name="list" size={14} color="var(--text-3)" />
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>Reconciliation</div>
|
||||
</div>
|
||||
{reconciliationItems.length > 0 ? reconciliationItems.map((item, index) => (
|
||||
<ReconciliationRow
|
||||
key={item.id}
|
||||
orgId={orgId}
|
||||
employeeId={employeeId}
|
||||
item={item}
|
||||
actions={actions}
|
||||
last={index === reconciliationItems.length - 1}
|
||||
/>
|
||||
)) : <div style={{ padding: 16 }}><Pill>0 reconciliation items</Pill></div>}
|
||||
</section>
|
||||
<Dialog title="Migration report" open={open} submitLabel="Create" onClose={() => setOpen(false)} onSubmit={submit}>
|
||||
<Field label="Source database path"><input className="input" name="sourceDatabasePath" required /></Field>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportRow({
|
||||
orgId,
|
||||
employeeId,
|
||||
report,
|
||||
actions,
|
||||
last,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
report: MigrationReportRecord;
|
||||
actions: SnapshotActions;
|
||||
last: boolean;
|
||||
}) {
|
||||
const body = parseReport(report.reportJson);
|
||||
const idCount = Object.values(body.idMaps).reduce((sum, ids) => sum + ids.length, 0);
|
||||
return (
|
||||
<div
|
||||
className="table-row"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.35fr) minmax(0, 1fr) 120px 88px 96px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={report.sourceRef}>{report.sourceRef}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{formatRelative(report.createdAt)}</div>
|
||||
</div>
|
||||
<div className="row" style={{ flexWrap: "wrap", gap: 4 }}>
|
||||
{Object.entries(body.tableCounts).map(([table, count]) => (
|
||||
<Pill key={table}>{table}: {count}</Pill>
|
||||
))}
|
||||
</div>
|
||||
<Pill>{Object.keys(body.tableChecksums).length} checksums</Pill>
|
||||
<div className="row" style={{ flexWrap: "wrap", gap: 4 }}>
|
||||
<Pill>{idCount} ids</Pill>
|
||||
{body.failures.length > 0 ? <Pill tone="amber">{body.failures.length} failures</Pill> : null}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!body.rollbackPack}
|
||||
onClick={() => void actions.run(() => window.agentCompany.createRollbackSnapshot({ orgId, migrationReportId: report.id, createdByEmployeeId: employeeId }))}
|
||||
>
|
||||
Snapshot
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RollbackRow({
|
||||
orgId,
|
||||
employeeId,
|
||||
snapshot,
|
||||
actions,
|
||||
last,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
snapshot: RollbackSnapshotRecord;
|
||||
actions: SnapshotActions;
|
||||
last: boolean;
|
||||
}) {
|
||||
const manifest = JSON.parse(snapshot.workspaceManifestJson) as { workspaces: unknown[]; files: unknown[] };
|
||||
return (
|
||||
<div
|
||||
className="table-row"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, .8fr) 96px 96px 72px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={snapshot.sourceDatabasePath}>{snapshot.sourceDatabasePath}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{formatRelative(snapshot.createdAt)}</div>
|
||||
</div>
|
||||
<Pill>{snapshot.sourceDatabaseSha256.slice(0, 12)}</Pill>
|
||||
<Pill>{manifest.workspaces.length} workspaces</Pill>
|
||||
<Pill>{manifest.files.length} files</Pill>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={snapshot.active}
|
||||
onClick={() => void actions.run(() => window.agentCompany.setRollbackSnapshotActive({ orgId, snapshotId: snapshot.id, active: true, employeeId }))}
|
||||
>
|
||||
{snapshot.active ? "active" : "Set"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReconciliationRow({
|
||||
orgId,
|
||||
employeeId,
|
||||
item,
|
||||
actions,
|
||||
last,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
item: MigrationReconciliationItemRecord;
|
||||
actions: SnapshotActions;
|
||||
last: boolean;
|
||||
}) {
|
||||
const resolved = item.state === "resolved";
|
||||
return (
|
||||
<div
|
||||
className="table-row"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr) 86px 112px 84px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.targetType} · {item.targetId}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{formatRelative(item.createdAt)}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.reason}</div>
|
||||
<Pill tone={resolved ? "green" : "amber"}>{item.state}</Pill>
|
||||
<Pill>{item.sourceEventId.slice(0, 12)}</Pill>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={resolved}
|
||||
onClick={() => void actions.run(() => window.agentCompany.resolveMigrationReconciliation({ orgId, reconciliationItemId: item.id, employeeId }))}
|
||||
>
|
||||
{resolved ? "Resolved" : "Resolve"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseReport(reportJson: string): ParsedReport {
|
||||
const parsed = JSON.parse(reportJson) as ParsedReport;
|
||||
if (!parsed.tableCounts || !parsed.tableChecksums || !parsed.idMaps || !Array.isArray(parsed.failures)) {
|
||||
throw new Error("Migration report is invalid.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
export interface SaveComposioSettingsInput {
|
||||
orgId: string;
|
||||
apiKey: string;
|
||||
toolkits: string[];
|
||||
}
|
||||
|
||||
export interface ComposioToolkitSummaryDto {
|
||||
slug: string;
|
||||
name: string;
|
||||
|
||||
@@ -63,7 +63,6 @@ import type {
|
||||
RunTaskInput,
|
||||
RevokeProviderCredentialInput,
|
||||
SaveProviderCredentialInput,
|
||||
SaveComposioSettingsInput,
|
||||
ComposioToolkitSummaryDto,
|
||||
ConnectComposioToolkitInput,
|
||||
ConnectComposioToolkitResult,
|
||||
@@ -179,7 +178,6 @@ export interface DesktopApi {
|
||||
setLegalHoldState(input: SetLegalHoldStateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createRetentionFreeze(input: CreateRetentionFreezeInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
saveComposioSettings(input: SaveComposioSettingsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
listComposioToolkits(input: ListComposioToolkitsInput): Promise<DesktopResult<ComposioToolkitSummaryDto[]>>;
|
||||
connectComposioToolkit(input: ConnectComposioToolkitInput): Promise<DesktopResult<ConnectComposioToolkitResult>>;
|
||||
refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -74,7 +74,6 @@ export const IPC_CHANNELS = {
|
||||
deleteMeeting: "agentcompany:delete-meeting",
|
||||
createSprint: "agentcompany:create-sprint",
|
||||
deleteSprint: "agentcompany:delete-sprint",
|
||||
saveComposioSettings: "agentcompany:save-composio-settings",
|
||||
listComposioToolkits: "agentcompany:list-composio-toolkits",
|
||||
connectComposioToolkit: "agentcompany:connect-composio-toolkit",
|
||||
refreshComposioAccounts: "agentcompany:refresh-composio-accounts",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
const profileDir = join(tmpdir(), `agentcompany-new-e2e-${randomUUID()}`);
|
||||
@@ -115,7 +114,7 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
// is reachable from the test machine.
|
||||
await page.getByRole("button", { name: "Connectors", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await expect(page.getByText("AI CEO", { exact: true })).toBeVisible();
|
||||
@@ -130,7 +129,6 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await expect(page.getByRole("button", { name: "Background" })).toBeVisible();
|
||||
await page.getByLabel("Request prompt").fill("Draft a provider control memo.");
|
||||
await page.getByLabel("Request instructions").fill("Use direct language.");
|
||||
await expect(page.getByLabel("Composio API key")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Providers" }).click();
|
||||
await page.getByLabel("OpenAI data policy").selectOption("zero_data_retention");
|
||||
@@ -147,17 +145,6 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await expect(page.getByText("Never run").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Run tests" }).click();
|
||||
await expect(page.getByText("1/1 passed").first()).toBeVisible();
|
||||
const legacyPath = createLegacyDatabase();
|
||||
await page.getByRole("button", { name: "Advanced" }).click();
|
||||
await page.getByRole("button", { name: "Report" }).click();
|
||||
await page.getByLabel("Source database path").fill(legacyPath);
|
||||
await page.locator(".modal").getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByText("organizations: 1").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Snapshot" }).click();
|
||||
await expect(page.getByText("Rollback snapshots", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/\d+ workspaces/).first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Set", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "active", exact: true })).toBeDisabled();
|
||||
|
||||
// Virtual office page
|
||||
await page.getByRole("button", { name: "Office" }).click();
|
||||
@@ -176,14 +163,6 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function createLegacyDatabase(): string {
|
||||
const legacyPath = join(tmpdir(), `agentcompany-legacy-${randomUUID()}.db`);
|
||||
const legacy = new DatabaseSync(legacyPath);
|
||||
legacy.exec("create table organizations(id text primary key); insert into organizations(id) values ('legacy-org');");
|
||||
legacy.close();
|
||||
return legacyPath;
|
||||
}
|
||||
|
||||
function createSkillDirectory(): string {
|
||||
const root = join(tmpdir(), `agentcompany-skill-${randomUUID()}`);
|
||||
mkdirSync(join(root, "checks"), { recursive: true });
|
||||
|
||||
@@ -40,30 +40,39 @@ describe("composio credential resolution", () => {
|
||||
test("seeds the database from the credential file", () => {
|
||||
const h = harness();
|
||||
writeCred(h, "ak_first");
|
||||
expect(resolveComposioApiKey(h.connection.db, h.orgId, h.credDir)).toBe("ak_first");
|
||||
expect(resolveComposioApiKey(h.connection.db, h.orgId, [h.credDir])).toBe("ak_first");
|
||||
expect(storedKey(h)).toBe("ak_first");
|
||||
});
|
||||
|
||||
test("a rotated credential file replaces the stale stored key", () => {
|
||||
const h = harness();
|
||||
writeCred(h, "ak_old");
|
||||
resolveComposioApiKey(h.connection.db, h.orgId, h.credDir);
|
||||
resolveComposioApiKey(h.connection.db, h.orgId, [h.credDir]);
|
||||
writeCred(h, "ak_rotated");
|
||||
expect(resolveComposioApiKey(h.connection.db, h.orgId, h.credDir)).toBe("ak_rotated");
|
||||
expect(resolveComposioApiKey(h.connection.db, h.orgId, [h.credDir])).toBe("ak_rotated");
|
||||
expect(storedKey(h)).toBe("ak_rotated");
|
||||
});
|
||||
|
||||
test("finds the credential file in a later directory when the first has none", () => {
|
||||
const h = harness();
|
||||
writeCred(h, "ak_resources");
|
||||
const emptyDir = join(tmpdir(), `ac-cred-empty-${randomUUID()}`);
|
||||
mkdirSync(emptyDir, { recursive: true });
|
||||
expect(resolveComposioApiKey(h.connection.db, h.orgId, [emptyDir, h.credDir])).toBe("ak_resources");
|
||||
expect(storedKey(h)).toBe("ak_resources");
|
||||
});
|
||||
|
||||
test("without a credential file the stored key stands", () => {
|
||||
const h = harness();
|
||||
const now = new Date().toISOString();
|
||||
h.connection.db.prepare(
|
||||
"insert into composio_settings(org_id, key, value, updated_at) values (?, 'api_key', 'ak_settings', ?)",
|
||||
).run(h.orgId, now);
|
||||
expect(resolveComposioApiKey(h.connection.db, h.orgId, h.credDir)).toBe("ak_settings");
|
||||
expect(resolveComposioApiKey(h.connection.db, h.orgId, [h.credDir])).toBe("ak_settings");
|
||||
});
|
||||
|
||||
test("no file and no stored key yields null", () => {
|
||||
const h = harness();
|
||||
expect(resolveComposioApiKey(h.connection.db, h.orgId, h.credDir)).toBeNull();
|
||||
expect(resolveComposioApiKey(h.connection.db, h.orgId, [h.credDir])).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user