diff --git a/packages/cli/package.json b/packages/cli/package.json index 0b4b0ef..8ad2153 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,6 +12,7 @@ "main": "src/index.ts", "types": "src/index.ts", "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "@versatly/workgraph-control-api": "workspace:*", "@versatly/workgraph-kernel": "workspace:*", "@versatly/workgraph-mcp-server": "workspace:*", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2357fd4..722cab1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -29,17 +29,26 @@ import { parseSetPairs, renderInstalledIntegrationResult, resolveInitTargetPath, + resolveApiKey, + resolveApiUrl, resolveWorkspacePath, runCommand, type JsonCapableOptions, wantsJson, } from './cli/core.js'; +import { WorkgraphRemoteClient } from './remote-client.js'; const DEFAULT_ACTOR = process.env.WORKGRAPH_AGENT || process.env.USER || 'anonymous'; +type PrimitiveRecord = { + path: string; + type: string; + fields: Record; +}; + registerDefaultDispatchAdaptersIntoKernelRegistry(); const CLI_VERSION = (() => { @@ -129,8 +138,30 @@ addWorkspaceOption( .option('--context ', 'Comma-separated workspace doc refs for context') .option('--tags ', 'Comma-separated tags') .option('--json', 'Emit structured JSON output') -).action((title, opts) => - runCommand( +).action((title, opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool<{ thread: PrimitiveRecord }>('workgraph_thread_create', { + title, + goal: opts.goal, + actor: opts.actor, + priority: opts.priority, + deps: csv(opts.deps), + parent: opts.parent, + space: opts.space, + context_refs: csv(opts.context), + tags: csv(opts.tags), + })), + (result) => [ + `Created thread: ${result.thread.path}`, + `Status: ${String(result.thread.fields.status)}`, + `Priority: ${String(result.thread.fields.priority)}`, + ], + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -149,9 +180,9 @@ addWorkspaceOption( `Created thread: ${result.thread.path}`, `Status: ${String(result.thread.fields.status)}`, `Priority: ${String(result.thread.fields.priority)}`, - ] - ) -); + ], + ); +}); addWorkspaceOption( threadCmd @@ -161,8 +192,34 @@ addWorkspaceOption( .option('--space ', 'Filter threads by space ref') .option('--ready', 'Only include threads ready to be claimed now') .option('--json', 'Emit structured JSON output') -).action((opts) => - runCommand( +).action((opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool<{ threads: Array; count: number }>( + 'workgraph_thread_list', + { + status: opts.status, + readyOnly: !!opts.ready, + space: opts.space, + }, + )), + (result) => { + if (result.threads.length === 0) return ['No threads found.']; + return [ + ...result.threads.map((t) => { + const status = String(t.fields.status); + const owner = t.fields.owner ? ` (${String(t.fields.owner)})` : ''; + const ready = t.ready ? ' ready' : ''; + return `[${status}]${ready} ${String(t.fields.title)}${owner} -> ${t.path}`; + }), + `${result.count} thread(s)`, + ]; + }, + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -194,9 +251,9 @@ addWorkspaceOption( }), `${result.count} thread(s)`, ]; - } - ) -); + }, + ); +}); addWorkspaceOption( threadCmd @@ -207,8 +264,51 @@ addWorkspaceOption( .option('--claim', 'Immediately claim the next ready thread') .option('--fail-on-empty', 'Exit non-zero if no ready thread exists') .option('--json', 'Emit structured JSON output') -).action((opts) => - runCommand( +).action((opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, async (client) => { + const readyResult = await client.callTool<{ threads: PrimitiveRecord[] }>( + 'workgraph_thread_list', + { + readyOnly: true, + space: opts.space, + }, + ); + const nextThread = readyResult.threads[0]; + if (!nextThread) { + if (opts.failOnEmpty) { + throw new Error('No ready threads available.'); + } + return { thread: null, claimed: false }; + } + if (!opts.claim) { + return { thread: nextThread, claimed: false }; + } + const claimedResult = await client.callTool<{ thread: PrimitiveRecord }>( + 'workgraph_thread_claim', + { + threadPath: nextThread.path, + actor: opts.actor, + }, + ); + return { + thread: claimedResult.thread, + claimed: true, + }; + }), + (result) => { + if (!result.thread) return ['No ready thread available.']; + return [ + `${result.claimed ? 'Claimed' : 'Selected'} thread: ${result.thread.path}`, + `Title: ${String(result.thread.fields.title)}`, + ...(result.thread.fields.space ? [`Space: ${String(result.thread.fields.space)}`] : []), + ]; + }, + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -234,9 +334,9 @@ addWorkspaceOption( `Title: ${String(result.thread.fields.title)}`, ...(result.thread.fields.space ? [`Space: ${String(result.thread.fields.space)}`] : []), ]; - } - ) -); + }, + ); +}); addWorkspaceOption( threadCmd @@ -361,8 +461,19 @@ addWorkspaceOption( .option('-a, --actor ', 'Agent name', DEFAULT_ACTOR) .option('--lease-ttl-minutes ', 'Claim lease TTL in minutes', '30') .option('--json', 'Emit structured JSON output') -).action((threadPath, opts) => - runCommand( +).action((threadPath, opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool<{ thread: PrimitiveRecord }>('workgraph_thread_claim', { + threadPath, + actor: opts.actor, + })), + (result) => [`Claimed: ${result.thread.path}`, `Owner: ${String(result.thread.fields.owner)}`], + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -372,9 +483,9 @@ addWorkspaceOption( }), }; }, - (result) => [`Claimed: ${result.thread.path}`, `Owner: ${String(result.thread.fields.owner)}`] - ) -); + (result) => [`Claimed: ${result.thread.path}`, `Owner: ${String(result.thread.fields.owner)}`], + ); +}); addWorkspaceOption( threadCmd @@ -402,8 +513,21 @@ addWorkspaceOption( .option('-o, --output ', 'Output/result summary') .option('--evidence ', 'Comma-separated evidence values (url/path/reply/thread refs)') .option('--json', 'Emit structured JSON output') -).action((threadPath, opts) => - runCommand( +).action((threadPath, opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool<{ thread: PrimitiveRecord }>('workgraph_thread_done', { + threadPath, + actor: opts.actor, + output: opts.output, + evidence: csv(opts.evidence), + })), + (result) => [`Done: ${result.thread.path}`], + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -413,9 +537,9 @@ addWorkspaceOption( }), }; }, - (result) => [`Done: ${result.thread.path}`] - ) -); + (result) => [`Done: ${result.thread.path}`], + ); +}); addWorkspaceOption( threadCmd @@ -595,8 +719,26 @@ addWorkspaceOption( .option('--current-task ', 'Current task/thread slug for this agent') .option('--capabilities ', 'Comma-separated capability tags') .option('--json', 'Emit structured JSON output') -).action((name, opts) => - runCommand( +).action((name, opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool<{ presence: PrimitiveRecord }>('workgraph_agent_heartbeat', { + name, + actor: opts.actor, + status: normalizeAgentPresenceStatus(opts.status), + currentTask: opts.currentTask, + capabilities: csv(opts.capabilities), + })), + (result) => [ + `Heartbeat: ${String(result.presence.fields.name)} [${String(result.presence.fields.status)}]`, + `Last seen: ${String(result.presence.fields.last_seen)}`, + `Current task: ${String(result.presence.fields.current_task ?? 'none')}`, + ], + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -614,8 +756,8 @@ addWorkspaceOption( `Last seen: ${String(result.presence.fields.last_seen)}`, `Current task: ${String(result.presence.fields.current_task ?? 'none')}`, ], - ) -); + ); +}); addWorkspaceOption( agentCmd @@ -628,8 +770,33 @@ addWorkspaceOption( .option('--current-task ', 'Optional current task/thread ref') .option('-a, --actor ', 'Actor writing registration artifacts') .option('--json', 'Emit structured JSON output') -).action((name, opts) => - runCommand( +).action((name, opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool>('workgraph_agent_register', { + name, + token: opts.token, + role: opts.role, + capabilities: csv(opts.capabilities), + status: normalizeAgentPresenceStatus(opts.status), + currentTask: opts.currentTask, + actor: opts.actor, + })), + (result) => [ + `Registered agent: ${result.agentName}`, + `Role: ${result.role} (${result.rolePath})`, + `Capabilities: ${result.capabilities.join(', ') || 'none'}`, + `Presence: ${result.presence.path}`, + `Policy party: ${result.policyParty.id}`, + `Bootstrap token: ${result.trustTokenPath} [${result.trustTokenStatus}]`, + ...(result.credential ? [`Credential: ${result.credential.id} [${result.credential.status}]`] : []), + ...(result.apiKey ? [`API key (store securely, shown once): ${result.apiKey}`] : []), + ], + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -656,8 +823,8 @@ addWorkspaceOption( ...(result.credential ? [`Credential: ${result.credential.id} [${result.credential.status}]`] : []), ...(result.apiKey ? [`API key (store securely, shown once): ${result.apiKey}`] : []), ], - ) -); + ); +}); addWorkspaceOption( agentCmd @@ -800,8 +967,28 @@ addWorkspaceOption( .command('list') .description('List known agent presence entries') .option('--json', 'Emit structured JSON output') -).action((opts) => - runCommand( +).action((opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool<{ agents: PrimitiveRecord[]; count: number }>('workgraph_agent_list', {})), + (result) => { + if (result.agents.length === 0) return ['No agent presence entries found.']; + return [ + ...result.agents.map((entry) => { + const name = String(entry.fields.name ?? entry.path); + const status = String(entry.fields.status ?? 'unknown'); + const task = String(entry.fields.current_task ?? 'none'); + const lastSeen = String(entry.fields.last_seen ?? 'unknown'); + return `${name} [${status}] task=${task} last_seen=${lastSeen}`; + }), + `${result.count} agent(s)`, + ]; + }, + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -824,8 +1011,8 @@ addWorkspaceOption( `${result.count} agent(s)`, ]; }, - ) -); + ); +}); // ============================================================================ // primitive @@ -1687,8 +1874,20 @@ addWorkspaceOption( .command('status') .description('Show workspace situational status snapshot') .option('--json', 'Emit structured JSON output') -).action((opts) => - runCommand( +).action((opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool>('workgraph_status', {})), + (result) => [ + `Threads: total=${result.threads.total} open=${result.threads.open} active=${result.threads.active} blocked=${result.threads.blocked} done=${result.threads.done}`, + `Ready threads: ${result.threads.ready} Active claims: ${result.claims.active}`, + `Primitive types: ${Object.keys(result.primitives.byType).length}`, + ], + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -1699,8 +1898,8 @@ addWorkspaceOption( `Ready threads: ${result.threads.ready} Active claims: ${result.claims.active}`, `Primitive types: ${Object.keys(result.primitives.byType).length}`, ], - ) -); + ); +}); addWorkspaceOption( program @@ -1710,8 +1909,25 @@ addWorkspaceOption( .option('--recent ', 'Recent activity count', '12') .option('--next ', 'Next ready threads to include', '5') .option('--json', 'Emit structured JSON output') -).action((opts) => - runCommand( +).action((opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool>('workgraph_brief', { + actor: opts.actor, + recentCount: Number.parseInt(String(opts.recent), 10), + nextCount: Number.parseInt(String(opts.next), 10), + })), + (result) => [ + `Brief for ${result.actor}`, + `My claims: ${result.myClaims.length}`, + `Blocked threads: ${result.blockedThreads.length}`, + `Next ready: ${result.nextReadyThreads.map((item) => item.path).join(', ') || 'none'}`, + ], + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -1726,8 +1942,8 @@ addWorkspaceOption( `Blocked threads: ${result.blockedThreads.length}`, `Next ready: ${result.nextReadyThreads.map((item) => item.path).join(', ') || 'none'}`, ], - ) -); + ); +}); addWorkspaceOption( program @@ -1738,8 +1954,22 @@ addWorkspaceOption( .option('--blocked ', 'Comma-separated blockers') .option('--tags ', 'Comma-separated tags') .option('--json', 'Emit structured JSON output') -).action((summary, opts) => - runCommand( +).action((summary, opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool<{ checkpoint: PrimitiveRecord }>('workgraph_checkpoint_create', { + actor: opts.actor, + summary, + next: csv(opts.next), + blocked: csv(opts.blocked), + tags: csv(opts.tags), + })), + (result) => [`Created checkpoint: ${result.checkpoint.path}`], + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -1752,8 +1982,8 @@ addWorkspaceOption( }; }, (result) => [`Created checkpoint: ${result.checkpoint.path}`], - ) -); + ); +}); addWorkspaceOption( program @@ -1790,15 +2020,23 @@ addWorkspaceOption( .command('list') .description('List built-in context lenses') .option('--json', 'Emit structured JSON output') -).action((opts) => - runCommand( +).action((opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool<{ lenses: Array<{ id: string; description: string }> }>('workgraph_lens_list', {})), + (result) => result.lenses.map((lens) => `lens://${lens.id} - ${lens.description}`), + ); + } + return runCommand( opts, () => ({ lenses: workgraph.lens.listContextLenses(), }), - (result) => result.lenses.map((lens) => `lens://${lens.id} - ${lens.description}`) - ) -); + (result) => result.lenses.map((lens) => `lens://${lens.id} - ${lens.description}`), + ); +}); addWorkspaceOption( lensCmd @@ -1810,8 +2048,43 @@ addWorkspaceOption( .option('--limit ', 'Maximum items per section', '10') .option('-o, --output ', 'Write lens markdown to workspace-relative output path') .option('--json', 'Emit structured JSON output') -).action((lensId, opts) => - runCommand( +).action((lensId, opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => client.callTool< + workgraph.WorkgraphLensResult | workgraph.WorkgraphMaterializedLensResult + >('workgraph_lens_show', { + lensId, + actor: opts.actor, + lookbackHours: parsePositiveNumberOption(opts.lookbackHours, 'lookback-hours'), + staleHours: parsePositiveNumberOption(opts.staleHours, 'stale-hours'), + limit: parsePositiveIntegerOption(opts.limit, 'limit'), + outputPath: opts.output, + })), + (result) => { + const metricSummary = Object.entries(result.metrics) + .map(([metric, value]) => `${metric}=${value}`) + .join(' '); + const sectionSummary = result.sections + .map((section) => `${section.id}:${section.items.length}`) + .join(' '); + const lines = [ + `Lens: ${result.lens}`, + `Generated: ${result.generatedAt}`, + ...(result.actor ? [`Actor: ${result.actor}`] : []), + `Metrics: ${metricSummary || 'none'}`, + `Sections: ${sectionSummary || 'none'}`, + ]; + if (isMaterializedLensResult(result)) { + lines.push(`Saved markdown: ${result.outputPath}`); + return lines; + } + return [...lines, '', ...result.markdown.split('\n')]; + }, + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -1849,8 +2122,8 @@ addWorkspaceOption( } return [...lines, '', ...result.markdown.split('\n')]; }, - ) -); + ); +}); // ============================================================================ // query/search @@ -1873,8 +2146,29 @@ addWorkspaceOption( .option('--limit ', 'Result limit') .option('--offset ', 'Result offset') .option('--json', 'Emit structured JSON output') -).action((opts) => - runCommand( +).action((opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool<{ results: PrimitiveRecord[]; count: number }>('workgraph_query', { + type: opts.type, + status: opts.status, + owner: opts.owner, + tag: opts.tag, + text: opts.text, + pathIncludes: opts.pathIncludes, + updatedAfter: opts.updatedAfter, + updatedBefore: opts.updatedBefore, + createdAfter: opts.createdAfter, + createdBefore: opts.createdBefore, + limit: opts.limit ? Number.parseInt(String(opts.limit), 10) : undefined, + offset: opts.offset ? Number.parseInt(String(opts.offset), 10) : undefined, + })), + (result) => result.results.map((item) => `${item.type} ${item.path}`), + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -1895,8 +2189,8 @@ addWorkspaceOption( return { results, count: results.length }; }, (result) => result.results.map((item) => `${item.type} ${item.path}`), - ) -); + ); +}); addWorkspaceOption( program @@ -1906,8 +2200,30 @@ addWorkspaceOption( .option('--mode ', 'auto | core | qmd', 'auto') .option('--limit ', 'Result limit') .option('--json', 'Emit structured JSON output') -).action((text, opts) => - runCommand( +).action((text, opts) => { + if (isRemoteMode(opts)) { + return runCommand( + opts, + () => withRemoteClient(opts, (client) => + client.callTool<{ + mode: string; + fallbackReason?: string; + results: PrimitiveRecord[]; + count: number; + }>('workgraph_search', { + text, + mode: opts.mode, + type: opts.type, + limit: opts.limit ? Number.parseInt(String(opts.limit), 10) : undefined, + })), + (result) => [ + `Mode: ${result.mode}`, + ...(result.fallbackReason ? [`Note: ${result.fallbackReason}`] : []), + ...result.results.map((item) => `${item.type} ${item.path}`), + ], + ); + } + return runCommand( opts, () => { const workspacePath = resolveWorkspacePath(opts); @@ -1926,8 +2242,8 @@ addWorkspaceOption( ...(result.fallbackReason ? [`Note: ${result.fallbackReason}`] : []), ...result.results.map((item) => `${item.type} ${item.path}`), ], - ) -); + ); +}); // ============================================================================ // board/graph @@ -2423,6 +2739,44 @@ addWorkspaceOption( registerAutonomyCommands(program, DEFAULT_ACTOR); +// ============================================================================ +// remote/api diagnostics +// ============================================================================ + +program + .command('remote') + .description('Remote/API mode diagnostics') + .command('test') + .description('Ping MCP HTTP endpoint and list available tools') + .option('--api-url ', 'Workgraph MCP HTTP endpoint URL (or WORKGRAPH_API_URL env)') + .option('--api-key ', 'Agent credential API key (or WORKGRAPH_API_KEY env)') + .option('--json', 'Emit structured JSON output') + .action((opts) => + runCommand( + opts, + () => withRemoteClient(opts, async (client) => { + const tools = await client.listTools(); + const status = await client.callTool>( + 'workgraph_status', + {}, + ); + return { + apiUrl: resolveApiUrl(opts), + ok: true, + toolCount: tools.length, + tools: tools.map((tool) => tool.name).sort((left, right) => left.localeCompare(right)), + status, + }; + }), + (result) => [ + `Connected to: ${result.apiUrl}`, + `MCP tools available: ${result.toolCount}`, + `Threads: total=${result.status.threads.total} ready=${result.status.threads.ready} active=${result.status.threads.active}`, + `Claims: active=${result.status.claims.active}`, + ], + ), + ); + // ============================================================================ // serve (http server) // ============================================================================ @@ -2595,6 +2949,30 @@ addWorkspaceOption( await program.parseAsync(); +function isRemoteMode(opts: JsonCapableOptions): boolean { + return !!resolveApiUrl(opts); +} + +async function withRemoteClient( + opts: JsonCapableOptions, + action: (client: WorkgraphRemoteClient) => Promise, +): Promise { + const apiUrl = resolveApiUrl(opts); + if (!apiUrl) { + throw new Error('Remote API mode requires --api-url or WORKGRAPH_API_URL.'); + } + const client = await WorkgraphRemoteClient.connect({ + apiUrl, + apiKey: resolveApiKey(opts), + version: CLI_VERSION, + }); + try { + return await action(client); + } finally { + await client.close(); + } +} + function isMaterializedLensResult( value: workgraph.WorkgraphLensResult | workgraph.WorkgraphMaterializedLensResult, ): value is workgraph.WorkgraphMaterializedLensResult { diff --git a/packages/cli/src/cli/core.ts b/packages/cli/src/cli/core.ts index 724d2b4..645b63d 100644 --- a/packages/cli/src/cli/core.ts +++ b/packages/cli/src/cli/core.ts @@ -9,6 +9,7 @@ export type JsonCapableOptions = { workspace?: string; vault?: string; sharedVault?: string; + apiUrl?: string; apiKey?: string; dryRun?: boolean; __dryRunWorkspace?: string; @@ -21,6 +22,7 @@ export function addWorkspaceOption(command: T): T { .option('-w, --workspace ', 'Workgraph workspace path') .option('--vault ', 'Alias for --workspace') .option('--shared-vault ', 'Shared vault path (e.g. mounted via Tailscale)') + .option('--api-url ', 'Workgraph MCP HTTP endpoint URL (or WORKGRAPH_API_URL env)') .option('--api-key ', 'Agent credential API key (or WORKGRAPH_API_KEY env)') .option('--dry-run', 'Execute against a temporary workspace copy and discard changes'); } @@ -196,7 +198,7 @@ export async function runCommand( renderText: (result: T) => string[], ): Promise { try { - const credentialToken = readCredentialToken(opts); + const credentialToken = resolveApiKey(opts); const result = await workgraph.auth.runWithAuthContext({ ...(credentialToken ? { credentialToken } : {}), source: 'cli', @@ -247,7 +249,13 @@ function cleanupDryRunSandbox(opts: JsonCapableOptions): void { delete opts.__dryRunOriginal; } -function readCredentialToken(opts: JsonCapableOptions): string | undefined { +export function resolveApiUrl(opts: JsonCapableOptions): string | undefined { + const fromOption = readNonEmptyString((opts as { apiUrl?: unknown }).apiUrl); + if (fromOption) return fromOption; + return readNonEmptyString(process.env.WORKGRAPH_API_URL); +} + +export function resolveApiKey(opts: JsonCapableOptions): string | undefined { const fromOption = readNonEmptyString((opts as { apiKey?: unknown }).apiKey); if (fromOption) return fromOption; const fromEnv = readNonEmptyString(process.env.WORKGRAPH_AGENT_API_KEY) diff --git a/packages/cli/src/remote-client.ts b/packages/cli/src/remote-client.ts new file mode 100644 index 0000000..5546c9d --- /dev/null +++ b/packages/cli/src/remote-client.ts @@ -0,0 +1,109 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +export interface WorkgraphRemoteClientOptions { + apiUrl: string; + apiKey?: string; + name?: string; + version?: string; +} + +interface McpTextContent { + type: string; + text?: string; +} + +interface McpToolResultEnvelope { + isError?: boolean; + structuredContent?: unknown; + content?: McpTextContent[]; +} + +export class WorkgraphRemoteClient { + private readonly client: Client; + + private closed = false; + + private constructor( + client: Client, + ) { + this.client = client; + } + + static async connect(options: WorkgraphRemoteClientOptions): Promise { + const headers: Record = {}; + const apiKey = readNonEmptyString(options.apiKey); + if (apiKey) { + headers.authorization = `Bearer ${apiKey}`; + } + + const client = new Client({ + name: options.name ?? 'workgraph-cli-remote', + version: options.version ?? '1.0.0', + }); + const transport = new StreamableHTTPClientTransport(new URL(options.apiUrl), { + requestInit: { + headers, + }, + }); + await client.connect(transport); + return new WorkgraphRemoteClient(client); + } + + async listTools(): Promise> { + const result = await this.client.listTools(); + return result.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + })); + } + + async callTool(name: string, args: Record = {}): Promise { + const raw = await this.client.callTool({ + name, + arguments: args, + }) as unknown; + return parseToolResult(raw, name); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + await this.client.close(); + } +} + +function parseToolResult(raw: unknown, toolName: string): T { + const envelope = raw as McpToolResultEnvelope | undefined; + if (!envelope || typeof envelope !== 'object') { + throw new Error(`MCP tool "${toolName}" returned an invalid response.`); + } + if (envelope.isError) { + const text = extractText(envelope.content); + throw new Error(text || `MCP tool "${toolName}" returned an error.`); + } + if (envelope.structuredContent !== undefined) { + return envelope.structuredContent as T; + } + const text = extractText(envelope.content); + if (text) { + try { + return JSON.parse(text) as T; + } catch { + throw new Error(text); + } + } + throw new Error(`MCP tool "${toolName}" returned no structured content.`); +} + +function extractText(content: McpTextContent[] | undefined): string | undefined { + if (!Array.isArray(content)) return undefined; + const textChunk = content.find((entry) => entry.type === 'text' && typeof entry.text === 'string'); + return readNonEmptyString(textChunk?.text); +} + +function readNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap b/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap index 34d24e0..fc41988 100644 --- a/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap +++ b/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap @@ -13,6 +13,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-url ", "--api-key ", "--dry-run", "-h, --help", @@ -22,6 +23,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-url ", "--api-key ", "--dry-run", "-h, --help", @@ -36,6 +38,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-url ", "--api-key ", "--dry-run", "-h, --help", @@ -46,6 +49,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-url ", "--api-key ", "--dry-run", "-h, --help", @@ -58,6 +62,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-url ", "--api-key ", "--dry-run", "-h, --help", @@ -69,6 +74,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-url ", "--api-key ", "--dry-run", "-h, --help", @@ -90,6 +96,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-url ", "--api-key ", "--dry-run", "-h, --help", @@ -102,6 +109,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-url ", "--api-key ", "--dry-run", "-h, --help", @@ -124,6 +132,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-url ", "--api-key ", "--dry-run", "-h, --help", @@ -962,6 +971,109 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`] "name": "wg_trigger_health", "title": "Trigger Health Projection", }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + }, + "description": "Create/update an agent presence heartbeat.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "actor": { + "type": "string", + }, + "capabilities": { + "items": { + "type": "string", + }, + "type": "array", + }, + "currentTask": { + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "status": { + "enum": [ + "online", + "busy", + "offline", + ], + "type": "string", + }, + }, + "required": [ + "name", + ], + "type": "object", + }, + "name": "workgraph_agent_heartbeat", + "title": "Agent Heartbeat", + }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "List known agent presence entries.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "workgraph_agent_list", + "title": "Agent List", + }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + }, + "description": "Register an agent using trust-token fallback flow.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "actor": { + "type": "string", + }, + "capabilities": { + "items": { + "type": "string", + }, + "type": "array", + }, + "currentTask": { + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "role": { + "type": "string", + }, + "status": { + "enum": [ + "online", + "busy", + "offline", + ], + "type": "string", + }, + "token": { + "type": "string", + }, + }, + "required": [ + "name", + ], + "type": "object", + }, + "name": "workgraph_agent_register", + "title": "Agent Register", + }, { "annotations": { "destructiveHint": true, @@ -1644,6 +1756,60 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`] "name": "workgraph_ledger_reconcile", "title": "Ledger Reconcile", }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "List built-in context lenses.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "workgraph_lens_list", + "title": "Workgraph Lens List", + }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Generate one context lens snapshot.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "actor": { + "type": "string", + }, + "lensId": { + "minLength": 1, + "type": "string", + }, + "limit": { + "maximum": 500, + "minimum": 1, + "type": "integer", + }, + "lookbackHours": { + "exclusiveMinimum": 0, + "type": "number", + }, + "outputPath": { + "type": "string", + }, + "staleHours": { + "exclusiveMinimum": 0, + "type": "number", + }, + }, + "required": [ + "lensId", + ], + "type": "object", + }, + "name": "workgraph_lens_show", + "title": "Workgraph Lens Show", + }, { "annotations": { "idempotentHint": true, @@ -2048,6 +2214,44 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`] "name": "workgraph_record_pattern", "title": "Pattern Record", }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Keyword search across markdown body/frontmatter.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "limit": { + "maximum": 1000, + "minimum": 0, + "type": "integer", + }, + "mode": { + "enum": [ + "auto", + "core", + "qmd", + ], + "type": "string", + }, + "text": { + "minLength": 1, + "type": "string", + }, + "type": { + "type": "string", + }, + }, + "required": [ + "text", + ], + "type": "object", + }, + "name": "workgraph_search", + "title": "Workgraph Search", + }, { "annotations": { "destructiveHint": true, diff --git a/packages/mcp-server/src/mcp/tools/read-tools.ts b/packages/mcp-server/src/mcp/tools/read-tools.ts index 8490e91..93c0cba 100644 --- a/packages/mcp-server/src/mcp/tools/read-tools.ts +++ b/packages/mcp-server/src/mcp/tools/read-tools.ts @@ -1,14 +1,17 @@ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { + agent as agentModule, federation as federationModule, graph as graphModule, ledger as ledgerModule, + lens as lensModule, mission as missionModule, orientation as orientationModule, projections as projectionsModule, query as queryModule, registry as registryModule, + searchQmdAdapter as searchQmdAdapterModule, store as storeModule, transport as transportModule, thread as threadModule, @@ -18,14 +21,17 @@ import { resolveActor } from '../auth.js'; import { errorResult, okResult, renderStatusSummary } from '../result.js'; import { type WorkgraphMcpServerOptions } from '../types.js'; +const agent = agentModule; const federation = federationModule; const graph = graphModule; const ledger = ledgerModule; +const lens = lensModule; const mission = missionModule; const orientation = orientationModule; const projections = projectionsModule; const query = queryModule; const registry = registryModule; +const searchQmdAdapter = searchQmdAdapterModule; const store = storeModule; const transport = transportModule; const thread = threadModule; @@ -81,6 +87,29 @@ export function registerReadTools(server: McpServer, options: WorkgraphMcpServer }, ); + server.registerTool( + 'workgraph_agent_list', + { + title: 'Agent List', + description: 'List known agent presence entries.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const agents = agent.list(options.workspacePath); + return okResult( + { agents, count: agents.length }, + `Agent list returned ${agents.length} entry(s).`, + ); + } catch (error) { + return errorResult(error); + } + }, + ); + server.registerTool( 'workgraph_company_context', { @@ -155,6 +184,112 @@ export function registerReadTools(server: McpServer, options: WorkgraphMcpServer }, ); + server.registerTool( + 'workgraph_search', + { + title: 'Workgraph Search', + description: 'Keyword search across markdown body/frontmatter.', + inputSchema: { + text: z.string().min(1), + type: z.string().optional(), + mode: z.enum(['auto', 'core', 'qmd']).optional(), + limit: z.number().int().min(0).max(1000).optional(), + }, + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async (args) => { + try { + const result = searchQmdAdapter.search(options.workspacePath, args.text, { + mode: args.mode, + type: args.type, + limit: args.limit, + }); + return okResult( + { + ...result, + count: result.results.length, + }, + `Search returned ${result.results.length} result(s) in ${result.mode} mode.`, + ); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'workgraph_lens_list', + { + title: 'Workgraph Lens List', + description: 'List built-in context lenses.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const lenses = lens.listContextLenses(); + return okResult( + { lenses, count: lenses.length }, + `Lens list returned ${lenses.length} item(s).`, + ); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'workgraph_lens_show', + { + title: 'Workgraph Lens Show', + description: 'Generate one context lens snapshot.', + inputSchema: { + lensId: z.string().min(1), + actor: z.string().optional(), + lookbackHours: z.number().positive().optional(), + staleHours: z.number().positive().optional(), + limit: z.number().int().min(1).max(500).optional(), + outputPath: z.string().optional(), + }, + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async (args) => { + try { + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + if (args.outputPath) { + const materialized = lens.materializeContextLens(options.workspacePath, args.lensId, { + actor, + lookbackHours: args.lookbackHours, + staleHours: args.staleHours, + limit: args.limit, + outputPath: args.outputPath, + }); + return okResult( + materialized, + `Materialized lens ${materialized.lens} to ${materialized.outputPath}.`, + ); + } + const generated = lens.generateContextLens(options.workspacePath, args.lensId, { + actor, + lookbackHours: args.lookbackHours, + staleHours: args.staleHours, + limit: args.limit, + }); + return okResult(generated, `Generated lens ${generated.lens}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + server.registerTool( 'workgraph_primitive_schema', { diff --git a/packages/mcp-server/src/mcp/tools/write-tools.ts b/packages/mcp-server/src/mcp/tools/write-tools.ts index b846f4c..71c2b47 100644 --- a/packages/mcp-server/src/mcp/tools/write-tools.ts +++ b/packages/mcp-server/src/mcp/tools/write-tools.ts @@ -1,6 +1,7 @@ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { + agent as agentModule, autonomy as autonomyModule, cursorBridge as cursorBridgeModule, dispatch as dispatchModule, @@ -18,6 +19,7 @@ import { checkWriteGate, resolveActor } from '../auth.js'; import { errorResult, okResult } from '../result.js'; import { type WorkgraphMcpServerOptions } from '../types.js'; +const agent = agentModule; const autonomy = autonomyModule; const cursorBridge = cursorBridgeModule; const dispatch = dispatchModule; @@ -62,6 +64,92 @@ const triggerConditionSchema = z.union([ const triggerContextSchema = z.object({}).passthrough(); export function registerWriteTools(server: McpServer, options: WorkgraphMcpServerOptions): void { + server.registerTool( + 'workgraph_agent_register', + { + title: 'Agent Register', + description: 'Register an agent using trust-token fallback flow.', + inputSchema: { + name: z.string().min(1), + actor: z.string().optional(), + token: z.string().optional(), + role: z.string().optional(), + capabilities: z.array(z.string()).optional(), + status: z.enum(['online', 'busy', 'offline']).optional(), + currentTask: z.string().optional(), + }, + annotations: { + destructiveHint: true, + idempotentHint: false, + }, + }, + async (args) => { + try { + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['mcp:write'], { + action: 'mcp.agent.register', + target: 'agents', + }); + if (!gate.allowed) return errorResult(gate.reason); + const token = typeof args.token === 'string' && args.token.trim().length > 0 + ? args.token.trim() + : process.env.WORKGRAPH_TRUST_TOKEN; + if (!token) { + return errorResult('Missing trust token. Provide token argument or set WORKGRAPH_TRUST_TOKEN.'); + } + const registered = agent.registerAgent(options.workspacePath, args.name, { + token, + role: args.role, + capabilities: args.capabilities, + status: args.status, + currentTask: args.currentTask, + actor: args.actor, + }); + return okResult(registered, `Registered agent ${registered.agentName}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'workgraph_agent_heartbeat', + { + title: 'Agent Heartbeat', + description: 'Create/update an agent presence heartbeat.', + inputSchema: { + name: z.string().min(1), + actor: z.string().optional(), + status: z.enum(['online', 'busy', 'offline']).optional(), + currentTask: z.string().optional(), + capabilities: z.array(z.string()).optional(), + }, + annotations: { + destructiveHint: true, + idempotentHint: false, + }, + }, + async (args) => { + try { + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['agent:heartbeat', 'mcp:write'], { + action: 'mcp.agent.heartbeat', + target: args.name, + }); + if (!gate.allowed) return errorResult(gate.reason); + const presence = agent.heartbeat(options.workspacePath, args.name, { + actor: args.actor, + status: args.status, + currentTask: args.currentTask, + capabilities: args.capabilities, + }); + return okResult({ presence }, `Heartbeated agent ${args.name}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + server.registerTool( 'workgraph_create_mission', { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c90583..4c59eb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: packages/cli: dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@4.3.6) '@versatly/workgraph-control-api': specifier: workspace:* version: link:../control-api diff --git a/tests/integration/remote-cli.test.ts b/tests/integration/remote-cli.test.ts new file mode 100644 index 0000000..708e8a4 --- /dev/null +++ b/tests/integration/remote-cli.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { policy as policyModule, workspace as workspaceModule } from '@versatly/workgraph-kernel'; +import { startWorkgraphMcpHttpServer } from '@versatly/workgraph-mcp-server'; +import { ensureCliBuiltForTests } from '../helpers/cli-build.js'; + +interface CliEnvelope { + ok: boolean; + data?: unknown; + error?: string; +} + +const policy = policyModule; +const workspace = workspaceModule; + +async function runCli(args: string[]): Promise { + ensureCliBuiltForTests(); + return new Promise((resolve, reject) => { + const child = spawn('node', [path.resolve('bin/workgraph.js'), ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('close', () => { + const output = (stdout || stderr || '').trim(); + try { + resolve(JSON.parse(output) as CliEnvelope); + } catch { + reject(new Error(`CLI output was not valid JSON for args [${args.join(' ')}]: ${output}`)); + } + }); + }); +} + +describe('CLI remote/API mode', () => { + beforeAll(() => { + ensureCliBuiltForTests(); + }); + + it('routes key commands through MCP HTTP when --api-url is set', async () => { + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-cli-remote-')); + const init = workspace.initWorkspace(workspacePath, { + createReadme: false, + createBases: false, + }); + policy.upsertParty(workspacePath, 'remote-admin', { + roles: ['operator'], + capabilities: ['mcp:write', 'thread:create', 'thread:claim', 'thread:done', 'checkpoint:create', 'agent:heartbeat'], + }, { + actor: 'remote-admin', + skipAuthorization: true, + }); + + const handle = await startWorkgraphMcpHttpServer({ + workspacePath, + defaultActor: 'remote-admin', + host: '127.0.0.1', + port: 0, + bearerToken: 'remote-test-token', + }); + + const remoteWorkspacePath = path.join(os.tmpdir(), 'wg-cli-remote-nonexistent-workspace'); + try { + const commonRemoteArgs = ['--api-url', handle.url, '--api-key', 'remote-test-token', '-w', remoteWorkspacePath]; + + const threadCreate = await runCli([ + 'thread', 'create', 'Remote API Thread', + ...commonRemoteArgs, + '--goal', 'Validate remote thread create', + '--actor', 'remote-admin', + '--json', + ]); + if (!threadCreate.ok) { + throw new Error(`thread create failed: ${JSON.stringify(threadCreate)}`); + } + const threadPath = String((threadCreate.data as { thread: { path: string } }).thread.path); + + const threadList = await runCli([ + 'thread', 'list', + ...commonRemoteArgs, + '--json', + ]); + expect(threadList.ok).toBe(true); + expect(((threadList.data as { count: number }).count) >= 1).toBe(true); + + const threadNext = await runCli([ + 'thread', 'next', + ...commonRemoteArgs, + '--actor', 'remote-admin', + '--json', + ]); + expect(threadNext.ok).toBe(true); + + const threadClaim = await runCli([ + 'thread', 'claim', threadPath, + ...commonRemoteArgs, + '--actor', 'remote-admin', + '--json', + ]); + expect(threadClaim.ok).toBe(true); + + const threadDone = await runCli([ + 'thread', 'done', threadPath, + ...commonRemoteArgs, + '--actor', 'remote-admin', + '--output', 'Finished via remote API mode https://cursor.com/remote-proof', + '--json', + ]); + expect(threadDone.ok, JSON.stringify(threadDone)).toBe(true); + + const status = await runCli([ + 'status', + ...commonRemoteArgs, + '--json', + ]); + expect(status.ok).toBe(true); + + const brief = await runCli([ + 'brief', + ...commonRemoteArgs, + '--actor', 'remote-admin', + '--json', + ]); + expect(brief.ok).toBe(true); + + const checkpoint = await runCli([ + 'checkpoint', 'Remote checkpoint summary', + ...commonRemoteArgs, + '--actor', 'remote-admin', + '--next', 'finalize validation', + '--json', + ]); + expect(checkpoint.ok).toBe(true); + + const register = await runCli([ + 'agent', 'register', 'remote-agent', + ...commonRemoteArgs, + '--actor', 'remote-admin', + '--token', init.bootstrapTrustToken, + '--json', + ]); + expect(register.ok).toBe(true); + + const heartbeat = await runCli([ + 'agent', 'heartbeat', 'remote-agent', + ...commonRemoteArgs, + '--actor', 'remote-admin', + '--status', 'online', + '--json', + ]); + expect(heartbeat.ok).toBe(true); + + const agentList = await runCli([ + 'agent', 'list', + ...commonRemoteArgs, + '--json', + ]); + expect(agentList.ok).toBe(true); + expect(((agentList.data as { count: number }).count) >= 1).toBe(true); + + const search = await runCli([ + 'search', 'Remote API Thread', + ...commonRemoteArgs, + '--json', + ]); + expect(search.ok).toBe(true); + expect(((search.data as { count: number }).count) >= 1).toBe(true); + + const query = await runCli([ + 'query', + ...commonRemoteArgs, + '--type', 'thread', + '--json', + ]); + expect(query.ok).toBe(true); + expect(((query.data as { count: number }).count) >= 1).toBe(true); + + const lensList = await runCli([ + 'lens', 'list', + ...commonRemoteArgs, + '--json', + ]); + expect(lensList.ok).toBe(true); + expect(((lensList.data as { lenses: unknown[] }).lenses.length) > 0).toBe(true); + + const lensShow = await runCli([ + 'lens', 'show', 'my-work', + ...commonRemoteArgs, + '--actor', 'remote-admin', + '--json', + ]); + expect(lensShow.ok).toBe(true); + + const remoteTest = await runCli([ + 'remote', 'test', + '--api-url', handle.url, + '--api-key', 'remote-test-token', + '--json', + ]); + expect(remoteTest.ok).toBe(true); + expect(((remoteTest.data as { toolCount: number }).toolCount) > 0).toBe(true); + } finally { + await handle.close(); + fs.rmSync(workspacePath, { recursive: true, force: true }); + } + }, 60_000); +});