From a521390e4f739e97912692dd9196056341ed2a2a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Mar 2026 07:20:13 +0000 Subject: [PATCH 1/6] Route trigger actions through transport outbox Co-authored-by: G9Pedro --- packages/kernel/src/trigger-engine.test.ts | 35 ++++++++ packages/kernel/src/trigger-engine.ts | 87 +++++++++++++++++++ .../mcp-server/src/mcp/tools/write-tools.ts | 22 +++++ .../mcp-server/src/transport-tools.test.ts | 40 +++++++++ 4 files changed, 184 insertions(+) diff --git a/packages/kernel/src/trigger-engine.test.ts b/packages/kernel/src/trigger-engine.test.ts index 9991a98..69b3be5 100644 --- a/packages/kernel/src/trigger-engine.test.ts +++ b/packages/kernel/src/trigger-engine.test.ts @@ -6,6 +6,7 @@ import * as registry from './registry.js'; import * as safety from './safety.js'; import * as store from './store.js'; import * as thread from './thread.js'; +import * as transport from './transport/index.js'; import * as triggerEngine from './trigger-engine.js'; let workspacePath: string; @@ -116,6 +117,40 @@ describe('trigger engine', () => { expect(state.triggers[triggerPrimitive.path]?.cooldownUntil).toBeDefined(); }); + it('records trigger action deliveries in the transport outbox', () => { + store.create(workspacePath, 'trigger', { + title: 'Transported trigger action', + status: 'active', + condition: { type: 'event', event: 'thread-complete' }, + action: { + type: 'create-thread', + title: 'Transport follow-up {{matched_event_latest_target}}', + goal: 'Verify transport outbox trigger delivery', + }, + cooldown: 0, + }, '# Trigger\n', 'system'); + + const seededThread = thread.createThread(workspacePath, 'Transport source', 'Ship transport source', 'agent-dev'); + thread.claim(workspacePath, seededThread.path, 'agent-dev'); + thread.done(workspacePath, seededThread.path, 'agent-dev', 'Transport source done https://github.com/versatly/workgraph/pull/88'); + + const first = triggerEngine.runTriggerEngineCycle(workspacePath, { actor: 'system' }); + expect(first.fired).toBe(0); + + const nextThread = thread.createThread(workspacePath, 'Transport source 2', 'Ship transport source 2', 'agent-dev'); + thread.claim(workspacePath, nextThread.path, 'agent-dev'); + thread.done(workspacePath, nextThread.path, 'agent-dev', 'Transport source 2 done https://github.com/versatly/workgraph/pull/89'); + + const second = triggerEngine.runTriggerEngineCycle(workspacePath, { actor: 'system' }); + expect(second.fired).toBe(1); + + const outbox = transport.listTransportOutbox(workspacePath) + .filter((record) => record.deliveryHandler === 'trigger-action'); + expect(outbox.length).toBeGreaterThanOrEqual(1); + expect(outbox[0]?.status).toBe('delivered'); + expect(outbox[0]?.envelope.topic).toBe('create-thread'); + }); + it('matches event trigger patterns against ledger events', () => { const patternTrigger = store.create(workspacePath, 'trigger', { title: 'Pattern match done events', diff --git a/packages/kernel/src/trigger-engine.ts b/packages/kernel/src/trigger-engine.ts index 0f267fc..90f1c73 100644 --- a/packages/kernel/src/trigger-engine.ts +++ b/packages/kernel/src/trigger-engine.ts @@ -10,6 +10,7 @@ import * as dispatch from './dispatch.js'; import * as ledger from './ledger.js'; import * as safety from './safety.js'; import * as store from './store.js'; +import * as transport from './transport/index.js'; import { matchesCronSchedule, nextCronMatch, parseCronExpression, type CronSchedule } from './cron.js'; import type { DispatchRun, PrimitiveInstance } from './types.js'; @@ -243,6 +244,14 @@ export interface TriggerRunEvidenceLoopOptions extends TriggerEngineCycleOptions retryFailedRuns?: boolean; } +export interface TriggerActionReplayInput { + triggerPath: string; + action: Record; + context: Record; + actor: string; + eventKey?: string; +} + export function triggerStatePath(workspacePath: string): string { return path.join(workspacePath, TRIGGER_STATE_FILE); } @@ -798,6 +807,62 @@ function executeTriggerAction( context: Record, defaultActor: string, eventKey: string | undefined, +): Record { + const actor = action.actor ?? (trigger.synthesis?.actor ?? defaultActor); + const envelope = transport.createTransportEnvelope({ + direction: 'outbound', + channel: 'trigger-action', + topic: action.type, + source: trigger.path, + target: action.type, + correlationId: eventKey, + dedupKeys: [ + `${trigger.path}:${action.type}:${eventKey ?? 'manual'}`, + ...(eventKey ? [`trigger-event:${eventKey}`] : []), + ], + payload: { + triggerPath: trigger.path, + action, + context, + actor, + eventKey, + }, + }); + const outbox = transport.createTransportOutboxRecord(workspacePath, { + envelope, + deliveryHandler: 'trigger-action', + deliveryTarget: trigger.path, + message: `Executing trigger action ${action.type} for ${trigger.path}.`, + }); + try { + const result = performTriggerAction(workspacePath, trigger, action, context, defaultActor, eventKey); + transport.markTransportOutboxDelivered( + workspacePath, + outbox.id, + `Trigger action ${action.type} delivered successfully.`, + ); + return result; + } catch (error) { + transport.markTransportOutboxFailed(workspacePath, outbox.id, { + message: errorMessage(error), + context: { + triggerPath: trigger.path, + actionType: action.type, + actor, + eventKey, + }, + }); + throw error; + } +} + +function performTriggerAction( + workspacePath: string, + trigger: NormalizedTrigger, + action: TriggerAction, + context: Record, + defaultActor: string, + eventKey: string | undefined, ): Record { const actor = action.actor ?? (trigger.synthesis?.actor ?? defaultActor); switch (action.type) { @@ -900,6 +965,28 @@ function executeTriggerAction( } } +export function replayTriggerActionDelivery( + workspacePath: string, + input: TriggerActionReplayInput, +): Record { + const trigger = listNormalizedTriggers(workspacePath).find((candidate) => candidate.path === input.triggerPath); + if (!trigger) { + throw new Error(`Trigger not found for replay: ${input.triggerPath}`); + } + const action = parseTriggerAction(input.action); + if (!action) { + throw new Error(`Invalid trigger action payload for replay: ${input.triggerPath}`); + } + return performTriggerAction( + workspacePath, + trigger, + action, + isRecord(input.context) ? input.context : {}, + input.actor, + input.eventKey, + ); +} + function createThreadFromTrigger( workspacePath: string, trigger: NormalizedTrigger, diff --git a/packages/mcp-server/src/mcp/tools/write-tools.ts b/packages/mcp-server/src/mcp/tools/write-tools.ts index dca7dc9..a471c60 100644 --- a/packages/mcp-server/src/mcp/tools/write-tools.ts +++ b/packages/mcp-server/src/mcp/tools/write-tools.ts @@ -839,6 +839,10 @@ async function replayTransportRecord( await replayRuntimeBridge(workspacePath, record); return; } + if (record.deliveryHandler === 'trigger-action') { + await replayTriggerAction(workspacePath, record); + return; + } throw new Error(`Unsupported transport replay handler "${record.deliveryHandler}".`); }); if (!replayed) { @@ -902,3 +906,21 @@ async function replayRuntimeBridge( : undefined, }); } + +async function replayTriggerAction( + workspacePath: string, + record: ReturnType extends infer T ? Exclude : never, +): Promise { + const payload = record.envelope.payload; + triggerEngine.replayTriggerActionDelivery(workspacePath, { + triggerPath: typeof payload.triggerPath === 'string' ? payload.triggerPath : record.deliveryTarget, + action: payload.action && typeof payload.action === 'object' && !Array.isArray(payload.action) + ? payload.action as Record + : {}, + context: payload.context && typeof payload.context === 'object' && !Array.isArray(payload.context) + ? payload.context as Record + : {}, + actor: typeof payload.actor === 'string' ? payload.actor : 'system', + eventKey: typeof payload.eventKey === 'string' ? payload.eventKey : undefined, + }); +} diff --git a/packages/mcp-server/src/transport-tools.test.ts b/packages/mcp-server/src/transport-tools.test.ts index 8a05a71..ae3f274 100644 --- a/packages/mcp-server/src/transport-tools.test.ts +++ b/packages/mcp-server/src/transport-tools.test.ts @@ -8,14 +8,20 @@ import { cursorBridge as cursorBridgeModule, policy as policyModule, registry as registryModule, + thread as threadModule, transport as transportModule, + triggerEngine as triggerEngineModule, + store as storeModule, } from '@versatly/workgraph-kernel'; import { createWorkgraphMcpServer } from './mcp-server.js'; const cursorBridge = cursorBridgeModule; const policy = policyModule; const registry = registryModule; +const store = storeModule; +const thread = threadModule; const transport = transportModule; +const triggerEngine = triggerEngineModule; let workspacePath: string; @@ -78,6 +84,28 @@ describe('transport MCP tools', () => { message: 'synthetic failure', }); + store.create(workspacePath, 'trigger', { + title: 'Replayable trigger action', + status: 'active', + condition: { type: 'event', event: 'thread-complete' }, + action: { + type: 'dispatch-run', + objective: 'Replay dispatch {{matched_event_latest_target}}', + }, + cooldown: 0, + }, '# Trigger\n', 'system'); + const sourceOne = thread.createThread(workspacePath, 'Replay seed', 'seed event', 'agent-seed'); + thread.claim(workspacePath, sourceOne.path, 'agent-seed'); + thread.done(workspacePath, sourceOne.path, 'agent-seed', 'seed done https://github.com/versatly/workgraph/pull/100'); + triggerEngine.runTriggerEngineCycle(workspacePath, { actor: 'system' }); + const sourceTwo = thread.createThread(workspacePath, 'Replay seed 2', 'second event', 'agent-seed'); + thread.claim(workspacePath, sourceTwo.path, 'agent-seed'); + thread.done(workspacePath, sourceTwo.path, 'agent-seed', 'seed 2 done https://github.com/versatly/workgraph/pull/101'); + triggerEngine.runTriggerEngineCycle(workspacePath, { actor: 'system' }); + const triggerActionOutbox = transport.listTransportOutbox(workspacePath) + .find((record) => record.deliveryHandler === 'trigger-action'); + expect(triggerActionOutbox).toBeDefined(); + const server = createWorkgraphMcpServer({ workspacePath, defaultActor: 'agent-mcp', @@ -132,6 +160,18 @@ describe('transport MCP tools', () => { expect(isToolError(replayed)).toBe(false); const replayedPayload = getStructured<{ status: string }>(replayed); expect(replayedPayload.status).toBe('replayed'); + + const replayedTrigger = await client.callTool({ + name: 'wg_transport_replay', + arguments: { + actor: 'agent-mcp', + recordType: 'outbox', + id: triggerActionOutbox!.id, + }, + }); + expect(isToolError(replayedTrigger)).toBe(false); + const replayedTriggerPayload = getStructured<{ status: string }>(replayedTrigger); + expect(replayedTriggerPayload.status).toBe('replayed'); } finally { await client.close(); await server.close(); From 234f64604ec6aaea9e224eace2bfeacf244540bd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Mar 2026 07:38:05 +0000 Subject: [PATCH 2/6] Add federation identity and ref resolution Co-authored-by: G9Pedro --- packages/kernel/src/federation-helpers.ts | 174 +++++++++ .../kernel/src/federation-resolve.test.ts | 180 +++++++++ packages/kernel/src/federation.ts | 354 +++++++++++++++++- 3 files changed, 689 insertions(+), 19 deletions(-) create mode 100644 packages/kernel/src/federation-helpers.ts create mode 100644 packages/kernel/src/federation-resolve.test.ts diff --git a/packages/kernel/src/federation-helpers.ts b/packages/kernel/src/federation-helpers.ts new file mode 100644 index 0000000..0250ead --- /dev/null +++ b/packages/kernel/src/federation-helpers.ts @@ -0,0 +1,174 @@ +import { createHash } from 'node:crypto'; +import path from 'node:path'; + +export const FEDERATION_PROTOCOL_VERSION = 'wg-federation/v1'; +export const DEFAULT_FEDERATION_CAPABILITIES = [ + 'resolve-ref', + 'search', + 'read-primitive', + 'read-thread', +] as const; + +export type FederationTrustLevel = 'local' | 'read-only'; +export type FederationTransportKind = 'local-path' | 'http' | 'mcp'; + +export interface FederationWorkspaceIdentity { + workspaceId: string; + protocolVersion: string; + capabilities: string[]; + trustLevel: FederationTrustLevel; +} + +export interface FederatedPrimitiveRef { + workspaceId: string; + primitiveType: string; + primitiveSlug: string; + protocolVersion: string; + transport: FederationTransportKind; + primitivePath?: string; + remoteAlias?: string; +} + +export function deriveWorkspaceId(workspacePath: string): string { + const normalized = path.resolve(workspacePath).replace(/\\/g, '/'); + const hash = createHash('sha256').update(`workgraph-federation:${normalized}`).digest('hex'); + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`; +} + +export function normalizeFederationWorkspaceIdentity( + value: unknown, + workspacePath: string, + fallbackTrustLevel: FederationTrustLevel = 'local', +): FederationWorkspaceIdentity { + const record = asRecord(value); + return { + workspaceId: normalizeOptionalString(record.workspaceId) ?? normalizeOptionalString(record.workspace_id) ?? deriveWorkspaceId(workspacePath), + protocolVersion: normalizeProtocolVersion(record.protocolVersion ?? record.protocol_version), + capabilities: normalizeCapabilitySet(record.capabilities), + trustLevel: normalizeTrustLevel(record.trustLevel ?? record.trust_level, fallbackTrustLevel), + }; +} + +export function normalizeProtocolVersion(value: unknown): string { + return normalizeOptionalString(value) ?? FEDERATION_PROTOCOL_VERSION; +} + +export function normalizeCapabilitySet(value: unknown): string[] { + const raw = Array.isArray(value) + ? value + : DEFAULT_FEDERATION_CAPABILITIES; + return [...new Set(raw.map((entry) => String(entry ?? '').trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)); +} + +export function normalizeTrustLevel(value: unknown, fallback: FederationTrustLevel = 'read-only'): FederationTrustLevel { + const normalized = normalizeOptionalString(value)?.toLowerCase(); + if (normalized === 'local' || normalized === 'read-only') return normalized; + return fallback; +} + +export function normalizeTransportKind(value: unknown, fallback: FederationTransportKind = 'local-path'): FederationTransportKind { + const normalized = normalizeOptionalString(value)?.toLowerCase(); + if (normalized === 'local-path' || normalized === 'http' || normalized === 'mcp') return normalized; + return fallback; +} + +export function buildFederatedPrimitiveRef(input: { + workspaceId: string; + primitiveType: string; + primitivePath: string; + protocolVersion?: string; + transport?: FederationTransportKind; + remoteAlias?: string; +}): FederatedPrimitiveRef { + return { + workspaceId: input.workspaceId, + primitiveType: input.primitiveType, + primitiveSlug: primitiveSlugFromPath(input.primitivePath), + protocolVersion: normalizeProtocolVersion(input.protocolVersion), + transport: input.transport ?? 'local-path', + primitivePath: normalizePrimitivePath(input.primitivePath), + ...(input.remoteAlias ? { remoteAlias: input.remoteAlias } : {}), + }; +} + +export function normalizeFederatedPrimitiveRef(value: unknown): FederatedPrimitiveRef | null { + const record = asRecord(value); + const workspaceId = normalizeOptionalString(record.workspaceId) ?? normalizeOptionalString(record.workspace_id); + const primitiveType = normalizeOptionalString(record.primitiveType) ?? normalizeOptionalString(record.primitive_type); + const primitiveSlug = normalizeOptionalString(record.primitiveSlug) ?? normalizeOptionalString(record.primitive_slug); + if (!workspaceId || !primitiveType || !primitiveSlug) return null; + return { + workspaceId, + primitiveType, + primitiveSlug, + protocolVersion: normalizeProtocolVersion(record.protocolVersion ?? record.protocol_version), + transport: normalizeTransportKind(record.transport), + ...(normalizeOptionalString(record.primitivePath) ?? normalizeOptionalString(record.primitive_path) + ? { primitivePath: normalizePrimitivePath(normalizeOptionalString(record.primitivePath) ?? normalizeOptionalString(record.primitive_path)!) } + : {}), + ...(normalizeOptionalString(record.remoteAlias) ?? normalizeOptionalString(record.remote_alias) + ? { remoteAlias: normalizeOptionalString(record.remoteAlias) ?? normalizeOptionalString(record.remote_alias)! } + : {}), + }; +} + +export function parseFederatedRef(value: unknown): FederatedPrimitiveRef | null { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return normalizeFederatedPrimitiveRef(value); + } + const raw = normalizeOptionalString(value); + if (!raw) return null; + if (!raw.startsWith('federation://')) return null; + const payload = raw.slice('federation://'.length); + const firstSlash = payload.indexOf('/'); + if (firstSlash <= 0) return null; + const remoteAlias = payload.slice(0, firstSlash); + const primitivePath = normalizePrimitivePath(payload.slice(firstSlash + 1)); + const primitiveType = primitiveTypeFromPath(primitivePath); + const primitiveSlug = primitiveSlugFromPath(primitivePath); + if (!primitiveType || !primitiveSlug) return null; + return { + workspaceId: remoteAlias, + primitiveType, + primitiveSlug, + protocolVersion: FEDERATION_PROTOCOL_VERSION, + transport: 'local-path', + primitivePath, + remoteAlias, + }; +} + +export function buildLegacyFederationLink(remoteAlias: string, primitivePath: string): string { + return `federation://${remoteAlias}/${normalizePrimitivePath(primitivePath)}`; +} + +export function primitiveSlugFromPath(primitivePath: string): string { + const normalized = normalizePrimitivePath(primitivePath); + const basename = path.basename(normalized, '.md'); + return basename; +} + +export function primitiveTypeFromPath(primitivePath: string): string { + const normalized = normalizePrimitivePath(primitivePath); + const [directory] = normalized.split('/'); + if (!directory) return ''; + if (directory === 'threads') return 'thread'; + return directory.endsWith('s') ? directory.slice(0, -1) : directory; +} + +export function normalizePrimitivePath(value: unknown): string { + const raw = normalizeOptionalString(value) ?? ''; + if (!raw) return ''; + return raw.endsWith('.md') ? raw.replace(/\\/g, '/') : `${raw.replace(/\\/g, '/')}.md`; +} + +export function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} diff --git a/packages/kernel/src/federation-resolve.test.ts b/packages/kernel/src/federation-resolve.test.ts new file mode 100644 index 0000000..e5434b2 --- /dev/null +++ b/packages/kernel/src/federation-resolve.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import * as federation from './federation.js'; +import { loadRegistry, saveRegistry } from './registry.js'; +import * as store from './store.js'; +import { createThread } from './thread.js'; + +let workspacePath: string; +let remoteWorkspacePath: string; + +beforeEach(() => { + workspacePath = createWorkspace('wg-federation-resolve-'); + remoteWorkspacePath = createWorkspace('wg-federation-resolve-remote-'); +}); + +afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); + fs.rmSync(remoteWorkspacePath, { recursive: true, force: true }); +}); + +describe('federation identity and resolution', () => { + it('creates stable local workspace identity in federation config', () => { + const config = federation.ensureFederationConfig(workspacePath); + expect(config.workspace.workspaceId).toMatch(/^[0-9a-f-]{36}$/); + expect(config.workspace.protocolVersion).toBe('wg-federation/v1'); + expect(config.workspace.capabilities).toContain('resolve-ref'); + expect(config.workspace.trustLevel).toBe('local'); + }); + + it('stores typed federated refs alongside legacy links and resolves them remotely', () => { + createThread(workspacePath, 'Local Thread', 'Local handoff', 'agent-local'); + const remoteThread = createThread(remoteWorkspacePath, 'Remote Thread', 'Remote dependency', 'agent-remote'); + federation.ensureFederationConfig(remoteWorkspacePath); + federation.addRemoteWorkspace(workspacePath, { + id: 'remote-main', + path: remoteWorkspacePath, + name: 'Remote Main', + }); + + const linked = federation.linkThreadToRemoteWorkspace( + workspacePath, + 'threads/local-thread.md', + 'remote-main', + remoteThread.path, + 'agent-local', + ); + expect(linked.ref.workspaceId).toBe(federation.ensureFederationConfig(remoteWorkspacePath).workspace.workspaceId); + expect(linked.ref.primitiveType).toBe('thread'); + expect(linked.ref.primitiveSlug).toBe('remote-thread'); + expect(linked.ref.protocolVersion).toBe('wg-federation/v1'); + expect(readRefs(linked.thread.fields.federation_refs)).toHaveLength(1); + + const resolved = federation.resolveFederatedRef(workspacePath, linked.ref); + expect(resolved.source).toBe('remote'); + expect(resolved.authority).toBe('remote'); + expect(resolved.instance.path).toBe(remoteThread.path); + }); + + it('prefers local authority when local and remote primitive slugs collide', () => { + const localThread = createThread(workspacePath, 'Remote Thread', 'Local wins', 'agent-local'); + const remoteThread = createThread(remoteWorkspacePath, 'Remote Thread', 'Remote collides', 'agent-remote'); + federation.ensureFederationConfig(remoteWorkspacePath); + federation.addRemoteWorkspace(workspacePath, { + id: 'remote-main', + path: remoteWorkspacePath, + }); + + const linked = federation.linkThreadToRemoteWorkspace( + workspacePath, + localThread.path, + 'remote-main', + remoteThread.path, + 'agent-local', + ); + const resolved = federation.resolveFederatedRef(workspacePath, linked.ref); + expect(resolved.source).toBe('local'); + expect(resolved.authority).toBe('local'); + expect(resolved.instance.path).toBe(localThread.path); + expect(resolved.warning).toContain('overrides remote authority'); + }); + + it('fails clearly on protocol or capability mismatch and surfaces staleness', () => { + const remoteThread = createThread(remoteWorkspacePath, 'Remote Capability', 'Remote capability target', 'agent-remote'); + federation.saveFederationConfig(remoteWorkspacePath, { + version: 2, + updatedAt: new Date().toISOString(), + workspace: { + workspaceId: 'remote-capability-test', + protocolVersion: 'wg-federation/v999', + capabilities: ['search'], + trustLevel: 'read-only', + }, + remotes: [], + }); + federation.addRemoteWorkspace(workspacePath, { + id: 'remote-main', + path: remoteWorkspacePath, + }); + + expect(() => federation.resolveFederatedRef( + workspacePath, + { + workspaceId: 'remote-capability-test', + primitiveType: 'thread', + primitiveSlug: 'remote-capability', + primitivePath: remoteThread.path, + protocolVersion: 'wg-federation/v999', + transport: 'local-path', + remoteAlias: 'remote-main', + }, + )).toThrow('Protocol mismatch'); + + federation.saveFederationConfig(remoteWorkspacePath, { + version: 2, + updatedAt: new Date().toISOString(), + workspace: { + workspaceId: 'remote-capability-test', + protocolVersion: 'wg-federation/v1', + capabilities: ['search'], + trustLevel: 'read-only', + }, + remotes: [], + }); + expect(() => federation.resolveFederatedRef( + workspacePath, + { + workspaceId: 'remote-capability-test', + primitiveType: 'thread', + primitiveSlug: 'remote-capability', + primitivePath: remoteThread.path, + protocolVersion: 'wg-federation/v1', + transport: 'local-path', + remoteAlias: 'remote-main', + }, + )).toThrow('does not support federated ref resolution'); + + federation.saveFederationConfig(remoteWorkspacePath, { + version: 2, + updatedAt: new Date().toISOString(), + workspace: { + workspaceId: 'remote-capability-test', + protocolVersion: 'wg-federation/v1', + capabilities: ['search', 'resolve-ref', 'read-thread'], + trustLevel: 'read-only', + }, + remotes: [], + }); + federation.syncFederation(workspacePath, 'sync-agent'); + store.update(remoteWorkspacePath, remoteThread.path, { status: 'blocked' }, undefined, 'agent-remote'); + const resolved = federation.resolveFederatedRef( + workspacePath, + { + workspaceId: 'remote-capability-test', + primitiveType: 'thread', + primitiveSlug: 'remote-capability', + primitivePath: remoteThread.path, + protocolVersion: 'wg-federation/v1', + transport: 'local-path', + remoteAlias: 'remote-main', + }, + ); + expect(resolved.stale).toBe(true); + expect(resolved.warning).toContain('stale'); + }); +}); + +function createWorkspace(prefix: string): string { + const target = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const registry = loadRegistry(target); + saveRegistry(target, registry); + return target; +} + +function readRefs(value: unknown): Array> { + return Array.isArray(value) + ? value.filter((entry): entry is Record => !!entry && typeof entry === 'object') + : []; +} diff --git a/packages/kernel/src/federation.ts b/packages/kernel/src/federation.ts index 2342cef..d2d2172 100644 --- a/packages/kernel/src/federation.ts +++ b/packages/kernel/src/federation.ts @@ -1,6 +1,29 @@ import fs from 'node:fs'; import path from 'node:path'; import YAML from 'yaml'; +import { + DEFAULT_FEDERATION_CAPABILITIES, + FEDERATION_PROTOCOL_VERSION, + asRecord, + buildFederatedPrimitiveRef, + buildLegacyFederationLink, + deriveWorkspaceId, + normalizeCapabilitySet, + normalizeFederatedPrimitiveRef, + normalizeFederationWorkspaceIdentity, + normalizeOptionalString, + normalizePrimitivePath, + normalizeProtocolVersion, + normalizeTransportKind, + normalizeTrustLevel, + parseFederatedRef, + primitiveSlugFromPath, + primitiveTypeFromPath, + type FederatedPrimitiveRef, + type FederationTransportKind, + type FederationTrustLevel, + type FederationWorkspaceIdentity, +} from './federation-helpers.js'; import * as query from './query.js'; import * as store from './store.js'; import type { PrimitiveInstance } from './types.js'; @@ -9,11 +32,17 @@ const FEDERATION_CONFIG_FILE = '.workgraph/federation.yaml'; export interface RemoteWorkspaceRef { id: string; + workspaceId: string; name: string; path: string; enabled: boolean; tags: string[]; + protocolVersion: string; + capabilities: string[]; + trustLevel: FederationTrustLevel; + transport: FederationTransportKind; addedAt: string; + lastHandshakeAt?: string; lastSyncedAt?: string; lastSyncStatus?: 'synced' | 'error'; lastSyncError?: string; @@ -22,6 +51,7 @@ export interface RemoteWorkspaceRef { export interface FederationConfig { version: number; updatedAt: string; + workspace: FederationWorkspaceIdentity; remotes: RemoteWorkspaceRef[]; } @@ -51,6 +81,7 @@ export interface LinkFederatedThreadResult { thread: PrimitiveInstance; created: boolean; link: string; + ref: FederatedPrimitiveRef; } export interface FederatedSearchOptions { @@ -63,6 +94,9 @@ export interface FederatedSearchOptions { export interface FederatedSearchResultItem { workspaceId: string; workspacePath: string; + protocolVersion: string; + trustLevel: FederationTrustLevel; + stale: boolean; instance: PrimitiveInstance; } @@ -84,11 +118,15 @@ export interface SyncFederationOptions { export interface FederationSyncRemoteResult { id: string; + workspaceId: string; workspacePath: string; enabled: boolean; status: 'synced' | 'skipped' | 'error'; threadCount: number; openThreadCount: number; + protocolVersion?: string; + capabilities?: string[]; + trustLevel?: FederationTrustLevel; syncedAt?: string; error?: string; } @@ -100,6 +138,38 @@ export interface FederationSyncResult { remotes: FederationSyncRemoteResult[]; } +export interface FederationHandshakeResult { + remote: RemoteWorkspaceRef; + identity: FederationWorkspaceIdentity; + compatible: boolean; + supportsRead: boolean; + supportsSearch: boolean; + error?: string; +} + +export interface FederationResolveResult { + ref: FederatedPrimitiveRef; + source: 'local' | 'remote'; + authority: 'local' | 'remote'; + workspaceId: string; + workspacePath: string; + protocolVersion: string; + trustLevel: FederationTrustLevel; + stale: boolean; + capabilityCheck: { + supportsRead: boolean; + supportsSearch: boolean; + }; + instance: PrimitiveInstance; + warning?: string; +} + +export interface FederationStatusResult { + workspace: FederationWorkspaceIdentity; + configPath: string; + remotes: FederationHandshakeResult[]; +} + export function federationConfigPath(workspacePath: string): string { return path.join(workspacePath, FEDERATION_CONFIG_FILE); } @@ -107,12 +177,12 @@ export function federationConfigPath(workspacePath: string): string { export function loadFederationConfig(workspacePath: string): FederationConfig { const configPath = federationConfigPath(workspacePath); if (!fs.existsSync(configPath)) { - return defaultFederationConfig(); + return defaultFederationConfig(workspacePath); } try { const raw = fs.readFileSync(configPath, 'utf-8'); const parsed = YAML.parse(raw) as unknown; - return normalizeFederationConfig(parsed); + return normalizeFederationConfig(workspacePath, parsed); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to parse federation config at ${configPath}: ${message}`); @@ -120,7 +190,7 @@ export function loadFederationConfig(workspacePath: string): FederationConfig { } export function saveFederationConfig(workspacePath: string, config: FederationConfig): FederationConfig { - const normalized = normalizeFederationConfig(config); + const normalized = normalizeFederationConfig(workspacePath, config); const configPath = federationConfigPath(workspacePath); const configDir = path.dirname(configPath); if (!fs.existsSync(configDir)) { @@ -135,7 +205,7 @@ export function ensureFederationConfig(workspacePath: string): FederationConfig if (fs.existsSync(configPath)) { return loadFederationConfig(workspacePath); } - const created = defaultFederationConfig(); + const created = defaultFederationConfig(workspacePath); return saveFederationConfig(workspacePath, created); } @@ -150,6 +220,15 @@ export function listRemoteWorkspaces( : config.remotes.filter((remote) => remote.enabled); } +export function federationStatus(workspacePath: string): FederationStatusResult { + const config = ensureFederationConfig(workspacePath); + return { + workspace: config.workspace, + configPath: federationConfigPath(workspacePath), + remotes: config.remotes.map((remote) => handshakeRemoteWorkspace(workspacePath, remote)), + }; +} + export function addRemoteWorkspace( workspacePath: string, input: AddRemoteWorkspaceInput, @@ -162,6 +241,7 @@ export function addRemoteWorkspace( } const config = ensureFederationConfig(workspacePath); + const remoteIdentity = readRemoteWorkspaceIdentity(remotePath); const now = new Date().toISOString(); const index = config.remotes.findIndex((remote) => remote.id === workspaceId); const previous = index >= 0 ? config.remotes[index] : undefined; @@ -170,11 +250,17 @@ export function addRemoteWorkspace( : normalizeTags(input.tags); const remote: RemoteWorkspaceRef = { id: workspaceId, + workspaceId: previous?.workspaceId ?? remoteIdentity.workspaceId, name: normalizeOptionalString(input.name) ?? previous?.name ?? workspaceId, path: remotePath, enabled: input.enabled ?? previous?.enabled ?? true, tags: nextTags, + protocolVersion: previous?.protocolVersion ?? remoteIdentity.protocolVersion, + capabilities: previous?.capabilities ?? remoteIdentity.capabilities, + trustLevel: previous?.trustLevel ?? 'read-only', + transport: previous?.transport ?? 'local-path', addedAt: previous?.addedAt ?? now, + lastHandshakeAt: previous?.lastHandshakeAt ?? now, lastSyncedAt: previous?.lastSyncedAt, lastSyncStatus: previous?.lastSyncStatus, lastSyncError: previous?.lastSyncError, @@ -257,15 +343,36 @@ export function linkThreadToRemoteWorkspace( if (!fs.existsSync(remote.path)) { throw new Error(`Federated workspace path not found for "${remoteId}": ${remote.path}`); } + const handshake = handshakeRemoteWorkspace(workspacePath, remote); + if (!handshake.compatible) { + throw new Error(handshake.error ?? `Remote workspace "${remoteId}" is not protocol-compatible.`); + } + if (!handshake.supportsRead) { + throw new Error(`Remote workspace "${remoteId}" does not advertise read capabilities for thread resolution.`); + } const remoteThread = store.read(remote.path, targetThreadPath); if (!remoteThread || remoteThread.type !== 'thread') { throw new Error(`Remote thread not found in "${remoteId}": ${targetThreadPath}`); } - const link = `federation://${remoteId}/${targetThreadPath}`; + const link = buildLegacyFederationLink(remote.id, targetThreadPath); + const ref = buildFederatedPrimitiveRef({ + workspaceId: handshake.identity.workspaceId, + primitiveType: remoteThread.type, + primitivePath: targetThreadPath, + protocolVersion: handshake.identity.protocolVersion, + transport: remote.transport, + remoteAlias: remote.id, + }); const existingLinks = readStringArray(localThread.fields.federation_links); + const existingRefs = readFederatedRefs(localThread.fields.federation_refs); const created = !existingLinks.includes(link); const links = created ? [...existingLinks, link] : existingLinks; + const refs = created + ? [...existingRefs, ref] + : existingRefs.some((entry) => entry.workspaceId === ref.workspaceId && entry.primitivePath === ref.primitivePath) + ? existingRefs + : [...existingRefs, ref]; const body = created ? appendThreadFederationLink(localThread.body, link, remote.name) : undefined; @@ -273,7 +380,10 @@ export function linkThreadToRemoteWorkspace( const updated = store.update( workspacePath, localThread.path, - { federation_links: links }, + { + federation_links: links, + federation_refs: refs, + }, body, actor, { @@ -286,6 +396,7 @@ export function linkThreadToRemoteWorkspace( thread: updated, created, link, + ref, }; } @@ -315,6 +426,9 @@ export function searchFederated( results.push({ workspaceId: 'local', workspacePath: path.resolve(workspacePath).replace(/\\/g, '/'), + protocolVersion: loadFederationConfig(workspacePath).workspace.protocolVersion, + trustLevel: 'local', + stale: false, instance, }); } @@ -322,6 +436,13 @@ export function searchFederated( for (const remote of remotes) { try { + const handshake = handshakeRemoteWorkspace(workspacePath, remote); + if (!handshake.compatible) { + throw new Error(handshake.error ?? `Remote workspace ${remote.id} is not protocol-compatible.`); + } + if (!handshake.supportsSearch) { + throw new Error(`Remote workspace ${remote.id} does not support federated search.`); + } if (!fs.existsSync(remote.path)) { throw new Error(`Remote workspace path not found: ${remote.path}`); } @@ -332,6 +453,9 @@ export function searchFederated( results.push({ workspaceId: remote.id, workspacePath: remote.path, + protocolVersion: handshake.identity.protocolVersion, + trustLevel: handshake.identity.trustLevel, + stale: isRemoteResultStale(remote, instance), instance, }); } @@ -353,6 +477,73 @@ export function searchFederated( }; } +export function resolveFederatedRef( + workspacePath: string, + ref: string | FederatedPrimitiveRef, +): FederationResolveResult { + const parsedRef = typeof ref === 'string' + ? parseFederatedRef(ref) + : normalizeFederatedPrimitiveRef(ref); + if (!parsedRef) { + throw new Error('Invalid federated ref. Expected legacy federation:// link or typed federated ref.'); + } + const config = ensureFederationConfig(workspacePath); + const remote = config.remotes.find((entry) => + entry.id === parsedRef.remoteAlias + || entry.id === parsedRef.workspaceId + || entry.workspaceId === parsedRef.workspaceId); + if (!remote) { + throw new Error(`Federated workspace not configured for ref workspace id "${parsedRef.workspaceId}".`); + } + const localWinner = findLocalAuthorityWinner(workspacePath, parsedRef); + if (localWinner) { + return { + ref: parsedRef, + source: 'local', + authority: 'local', + workspaceId: config.workspace.workspaceId, + workspacePath: path.resolve(workspacePath).replace(/\\/g, '/'), + protocolVersion: config.workspace.protocolVersion, + trustLevel: config.workspace.trustLevel, + stale: false, + capabilityCheck: { + supportsRead: true, + supportsSearch: true, + }, + instance: localWinner, + warning: `Local primitive "${localWinner.path}" overrides remote authority for slug "${parsedRef.primitiveSlug}".`, + }; + } + const handshake = handshakeRemoteWorkspace(workspacePath, remote); + if (!handshake.compatible) { + throw new Error(handshake.error ?? `Remote workspace "${remote.id}" is not protocol-compatible.`); + } + if (!handshake.supportsRead) { + throw new Error(`Remote workspace "${remote.id}" does not support federated ref resolution.`); + } + const remoteInstance = resolveRemotePrimitive(remote.path, parsedRef); + if (!remoteInstance) { + throw new Error(`Remote primitive not found for ${parsedRef.primitiveType}:${parsedRef.primitiveSlug} in "${remote.id}".`); + } + const stale = isRemoteResultStale(remote, remoteInstance); + return { + ref: parsedRef, + source: 'remote', + authority: 'remote', + workspaceId: handshake.identity.workspaceId, + workspacePath: remote.path, + protocolVersion: handshake.identity.protocolVersion, + trustLevel: handshake.identity.trustLevel, + stale, + capabilityCheck: { + supportsRead: handshake.supportsRead, + supportsSearch: handshake.supportsSearch, + }, + instance: remoteInstance, + ...(stale ? { warning: `Remote primitive "${remoteInstance.path}" may be stale relative to last federation sync.` } : {}), + }; +} + export function syncFederation( workspacePath: string, actor: string, @@ -368,22 +559,30 @@ export function syncFederation( if (!selected) { remotesResult.push({ id: remote.id, + workspaceId: remote.workspaceId, workspacePath: remote.path, enabled: remote.enabled, status: 'skipped', threadCount: 0, openThreadCount: 0, + protocolVersion: remote.protocolVersion, + capabilities: remote.capabilities, + trustLevel: remote.trustLevel, }); return remote; } if (!remote.enabled && options.includeDisabled !== true) { remotesResult.push({ id: remote.id, + workspaceId: remote.workspaceId, workspacePath: remote.path, enabled: remote.enabled, status: 'skipped', threadCount: 0, openThreadCount: 0, + protocolVersion: remote.protocolVersion, + capabilities: remote.capabilities, + trustLevel: remote.trustLevel, }); return remote; } @@ -392,19 +591,32 @@ export function syncFederation( if (!fs.existsSync(remote.path)) { throw new Error(`Remote workspace path not found: ${remote.path}`); } + const handshake = handshakeRemoteWorkspace(workspacePath, remote); + if (!handshake.compatible) { + throw new Error(handshake.error ?? `Remote workspace ${remote.id} is not protocol-compatible.`); + } const threads = store.list(remote.path, 'thread'); const openThreadCount = threads.filter((thread) => String(thread.fields.status ?? '') === 'open').length; remotesResult.push({ id: remote.id, + workspaceId: handshake.identity.workspaceId, workspacePath: remote.path, enabled: remote.enabled, status: 'synced', threadCount: threads.length, openThreadCount, + protocolVersion: handshake.identity.protocolVersion, + capabilities: handshake.identity.capabilities, + trustLevel: handshake.identity.trustLevel, syncedAt: now, }); return { ...remote, + workspaceId: handshake.identity.workspaceId, + protocolVersion: handshake.identity.protocolVersion, + capabilities: handshake.identity.capabilities, + trustLevel: handshake.identity.trustLevel, + lastHandshakeAt: now, lastSyncedAt: now, lastSyncStatus: 'synced' as const, lastSyncError: undefined, @@ -413,16 +625,21 @@ export function syncFederation( const message = error instanceof Error ? error.message : String(error); remotesResult.push({ id: remote.id, + workspaceId: remote.workspaceId, workspacePath: remote.path, enabled: remote.enabled, status: 'error', threadCount: 0, openThreadCount: 0, + protocolVersion: remote.protocolVersion, + capabilities: remote.capabilities, + trustLevel: remote.trustLevel, syncedAt: now, error: message, }); return { ...remote, + lastHandshakeAt: now, lastSyncedAt: now, lastSyncStatus: 'error' as const, lastSyncError: message, @@ -443,15 +660,16 @@ export function syncFederation( }; } -function defaultFederationConfig(now: string = new Date().toISOString()): FederationConfig { +function defaultFederationConfig(workspacePath: string, now: string = new Date().toISOString()): FederationConfig { return { - version: 1, + version: 2, updatedAt: now, + workspace: normalizeFederationWorkspaceIdentity(undefined, workspacePath), remotes: [], }; } -function normalizeFederationConfig(value: unknown): FederationConfig { +function normalizeFederationConfig(workspacePath: string, value: unknown): FederationConfig { const root = asRecord(value); const now = new Date().toISOString(); const remotes = asArray(root.remotes) @@ -460,10 +678,11 @@ function normalizeFederationConfig(value: unknown): FederationConfig { .sort((a, b) => a.id.localeCompare(b.id)); const version = typeof root.version === 'number' && Number.isFinite(root.version) ? Math.max(1, Math.floor(root.version)) - : 1; + : 2; return { version, updatedAt: normalizeOptionalString(root.updatedAt) ?? now, + workspace: normalizeFederationWorkspaceIdentity(root.workspace, workspacePath), remotes, }; } @@ -476,11 +695,17 @@ function normalizeRemoteWorkspaceRef(value: unknown): RemoteWorkspaceRef | null const now = new Date().toISOString(); return { id: normalizeIdentifier(id, 'remote.id'), + workspaceId: normalizeOptionalString(raw.workspaceId) ?? normalizeOptionalString(raw.workspace_id) ?? normalizeIdentifier(id, 'remote.id'), name: normalizeOptionalString(raw.name) ?? normalizeIdentifier(id, 'remote.id'), path: normalizeRemoteWorkspacePath(workspacePath), enabled: asBoolean(raw.enabled, true), tags: normalizeTags(asArray(raw.tags).map((entry) => String(entry))), + protocolVersion: normalizeProtocolVersion(raw.protocolVersion ?? raw.protocol_version), + capabilities: normalizeCapabilitySet(raw.capabilities), + trustLevel: normalizeTrustLevel(raw.trustLevel ?? raw.trust_level, 'read-only'), + transport: normalizeTransportKind(raw.transport, 'local-path'), addedAt: normalizeOptionalString(raw.addedAt) ?? now, + lastHandshakeAt: normalizeOptionalString(raw.lastHandshakeAt ?? raw.last_handshake_at), lastSyncedAt: normalizeOptionalString(raw.lastSyncedAt), lastSyncStatus: normalizeSyncStatus(raw.lastSyncStatus), lastSyncError: normalizeOptionalString(raw.lastSyncError), @@ -548,12 +773,6 @@ function normalizeTags(values: unknown): string[] { return [...seen].sort((a, b) => a.localeCompare(b)); } -function normalizeOptionalString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - function appendThreadFederationLink(body: string, link: string, remoteName: string): string { const currentBody = String(body ?? ''); if (currentBody.includes(link)) return currentBody; @@ -575,9 +794,106 @@ function readStringArray(value: unknown): string[] { .filter((entry) => entry.length > 0); } -function asRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; +function readFederatedRefs(value: unknown): FederatedPrimitiveRef[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => normalizeFederatedPrimitiveRef(entry)) + .filter((entry): entry is FederatedPrimitiveRef => entry !== null); +} + +function readRemoteWorkspaceIdentity(workspacePath: string): FederationWorkspaceIdentity { + const configPath = federationConfigPath(workspacePath); + if (!fs.existsSync(configPath)) { + return { + workspaceId: deriveWorkspaceId(workspacePath), + protocolVersion: FEDERATION_PROTOCOL_VERSION, + capabilities: [...DEFAULT_FEDERATION_CAPABILITIES], + trustLevel: 'read-only', + }; + } + try { + const raw = YAML.parse(fs.readFileSync(configPath, 'utf-8')) as unknown; + const root = asRecord(raw); + return normalizeFederationWorkspaceIdentity(root.workspace, workspacePath, 'read-only'); + } catch { + return { + workspaceId: deriveWorkspaceId(workspacePath), + protocolVersion: FEDERATION_PROTOCOL_VERSION, + capabilities: [...DEFAULT_FEDERATION_CAPABILITIES], + trustLevel: 'read-only', + }; + } +} + +function handshakeRemoteWorkspace( + workspacePath: string, + remote: RemoteWorkspaceRef, +): FederationHandshakeResult { + const local = ensureFederationConfig(workspacePath).workspace; + if (!fs.existsSync(remote.path)) { + return { + remote, + identity: { + workspaceId: remote.workspaceId, + protocolVersion: remote.protocolVersion, + capabilities: remote.capabilities, + trustLevel: remote.trustLevel, + }, + compatible: false, + supportsRead: false, + supportsSearch: false, + error: `Remote workspace path not found: ${remote.path}`, + }; + } + const identity = readRemoteWorkspaceIdentity(remote.path); + const compatible = identity.protocolVersion === local.protocolVersion; + const supportsRead = identity.capabilities.includes('resolve-ref') && ( + identity.capabilities.includes('read-primitive') || identity.capabilities.includes('read-thread') + ); + const supportsSearch = identity.capabilities.includes('search'); + return { + remote, + identity, + compatible, + supportsRead, + supportsSearch, + ...(compatible ? {} : { + error: `Protocol mismatch for remote "${remote.id}": local=${local.protocolVersion} remote=${identity.protocolVersion}.`, + }), + }; +} + +function resolveRemotePrimitive( + workspacePath: string, + ref: FederatedPrimitiveRef, +): PrimitiveInstance | null { + if (ref.primitivePath) { + const direct = store.read(workspacePath, ref.primitivePath); + if (direct) return direct; + } + const instances = store.list(workspacePath, ref.primitiveType); + return instances.find((instance) => primitiveSlugFromPath(instance.path) === ref.primitiveSlug) ?? null; +} + +function findLocalAuthorityWinner( + workspacePath: string, + ref: FederatedPrimitiveRef, +): PrimitiveInstance | null { + return resolveRemotePrimitive(workspacePath, { + ...ref, + workspaceId: 'local', + }); +} + +function isRemoteResultStale( + remote: RemoteWorkspaceRef, + instance: PrimitiveInstance, +): boolean { + if (!remote.lastSyncedAt) return true; + const syncedAt = Date.parse(remote.lastSyncedAt); + const updatedAt = Date.parse(String(instance.fields.updated ?? instance.fields.created ?? '')); + if (!Number.isFinite(syncedAt) || !Number.isFinite(updatedAt)) return true; + return updatedAt > syncedAt; } function asArray(value: unknown): unknown[] { From cd5bcf03725a6c8046d8ff2b9aad56505c2d8077 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Mar 2026 07:43:06 +0000 Subject: [PATCH 3/6] Add federation MCP read tools Co-authored-by: G9Pedro --- .../mcp-server/src/federation-tools.test.ts | 124 ++++++++++++++++++ .../mcp-server/src/mcp/tools/read-tools.ts | 83 ++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 packages/mcp-server/src/federation-tools.test.ts diff --git a/packages/mcp-server/src/federation-tools.test.ts b/packages/mcp-server/src/federation-tools.test.ts new file mode 100644 index 0000000..d88d2ab --- /dev/null +++ b/packages/mcp-server/src/federation-tools.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { + federation as federationModule, + registry as registryModule, + thread as threadModule, +} from '@versatly/workgraph-kernel'; +import { createWorkgraphMcpServer } from './mcp-server.js'; + +const federation = federationModule; +const registry = registryModule; +const thread = threadModule; + +let workspacePath: string; +let remoteWorkspacePath: string; + +describe('federation MCP tools', () => { + beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-mcp-federation-')); + remoteWorkspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-mcp-federation-remote-')); + registry.saveRegistry(workspacePath, registry.loadRegistry(workspacePath)); + registry.saveRegistry(remoteWorkspacePath, registry.loadRegistry(remoteWorkspacePath)); + }); + + afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); + fs.rmSync(remoteWorkspacePath, { recursive: true, force: true }); + }); + + it('reports federation status, resolves refs, and searches remote workspaces', async () => { + const localThread = thread.createThread(workspacePath, 'Local thread', 'Coordinate remote work', 'agent-local'); + const remoteThread = thread.createThread(remoteWorkspacePath, 'Remote auth thread', 'Build auth dashboard', 'agent-remote'); + federation.ensureFederationConfig(remoteWorkspacePath); + federation.addRemoteWorkspace(workspacePath, { + id: 'remote-main', + path: remoteWorkspacePath, + name: 'Remote Main', + }); + const linked = federation.linkThreadToRemoteWorkspace( + workspacePath, + localThread.path, + 'remote-main', + remoteThread.path, + 'agent-local', + ); + + const server = createWorkgraphMcpServer({ + workspacePath, + defaultActor: 'agent-mcp', + }); + const client = new Client({ + name: 'workgraph-mcp-federation-client', + version: '1.0.0', + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + + try { + const tools = await client.listTools(); + const toolNames = tools.tools.map((entry) => entry.name); + expect(toolNames).toContain('wg_federation_status'); + expect(toolNames).toContain('wg_federation_resolve_ref'); + expect(toolNames).toContain('wg_federation_search'); + + const statusResult = await client.callTool({ + name: 'wg_federation_status', + arguments: {}, + }); + expect(isToolError(statusResult)).toBe(false); + const statusPayload = getStructured<{ workspace: { workspaceId: string }; remotes: Array<{ remote: { id: string } }> }>(statusResult); + expect(statusPayload.workspace.workspaceId).toMatch(/^[0-9a-f-]{36}$/); + expect(statusPayload.remotes[0]?.remote.id).toBe('remote-main'); + + const resolveResult = await client.callTool({ + name: 'wg_federation_resolve_ref', + arguments: { + ref: linked.ref, + }, + }); + expect(isToolError(resolveResult)).toBe(false); + const resolvePayload = getStructured<{ source: string; authority: string; instance: { path: string } }>(resolveResult); + expect(resolvePayload.source).toBe('remote'); + expect(resolvePayload.authority).toBe('remote'); + expect(resolvePayload.instance.path).toBe(remoteThread.path); + + const searchResult = await client.callTool({ + name: 'wg_federation_search', + arguments: { + query: 'auth', + type: 'thread', + includeLocal: true, + }, + }); + expect(isToolError(searchResult)).toBe(false); + const searchPayload = getStructured<{ results: Array<{ workspaceId: string; instance: { path: string } }> }>(searchResult); + expect(searchPayload.results.some((entry) => entry.workspaceId === 'remote-main' && entry.instance.path === remoteThread.path)).toBe(true); + } finally { + await client.close(); + await server.close(); + } + }); +}); + +function getStructured(result: unknown): T { + if (!result || typeof result !== 'object' || !('structuredContent' in result)) { + throw new Error('Expected structuredContent in MCP tool response.'); + } + const typed = result as { structuredContent?: unknown }; + if (!typed.structuredContent) { + throw new Error('Expected structuredContent in MCP tool response.'); + } + return typed.structuredContent as T; +} + +function isToolError(result: unknown): boolean { + return Boolean(result && typeof result === 'object' && 'isError' in result && (result as { isError?: boolean }).isError); +} diff --git a/packages/mcp-server/src/mcp/tools/read-tools.ts b/packages/mcp-server/src/mcp/tools/read-tools.ts index e8ffb31..488eae0 100644 --- a/packages/mcp-server/src/mcp/tools/read-tools.ts +++ b/packages/mcp-server/src/mcp/tools/read-tools.ts @@ -1,6 +1,7 @@ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { + federation as federationModule, graph as graphModule, ledger as ledgerModule, mission as missionModule, @@ -16,6 +17,7 @@ import { resolveActor } from '../auth.js'; import { errorResult, okResult, renderStatusSummary } from '../result.js'; import { type WorkgraphMcpServerOptions } from '../types.js'; +const federation = federationModule; const graph = graphModule; const ledger = ledgerModule; const mission = missionModule; @@ -380,6 +382,87 @@ export function registerReadTools(server: McpServer, options: WorkgraphMcpServer }, ); + server.registerTool( + 'wg_federation_status', + { + title: 'Federation Status', + description: 'Read workspace federation identity and remote handshake status.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const status = federation.federationStatus(options.workspacePath); + return okResult(status, `Federation status loaded for ${status.remotes.length} remote(s).`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'wg_federation_resolve_ref', + { + title: 'Federation Resolve Ref', + description: 'Resolve one typed or legacy federated reference with authority and staleness metadata.', + inputSchema: { + ref: z.union([z.string().min(1), z.object({}).passthrough()]), + }, + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async (args) => { + try { + const resolved = federation.resolveFederatedRef(options.workspacePath, args.ref as any); + return okResult( + resolved, + `Resolved federated ref to ${resolved.source}:${resolved.instance.path} (authority=${resolved.authority}).`, + ); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'wg_federation_search', + { + title: 'Federation Search', + description: 'Search local and remote workspaces through read-only federation capability negotiation.', + inputSchema: { + query: z.string().min(1), + type: z.string().optional(), + limit: z.number().int().min(0).max(1000).optional(), + remoteIds: z.array(z.string()).optional(), + includeLocal: z.boolean().optional(), + }, + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async (args) => { + try { + const result = federation.searchFederated(options.workspacePath, args.query, { + type: args.type, + limit: args.limit, + remoteIds: args.remoteIds, + includeLocal: args.includeLocal, + }); + return okResult( + result, + `Federation search returned ${result.results.length} result(s) with ${result.errors.length} remote error(s).`, + ); + } catch (error) { + return errorResult(error); + } + }, + ); + server.registerTool( 'workgraph_ledger_reconcile', { From cb340fcd1dcd23ee1735bf6b559d6ededd3d3a4c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Mar 2026 08:11:56 +0000 Subject: [PATCH 4/6] Compose adapters outside kernel registry Co-authored-by: G9Pedro --- packages/adapter-claude-code/package.json | 1 + packages/adapter-claude-code/src/adapter.ts | 6 +- packages/adapter-http-webhook/package.json | 14 + packages/adapter-http-webhook/src/adapter.ts | 242 +++++++ packages/adapter-http-webhook/src/index.ts | 1 + packages/adapter-http-webhook/tsconfig.json | 8 + packages/adapter-shell-worker/package.json | 15 + packages/adapter-shell-worker/src/adapter.ts | 259 +++++++ packages/adapter-shell-worker/src/index.ts | 1 + packages/adapter-shell-worker/tsconfig.json | 8 + packages/cli/src/cli.ts | 3 + packages/control-api/src/server.ts | 2 + packages/kernel/src/adapter-claude-code.ts | 135 +--- packages/kernel/src/adapter-cursor-cloud.ts | 637 +----------------- packages/kernel/src/adapter-http-webhook.ts | 244 +------ packages/kernel/src/adapter-shell-worker.ts | 261 +------ packages/kernel/src/adapters.test.ts | 2 + packages/kernel/src/cursor-bridge.test.ts | 2 + .../kernel/src/dispatch-evidence-loop.test.ts | 2 + packages/kernel/src/policy-dispatch.test.ts | 2 + packages/kernel/src/reconciler-runs.test.ts | 2 + .../src/runtime-adapter-registry.test.ts | 3 + .../kernel/src/runtime-adapter-registry.ts | 11 +- .../src/trigger-run-evidence-loop.test.ts | 2 + packages/mcp-server/src/mcp-server.ts | 2 + packages/runtime-adapter-core/package.json | 4 + .../src/default-composition.ts | 15 + packages/runtime-adapter-core/src/index.ts | 1 + pnpm-lock.yaml | 43 +- tsup.config.ts | 2 + 30 files changed, 636 insertions(+), 1294 deletions(-) create mode 100644 packages/adapter-http-webhook/package.json create mode 100644 packages/adapter-http-webhook/src/adapter.ts create mode 100644 packages/adapter-http-webhook/src/index.ts create mode 100644 packages/adapter-http-webhook/tsconfig.json create mode 100644 packages/adapter-shell-worker/package.json create mode 100644 packages/adapter-shell-worker/src/adapter.ts create mode 100644 packages/adapter-shell-worker/src/index.ts create mode 100644 packages/adapter-shell-worker/tsconfig.json create mode 100644 packages/runtime-adapter-core/src/default-composition.ts diff --git a/packages/adapter-claude-code/package.json b/packages/adapter-claude-code/package.json index 37d2aa6..0da999f 100644 --- a/packages/adapter-claude-code/package.json +++ b/packages/adapter-claude-code/package.json @@ -9,6 +9,7 @@ "main": "src/index.ts", "types": "src/index.ts", "dependencies": { + "@versatly/workgraph-adapter-shell-worker": "workspace:*", "@versatly/workgraph-runtime-adapter-core": "workspace:*" } } diff --git a/packages/adapter-claude-code/src/adapter.ts b/packages/adapter-claude-code/src/adapter.ts index 814b08e..63bca5e 100644 --- a/packages/adapter-claude-code/src/adapter.ts +++ b/packages/adapter-claude-code/src/adapter.ts @@ -1,6 +1,6 @@ import { - ShellSubprocessAdapter, -} from '@versatly/workgraph-runtime-adapter-core'; + ShellWorkerAdapter, +} from '@versatly/workgraph-adapter-shell-worker'; import type { DispatchAdapter, DispatchAdapterCreateInput, @@ -18,7 +18,7 @@ import type { */ export class ClaudeCodeAdapter implements DispatchAdapter { name = 'claude-code'; - private readonly shellAdapter = new ShellSubprocessAdapter(); + private readonly shellAdapter = new ShellWorkerAdapter(); async create(input: DispatchAdapterCreateInput): Promise { return this.shellAdapter.create(input); diff --git a/packages/adapter-http-webhook/package.json b/packages/adapter-http-webhook/package.json new file mode 100644 index 0000000..f9db226 --- /dev/null +++ b/packages/adapter-http-webhook/package.json @@ -0,0 +1,14 @@ +{ + "name": "@versatly/workgraph-adapter-http-webhook", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@versatly/workgraph-runtime-adapter-core": "workspace:*" + } +} diff --git a/packages/adapter-http-webhook/src/adapter.ts b/packages/adapter-http-webhook/src/adapter.ts new file mode 100644 index 0000000..d4a9cb7 --- /dev/null +++ b/packages/adapter-http-webhook/src/adapter.ts @@ -0,0 +1,242 @@ +import type { + DispatchAdapter, + DispatchAdapterCreateInput, + DispatchAdapterExecutionInput, + DispatchAdapterExecutionResult, + DispatchAdapterLogEntry, + DispatchAdapterRunStatus, +} from '@versatly/workgraph-runtime-adapter-core'; + +const DEFAULT_POLL_MS = 1000; +const DEFAULT_MAX_WAIT_MS = 90_000; + +export class HttpWebhookAdapter implements DispatchAdapter { + name = 'http-webhook'; + + async create(_input: DispatchAdapterCreateInput): Promise { + return { runId: 'http-webhook-managed', status: 'queued' }; + } + + async status(runId: string): Promise { + return { runId, status: 'running' }; + } + + async followup(runId: string, _actor: string, _input: string): Promise { + return { runId, status: 'running' }; + } + + async stop(runId: string, _actor: string): Promise { + return { runId, status: 'cancelled' }; + } + + async logs(_runId: string): Promise { + return []; + } + + async execute(input: DispatchAdapterExecutionInput): Promise { + const logs: DispatchAdapterLogEntry[] = []; + const webhookUrl = resolveUrl(input.context?.webhook_url, process.env.WORKGRAPH_DISPATCH_WEBHOOK_URL); + if (!webhookUrl) { + return { + status: 'failed', + error: 'http-webhook adapter requires context.webhook_url or WORKGRAPH_DISPATCH_WEBHOOK_URL.', + logs, + }; + } + + const token = readString(input.context?.webhook_token) ?? process.env.WORKGRAPH_DISPATCH_WEBHOOK_TOKEN; + const headers = { + 'content-type': 'application/json', + ...extractHeaders(input.context?.webhook_headers), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; + + const payload = { + runId: input.runId, + actor: input.actor, + objective: input.objective, + workspacePath: input.workspacePath, + context: input.context ?? {}, + ts: new Date().toISOString(), + }; + + pushLog(logs, 'info', `http-webhook posting run ${input.runId} to ${webhookUrl}`); + const response = await fetch(webhookUrl, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + const rawText = await response.text(); + const parsed = safeParseJson(rawText); + pushLog(logs, response.ok ? 'info' : 'error', `http-webhook response status: ${response.status}`); + + if (!response.ok) { + return { + status: 'failed', + error: `http-webhook request failed (${response.status}): ${rawText || response.statusText}`, + logs, + }; + } + + const immediateStatus = normalizeRunStatus(parsed?.status); + if (immediateStatus && isTerminalStatus(immediateStatus)) { + return { + status: immediateStatus, + output: typeof parsed?.output === 'string' ? parsed.output : rawText, + error: typeof parsed?.error === 'string' ? parsed.error : undefined, + logs, + metrics: { + adapter: 'http-webhook', + httpStatus: response.status, + }, + }; + } + + const pollUrl = resolveUrl(parsed?.pollUrl, input.context?.webhook_status_url, process.env.WORKGRAPH_DISPATCH_WEBHOOK_STATUS_URL); + if (!pollUrl) { + return { + status: 'succeeded', + output: rawText || 'http-webhook acknowledged run successfully.', + logs, + metrics: { + adapter: 'http-webhook', + httpStatus: response.status, + }, + }; + } + + const pollMs = clampInt(readNumber(input.context?.webhook_poll_ms), DEFAULT_POLL_MS, 200, 30_000); + const maxWaitMs = clampInt(readNumber(input.context?.webhook_max_wait_ms), DEFAULT_MAX_WAIT_MS, 1000, 15 * 60_000); + const startedAt = Date.now(); + pushLog(logs, 'info', `http-webhook polling status from ${pollUrl}`); + + while (Date.now() - startedAt < maxWaitMs) { + if (input.isCancelled?.()) { + pushLog(logs, 'warn', 'http-webhook run cancelled while polling'); + return { + status: 'cancelled', + output: 'http-webhook polling cancelled by dispatcher.', + logs, + }; + } + + const pollResponse = await fetch(pollUrl, { + method: 'GET', + headers: { + ...headers, + }, + }); + const pollText = await pollResponse.text(); + const pollJson = safeParseJson(pollText); + const pollStatus = normalizeRunStatus(pollJson?.status); + pushLog(logs, 'info', `poll status=${pollResponse.status} run_status=${pollStatus ?? 'unknown'}`); + + if (pollStatus && isTerminalStatus(pollStatus)) { + return { + status: pollStatus, + output: typeof pollJson?.output === 'string' ? pollJson.output : pollText, + error: typeof pollJson?.error === 'string' ? pollJson.error : undefined, + logs, + metrics: { + adapter: 'http-webhook', + pollUrl, + pollHttpStatus: pollResponse.status, + elapsedMs: Date.now() - startedAt, + }, + }; + } + + await sleep(pollMs); + } + + return { + status: 'failed', + error: `http-webhook polling exceeded timeout (${maxWaitMs}ms) for run ${input.runId}.`, + logs, + }; + } +} + +function pushLog(target: DispatchAdapterLogEntry[], level: DispatchAdapterLogEntry['level'], message: string): void { + target.push({ + ts: new Date().toISOString(), + level, + message, + }); +} + +function readString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveUrl(...values: unknown[]): string | undefined { + for (const value of values) { + const parsed = readString(value); + if (!parsed) continue; + try { + const url = new URL(parsed); + if (url.protocol === 'http:' || url.protocol === 'https:') { + return url.toString(); + } + } catch { + continue; + } + } + return undefined; +} + +function extractHeaders(input: unknown): Record { + if (!input || typeof input !== 'object' || Array.isArray(input)) return {}; + const record = input as Record; + const out: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (!key || value === undefined || value === null) continue; + out[key.toLowerCase()] = String(value); + } + return out; +} + +function safeParseJson(value: string): Record | null { + if (!value || !value.trim()) return null; + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + +function normalizeRunStatus(value: unknown): DispatchAdapterRunStatus['status'] | undefined { + const normalized = String(value ?? '').toLowerCase(); + if (normalized === 'queued' || normalized === 'running' || normalized === 'succeeded' || normalized === 'failed' || normalized === 'cancelled') { + return normalized; + } + return undefined; +} + +function isTerminalStatus(status: DispatchAdapterRunStatus['status']): boolean { + return status === 'succeeded' || status === 'failed' || status === 'cancelled'; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { + const raw = typeof value === 'number' ? Math.trunc(value) : fallback; + return Math.min(max, Math.max(min, raw)); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/packages/adapter-http-webhook/src/index.ts b/packages/adapter-http-webhook/src/index.ts new file mode 100644 index 0000000..ddec7b5 --- /dev/null +++ b/packages/adapter-http-webhook/src/index.ts @@ -0,0 +1 @@ +export * from './adapter.js'; diff --git a/packages/adapter-http-webhook/tsconfig.json b/packages/adapter-http-webhook/tsconfig.json new file mode 100644 index 0000000..79e486b --- /dev/null +++ b/packages/adapter-http-webhook/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/packages/adapter-shell-worker/package.json b/packages/adapter-shell-worker/package.json new file mode 100644 index 0000000..8f302cf --- /dev/null +++ b/packages/adapter-shell-worker/package.json @@ -0,0 +1,15 @@ +{ + "name": "@versatly/workgraph-adapter-shell-worker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@versatly/workgraph-adapter-cursor-cloud": "workspace:*", + "@versatly/workgraph-runtime-adapter-core": "workspace:*" + } +} diff --git a/packages/adapter-shell-worker/src/adapter.ts b/packages/adapter-shell-worker/src/adapter.ts new file mode 100644 index 0000000..62146a2 --- /dev/null +++ b/packages/adapter-shell-worker/src/adapter.ts @@ -0,0 +1,259 @@ +import { spawn } from 'node:child_process'; +import { CursorCloudAdapter } from '../../adapter-cursor-cloud/src/adapter.js'; +import type { + DispatchAdapter, + DispatchAdapterCreateInput, + DispatchAdapterExecutionInput, + DispatchAdapterExecutionResult, + DispatchAdapterLogEntry, + DispatchAdapterRunStatus, +} from '@versatly/workgraph-runtime-adapter-core'; + +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_CAPTURE_CHARS = 12000; + +export class ShellWorkerAdapter implements DispatchAdapter { + name = 'shell-worker'; + private readonly fallback = new CursorCloudAdapter(); + + async create(_input: DispatchAdapterCreateInput): Promise { + return { runId: 'shell-worker-managed', status: 'queued' }; + } + + async status(runId: string): Promise { + return { runId, status: 'running' }; + } + + async followup(runId: string, _actor: string, _input: string): Promise { + return { runId, status: 'running' }; + } + + async stop(runId: string, _actor: string): Promise { + return { runId, status: 'cancelled' }; + } + + async logs(_runId: string): Promise { + return []; + } + + async execute(input: DispatchAdapterExecutionInput): Promise { + const command = readString(input.context?.shell_command); + if (!command) { + return this.fallback.execute(input); + } + + const shellCwd = readString(input.context?.shell_cwd) ?? input.workspacePath; + const timeoutMs = clampInt(readNumber(input.context?.shell_timeout_ms), DEFAULT_TIMEOUT_MS, 1000, 60 * 60 * 1000); + const shellEnv = readEnv(input.context?.shell_env); + const logs: DispatchAdapterLogEntry[] = []; + const startedAt = Date.now(); + const outputParts: string[] = []; + const errorParts: string[] = []; + + pushLog(logs, 'info', `shell-worker starting command: ${command}`); + pushLog(logs, 'info', `shell-worker cwd: ${shellCwd}`); + + const result = await runShellCommand({ + command, + cwd: shellCwd, + timeoutMs, + env: shellEnv, + isCancelled: input.isCancelled, + onStdout: (chunk) => { + outputParts.push(chunk); + pushLog(logs, 'info', `[stdout] ${chunk.trimEnd()}`); + }, + onStderr: (chunk) => { + errorParts.push(chunk); + pushLog(logs, 'warn', `[stderr] ${chunk.trimEnd()}`); + }, + }); + + const elapsedMs = Date.now() - startedAt; + const stdout = truncateText(outputParts.join(''), MAX_CAPTURE_CHARS); + const stderr = truncateText(errorParts.join(''), MAX_CAPTURE_CHARS); + + if (result.cancelled) { + pushLog(logs, 'warn', `shell-worker command cancelled after ${elapsedMs}ms`); + return { + status: 'cancelled', + output: formatShellOutput(command, result.exitCode, stdout, stderr, elapsedMs, true), + logs, + }; + } + + if (result.timedOut) { + pushLog(logs, 'error', `shell-worker command timed out after ${elapsedMs}ms`); + return { + status: 'failed', + error: formatShellOutput(command, result.exitCode, stdout, stderr, elapsedMs, false), + logs, + }; + } + + if (result.exitCode !== 0) { + pushLog(logs, 'error', `shell-worker command failed with exit code ${result.exitCode}`); + return { + status: 'failed', + error: formatShellOutput(command, result.exitCode, stdout, stderr, elapsedMs, false), + logs, + }; + } + + pushLog(logs, 'info', `shell-worker command succeeded in ${elapsedMs}ms`); + return { + status: 'succeeded', + output: formatShellOutput(command, result.exitCode, stdout, stderr, elapsedMs, false), + logs, + metrics: { + elapsedMs, + exitCode: result.exitCode, + adapter: 'shell-worker', + }, + }; + } +} + +interface RunShellCommandOptions { + command: string; + cwd: string; + timeoutMs: number; + env: Record; + isCancelled?: () => boolean; + onStdout: (chunk: string) => void; + onStderr: (chunk: string) => void; +} + +interface RunShellCommandResult { + exitCode: number; + timedOut: boolean; + cancelled: boolean; +} + +async function runShellCommand(options: RunShellCommandOptions): Promise { + return new Promise((resolve) => { + const child = spawn(options.command, { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let resolved = false; + let timedOut = false; + let cancelled = false; + const timeoutHandle = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + setTimeout(() => child.kill('SIGKILL'), 1500).unref(); + }, options.timeoutMs); + + const cancelWatcher = setInterval(() => { + if (options.isCancelled?.()) { + cancelled = true; + child.kill('SIGTERM'); + } + }, 200); + cancelWatcher.unref(); + + child.stdout.on('data', (chunk: Buffer) => { + options.onStdout(chunk.toString('utf-8')); + }); + child.stderr.on('data', (chunk: Buffer) => { + options.onStderr(chunk.toString('utf-8')); + }); + + child.on('close', (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutHandle); + clearInterval(cancelWatcher); + resolve({ + exitCode: typeof code === 'number' ? code : 1, + timedOut, + cancelled, + }); + }); + + child.on('error', () => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutHandle); + clearInterval(cancelWatcher); + resolve({ + exitCode: 1, + timedOut, + cancelled, + }); + }); + }); +} + +function pushLog(target: DispatchAdapterLogEntry[], level: DispatchAdapterLogEntry['level'], message: string): void { + target.push({ + ts: new Date().toISOString(), + level, + message, + }); +} + +function readEnv(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const input = value as Record; + const result: Record = {}; + for (const [key, raw] of Object.entries(input)) { + if (!key) continue; + if (raw === undefined || raw === null) continue; + result[key] = String(raw); + } + return result; +} + +function readString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { + const raw = typeof value === 'number' ? Math.trunc(value) : fallback; + return Math.min(max, Math.max(min, raw)); +} + +function truncateText(value: string, limit: number): string { + if (value.length <= limit) return value; + return `${value.slice(0, limit)}\n...[truncated]`; +} + +function formatShellOutput( + command: string, + exitCode: number, + stdout: string, + stderr: string, + elapsedMs: number, + cancelled: boolean, +): string { + const lines = [ + 'Shell worker execution summary', + `Command: ${command}`, + `Exit code: ${exitCode}`, + `Elapsed ms: ${elapsedMs}`, + `Cancelled: ${cancelled ? 'yes' : 'no'}`, + '', + 'STDOUT:', + stdout || '(empty)', + '', + 'STDERR:', + stderr || '(empty)', + ]; + return lines.join('\n'); +} diff --git a/packages/adapter-shell-worker/src/index.ts b/packages/adapter-shell-worker/src/index.ts new file mode 100644 index 0000000..ddec7b5 --- /dev/null +++ b/packages/adapter-shell-worker/src/index.ts @@ -0,0 +1 @@ +export * from './adapter.js'; diff --git a/packages/adapter-shell-worker/tsconfig.json b/packages/adapter-shell-worker/tsconfig.json new file mode 100644 index 0000000..79e486b --- /dev/null +++ b/packages/adapter-shell-worker/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a2c1984..2357fd4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { Command } from 'commander'; import * as workgraph from '@versatly/workgraph-kernel'; +import { registerDefaultDispatchAdaptersIntoKernelRegistry } from '@versatly/workgraph-runtime-adapter-core'; import { startWorkgraphServer, waitForShutdown } from '@versatly/workgraph-control-api'; import { registerAdapterCommands } from './cli/commands/adapter.js'; import { registerAutonomyCommands } from './cli/commands/autonomy.js'; @@ -39,6 +40,8 @@ const DEFAULT_ACTOR = process.env.USER || 'anonymous'; +registerDefaultDispatchAdaptersIntoKernelRegistry(); + const CLI_VERSION = (() => { try { const pkgUrl = new URL('../package.json', import.meta.url); diff --git a/packages/control-api/src/server.ts b/packages/control-api/src/server.ts index 4ca2bd8..7604e42 100644 --- a/packages/control-api/src/server.ts +++ b/packages/control-api/src/server.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { registerDefaultDispatchAdaptersIntoKernelRegistry } from '@versatly/workgraph-runtime-adapter-core'; import { auth as authModule, ledger as ledgerModule, @@ -114,6 +115,7 @@ interface WebhookCreateRequestBody { } export async function startWorkgraphServer(options: WorkgraphServerOptions): Promise { + registerDefaultDispatchAdaptersIntoKernelRegistry(); const workspacePath = path.resolve(options.workspacePath); const host = readNonEmptyString(options.host) ?? DEFAULT_HOST; const port = normalizePort(options.port, DEFAULT_PORT); diff --git a/packages/kernel/src/adapter-claude-code.ts b/packages/kernel/src/adapter-claude-code.ts index 7e828bf..3c43ba0 100644 --- a/packages/kernel/src/adapter-claude-code.ts +++ b/packages/kernel/src/adapter-claude-code.ts @@ -1,134 +1 @@ -import { ShellWorkerAdapter } from './adapter-shell-worker.js'; -import type { - DispatchAdapter, - DispatchAdapterCreateInput, - DispatchAdapterExecutionInput, - DispatchAdapterExecutionResult, - DispatchAdapterLogEntry, - DispatchAdapterRunStatus, -} from './runtime-adapter-contracts.js'; - -/** - * Claude Code adapter backed by the shell worker transport. - * - * This keeps runtime orchestration in-kernel while allowing concrete execution - * through a production command template configured per environment. - */ -export class ClaudeCodeAdapter implements DispatchAdapter { - name = 'claude-code'; - private readonly shellWorker = new ShellWorkerAdapter(); - - async create(input: DispatchAdapterCreateInput): Promise { - return this.shellWorker.create(input); - } - - async status(runId: string): Promise { - return this.shellWorker.status(runId); - } - - async followup(runId: string, actor: string, input: string): Promise { - return this.shellWorker.followup(runId, actor, input); - } - - async stop(runId: string, actor: string): Promise { - return this.shellWorker.stop(runId, actor); - } - - async logs(runId: string): Promise { - return this.shellWorker.logs(runId); - } - - async execute(input: DispatchAdapterExecutionInput): Promise { - const template = readString(input.context?.claude_command_template) - ?? process.env.WORKGRAPH_CLAUDE_COMMAND_TEMPLATE; - - if (!template) { - return { - status: 'failed', - error: [ - 'claude-code adapter requires a command template.', - 'Set context.claude_command_template or WORKGRAPH_CLAUDE_COMMAND_TEMPLATE.', - 'Template tokens: {workspace}, {run_id}, {actor}, {objective}, {prompt}, {prompt_shell}.', - 'Example: claude -p {prompt_shell}', - ].join(' '), - logs: [ - { - ts: new Date().toISOString(), - level: 'error', - message: 'Missing Claude command template.', - }, - ], - }; - } - - const prompt = buildPrompt(input); - const command = applyTemplate(template, { - workspace: input.workspacePath, - run_id: input.runId, - actor: input.actor, - objective: input.objective, - prompt, - prompt_shell: quoteForShell(prompt), - }); - - const context = { - ...input.context, - shell_command: command, - shell_cwd: readString(input.context?.shell_cwd) ?? input.workspacePath, - shell_timeout_ms: input.context?.shell_timeout_ms ?? process.env.WORKGRAPH_CLAUDE_TIMEOUT_MS, - }; - - const result = await this.shellWorker.execute({ - ...input, - context, - }); - const logs = [ - { - ts: new Date().toISOString(), - level: 'info' as const, - message: 'claude-code adapter dispatched shell execution from command template.', - }, - ...(result.logs ?? []), - ]; - return { - ...result, - logs, - metrics: { - ...(result.metrics ?? {}), - adapter: 'claude-code', - }, - }; - } -} - -function buildPrompt(input: DispatchAdapterExecutionInput): string { - const extraInstructions = readString(input.context?.claude_instructions); - const sections = [ - `Workgraph run id: ${input.runId}`, - `Actor: ${input.actor}`, - `Objective: ${input.objective}`, - `Workspace: ${input.workspacePath}`, - ]; - if (extraInstructions) { - sections.push(`Instructions: ${extraInstructions}`); - } - return sections.join('\n'); -} - -function applyTemplate(template: string, values: Record): string { - let rendered = template; - for (const [key, value] of Object.entries(values)) { - rendered = rendered.replaceAll(`{${key}}`, value); - } - return rendered; -} - -function quoteForShell(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -function readString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} +export { ClaudeCodeAdapter } from '../../adapter-claude-code/src/adapter.js'; diff --git a/packages/kernel/src/adapter-cursor-cloud.ts b/packages/kernel/src/adapter-cursor-cloud.ts index d782d3a..bae00c2 100644 --- a/packages/kernel/src/adapter-cursor-cloud.ts +++ b/packages/kernel/src/adapter-cursor-cloud.ts @@ -1,636 +1 @@ -import * as orientation from './orientation.js'; -import * as store from './store.js'; -import * as thread from './thread.js'; -import type { - DispatchAdapter, - DispatchAdapterCancelInput, - DispatchAdapterCreateInput, - DispatchAdapterDispatchInput, - DispatchAdapterExecutionInput, - DispatchAdapterExecutionResult, - DispatchAdapterExternalUpdate, - DispatchAdapterLogEntry, - DispatchAdapterPollInput, - DispatchAdapterRunStatus, -} from './runtime-adapter-contracts.js'; -import type { RunStatus } from './types.js'; - -const DEFAULT_MAX_STEPS = 200; -const DEFAULT_STEP_DELAY_MS = 25; -const DEFAULT_AGENT_COUNT = 3; -const DEFAULT_EXTERNAL_TIMEOUT_MS = 30_000; - -export class CursorCloudAdapter implements DispatchAdapter { - name = 'cursor-cloud'; - - async create(_input: DispatchAdapterCreateInput): Promise { - return { - runId: 'adapter-managed', - status: 'queued', - }; - } - - async status(runId: string): Promise { - return { runId, status: 'running' }; - } - - async followup(runId: string, _actor: string, _input: string): Promise { - return { runId, status: 'running' }; - } - - async stop(runId: string, _actor: string): Promise { - return { runId, status: 'cancelled' }; - } - - async logs(_runId: string): Promise { - return []; - } - - async dispatch(input: DispatchAdapterDispatchInput): Promise { - const config = resolveCursorBrokerConfig(input.context); - if (!config) { - throw new Error('cursor-cloud external broker requires cursor_cloud_api_base_url or cursor_cloud_dispatch_url.'); - } - const now = new Date().toISOString(); - const payload = { - runId: input.runId, - actor: input.actor, - objective: input.objective, - workspacePath: input.workspacePath, - context: input.context ?? {}, - followups: input.followups ?? [], - external: input.external ?? null, - ts: now, - }; - const response = await fetchJson(config.dispatchUrl, { - method: 'POST', - headers: buildCursorHeaders(config), - body: JSON.stringify(payload), - signal: input.abortSignal, - }, config.timeoutMs); - const externalRunId = readExternalRunId(response.json); - if (!response.ok || !externalRunId) { - throw new Error(`cursor-cloud dispatch failed (${response.status}): ${response.text || 'missing external run id'}`); - } - return { - acknowledged: true, - acknowledgedAt: now, - status: normalizeRunStatus(response.json?.status) ?? 'queued', - external: { - provider: 'cursor-cloud', - externalRunId, - externalAgentId: readString(response.json?.agentId) ?? readString(response.json?.agent_id), - externalThreadId: readString(response.json?.threadId) ?? readString(response.json?.thread_id), - correlationKeys: compactStrings([ - input.runId, - readString(input.context?.cursor_correlation_key), - readString(response.json?.correlationKey), - ]), - metadata: { - response: response.json ?? response.text, - }, - }, - lastKnownAt: now, - logs: [ - { - ts: now, - level: 'info', - message: `cursor-cloud dispatched external run ${externalRunId}.`, - }, - ], - metrics: { - adapter: 'cursor-cloud', - httpStatus: response.status, - }, - metadata: { - httpStatus: response.status, - }, - }; - } - - async poll(input: DispatchAdapterPollInput): Promise { - const config = resolveCursorBrokerConfig(input.context); - if (!config) return null; - const response = await fetchJson(resolveTemplate(config.statusUrlTemplate, input.external.externalRunId), { - method: 'GET', - headers: buildCursorHeaders(config), - signal: input.abortSignal, - }, config.timeoutMs); - if (!response.ok) { - throw new Error(`cursor-cloud poll failed (${response.status}): ${response.text || response.statusText}`); - } - return { - status: normalizeRunStatus(response.json?.status), - output: readString(response.json?.output), - error: readString(response.json?.error), - external: { - provider: 'cursor-cloud', - externalRunId: input.external.externalRunId, - externalAgentId: readString(response.json?.agentId) ?? readString(response.json?.agent_id) ?? input.external.externalAgentId, - externalThreadId: readString(response.json?.threadId) ?? readString(response.json?.thread_id) ?? input.external.externalThreadId, - correlationKeys: compactStrings([ - ...(input.external.correlationKeys ?? []), - readString(response.json?.correlationKey), - ]), - metadata: { - response: response.json ?? response.text, - }, - }, - lastKnownAt: readString(response.json?.updatedAt) ?? readString(response.json?.updated_at) ?? new Date().toISOString(), - logs: [], - metadata: { - httpStatus: response.status, - }, - }; - } - - async cancel(input: DispatchAdapterCancelInput): Promise { - const config = resolveCursorBrokerConfig(input.context); - if (!config || !input.external?.externalRunId) { - return { - status: 'cancelled', - acknowledged: true, - acknowledgedAt: new Date().toISOString(), - external: input.external, - }; - } - const now = new Date().toISOString(); - const response = await fetchJson(resolveTemplate(config.cancelUrlTemplate, input.external.externalRunId), { - method: 'POST', - headers: buildCursorHeaders(config), - body: JSON.stringify({ - runId: input.runId, - actor: input.actor, - objective: input.objective, - externalRunId: input.external.externalRunId, - ts: now, - }), - signal: input.abortSignal, - }, config.timeoutMs); - if (!response.ok) { - throw new Error(`cursor-cloud cancel failed (${response.status}): ${response.text || response.statusText}`); - } - return { - status: normalizeRunStatus(response.json?.status), - acknowledged: true, - acknowledgedAt: now, - external: { - provider: 'cursor-cloud', - externalRunId: input.external.externalRunId, - externalAgentId: input.external.externalAgentId, - externalThreadId: input.external.externalThreadId, - correlationKeys: input.external.correlationKeys, - metadata: { - response: response.json ?? response.text, - }, - }, - lastKnownAt: now, - metadata: { - httpStatus: response.status, - }, - }; - } - - async health(): Promise> { - return { - adapter: this.name, - mode: 'dual', - }; - } - - async execute(input: DispatchAdapterExecutionInput): Promise { - const start = Date.now(); - const logs: DispatchAdapterLogEntry[] = []; - const agentPool = normalizeAgents(input.agents, input.actor); - const maxSteps = normalizeInt(input.maxSteps, DEFAULT_MAX_STEPS, 1, 5000); - const stepDelayMs = normalizeInt(input.stepDelayMs, DEFAULT_STEP_DELAY_MS, 0, 5000); - const claimedByAgent: Record = {}; - const completedByAgent: Record = {}; - let stepsExecuted = 0; - let completionCount = 0; - let failureCount = 0; - let cancelled = false; - - for (const agent of agentPool) { - claimedByAgent[agent] = 0; - completedByAgent[agent] = 0; - } - - pushLog(logs, 'info', `Run ${input.runId} started with agents: ${agentPool.join(', ')}`); - pushLog(logs, 'info', `Objective: ${input.objective}`); - - while (stepsExecuted < maxSteps) { - if (input.isCancelled?.()) { - cancelled = true; - pushLog(logs, 'warn', `Run ${input.runId} received cancellation signal.`); - break; - } - - const claimedThisRound: Array<{ agent: string; threadPath: string; goal: string }> = []; - for (const agent of agentPool) { - try { - const claimed = input.space - ? thread.claimNextReadyInSpace(input.workspacePath, agent, input.space) - : thread.claimNextReady(input.workspacePath, agent); - if (!claimed) { - continue; - } - const path = claimed.path; - const goal = String(claimed.fields.goal ?? claimed.fields.title ?? path); - claimedThisRound.push({ agent, threadPath: path, goal }); - claimedByAgent[agent] += 1; - pushLog(logs, 'info', `${agent} claimed ${path}`); - } catch (error) { - // Races are expected in multi-agent scheduling; recover and keep moving. - pushLog(logs, 'warn', `${agent} claim skipped: ${errorMessage(error)}`); - } - } - - if (claimedThisRound.length === 0) { - const readyRemaining = listReady(input.workspacePath, input.space).length; - if (readyRemaining === 0) { - pushLog(logs, 'info', 'No ready threads remaining; autonomous loop complete.'); - break; - } - if (stepDelayMs > 0) { - await sleep(stepDelayMs); - } - continue; - } - - await Promise.all(claimedThisRound.map(async (claimed) => { - if (input.isCancelled?.()) { - cancelled = true; - return; - } - if (stepDelayMs > 0) { - await sleep(stepDelayMs); - } - try { - thread.done( - input.workspacePath, - claimed.threadPath, - claimed.agent, - `Completed by ${claimed.agent} during dispatch run ${input.runId}. Goal: ${claimed.goal}`, - { - evidence: [ - { type: 'thread-ref', value: claimed.threadPath }, - { type: 'reply-ref', value: `thread:${input.runId}` }, - ], - }, - ); - completionCount += 1; - completedByAgent[claimed.agent] += 1; - pushLog(logs, 'info', `${claimed.agent} completed ${claimed.threadPath}`); - } catch (error) { - failureCount += 1; - pushLog(logs, 'error', `${claimed.agent} failed to complete ${claimed.threadPath}: ${errorMessage(error)}`); - } - })); - - stepsExecuted += claimedThisRound.length; - if (cancelled) break; - } - - const readyAfter = listReady(input.workspacePath, input.space); - const activeAfter = input.space - ? store.threadsInSpace(input.workspacePath, input.space).filter((candidate) => candidate.fields.status === 'active') - : store.activeThreads(input.workspacePath); - const openAfter = input.space - ? store.threadsInSpace(input.workspacePath, input.space).filter((candidate) => candidate.fields.status === 'open') - : store.openThreads(input.workspacePath); - const blockedAfter = input.space - ? store.threadsInSpace(input.workspacePath, input.space).filter((candidate) => candidate.fields.status === 'blocked') - : store.blockedThreads(input.workspacePath); - - const elapsedMs = Date.now() - start; - const summary = renderSummary({ - objective: input.objective, - runId: input.runId, - completed: completionCount, - failed: failureCount, - stepsExecuted, - readyRemaining: readyAfter.length, - openRemaining: openAfter.length, - blockedRemaining: blockedAfter.length, - activeRemaining: activeAfter.length, - elapsedMs, - claimedByAgent, - completedByAgent, - cancelled, - }); - - if (input.createCheckpoint !== false) { - try { - orientation.checkpoint( - input.workspacePath, - input.actor, - `Dispatch run ${input.runId} completed autonomous execution.`, - { - next: readyAfter.slice(0, 10).map((entry) => entry.path), - blocked: blockedAfter.slice(0, 10).map((entry) => entry.path), - tags: ['dispatch', 'autonomous-run'], - }, - ); - pushLog(logs, 'info', `Checkpoint recorded for run ${input.runId}.`); - } catch (error) { - // Checkpoint creation is helpful but should not fail a completed run. - pushLog(logs, 'warn', `Checkpoint creation skipped: ${errorMessage(error)}`); - } - } - - if (cancelled) { - return { - status: 'cancelled', - output: summary, - logs, - metrics: { - completed: completionCount, - failed: failureCount, - readyRemaining: readyAfter.length, - openRemaining: openAfter.length, - blockedRemaining: blockedAfter.length, - elapsedMs, - claimedByAgent, - completedByAgent, - }, - }; - } - - if (failureCount > 0) { - return { - status: 'failed', - error: summary, - logs, - metrics: { - completed: completionCount, - failed: failureCount, - readyRemaining: readyAfter.length, - openRemaining: openAfter.length, - blockedRemaining: blockedAfter.length, - elapsedMs, - claimedByAgent, - completedByAgent, - }, - }; - } - - const status = readyAfter.length === 0 && activeAfter.length === 0 ? 'succeeded' : 'failed'; - if (status === 'failed') { - pushLog(logs, 'warn', 'Execution stopped with actionable work still remaining.'); - } - - return { - status, - output: summary, - logs, - metrics: { - completed: completionCount, - failed: failureCount, - readyRemaining: readyAfter.length, - openRemaining: openAfter.length, - blockedRemaining: blockedAfter.length, - elapsedMs, - claimedByAgent, - completedByAgent, - }, - }; - } -} - -function normalizeAgents(agents: string[] | undefined, actor: string): string[] { - const fromInput = (agents ?? []).map((entry) => String(entry).trim()).filter(Boolean); - if (fromInput.length > 0) return [...new Set(fromInput)]; - return Array.from({ length: DEFAULT_AGENT_COUNT }, (_, idx) => `${actor}-worker-${idx + 1}`); -} - -function normalizeInt( - rawValue: number | undefined, - fallback: number, - min: number, - max: number, -): number { - const value = Number.isFinite(rawValue) ? Number(rawValue) : fallback; - return Math.min(max, Math.max(min, Math.trunc(value))); -} - -function pushLog(target: DispatchAdapterLogEntry[], level: DispatchAdapterLogEntry['level'], message: string): void { - target.push({ - ts: new Date().toISOString(), - level, - message, - }); -} - -function listReady(workspacePath: string, space: string | undefined) { - return space - ? thread.listReadyThreadsInSpace(workspacePath, space) - : thread.listReadyThreads(workspacePath); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -interface CursorBrokerConfig { - dispatchUrl: string; - statusUrlTemplate: string; - cancelUrlTemplate: string; - token?: string; - headers: Record; - timeoutMs: number; -} - -async function fetchJson( - url: string, - init: RequestInit, - timeoutMs: number, -): Promise<{ - ok: boolean; - status: number; - statusText: string; - text: string; - json: Record | null; -}> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { - ...init, - signal: init.signal ?? controller.signal, - }); - const text = await response.text(); - return { - ok: response.ok, - status: response.status, - statusText: response.statusText, - text, - json: safeParseJson(text), - }; - } finally { - clearTimeout(timeout); - } -} - -function resolveCursorBrokerConfig(context: Record | undefined): CursorBrokerConfig | null { - const baseUrl = resolveUrl( - context?.cursor_cloud_api_base_url, - process.env.WORKGRAPH_CURSOR_CLOUD_API_BASE_URL, - ); - const dispatchUrl = resolveUrl( - context?.cursor_cloud_dispatch_url, - baseUrl ? `${baseUrl}/runs` : undefined, - ); - if (!dispatchUrl) return null; - const statusUrlTemplate = readString(context?.cursor_cloud_status_url_template) - ?? (baseUrl ? `${baseUrl}/runs/{externalRunId}` : undefined) - ?? `${dispatchUrl.replace(/\/+$/, '')}/{externalRunId}`; - const cancelUrlTemplate = readString(context?.cursor_cloud_cancel_url_template) - ?? (baseUrl ? `${baseUrl}/runs/{externalRunId}/cancel` : undefined) - ?? `${dispatchUrl.replace(/\/+$/, '')}/{externalRunId}/cancel`; - return { - dispatchUrl, - statusUrlTemplate, - cancelUrlTemplate, - token: readString(context?.cursor_cloud_api_token) ?? readString(process.env.WORKGRAPH_CURSOR_CLOUD_API_TOKEN), - headers: readHeaders(context?.cursor_cloud_headers), - timeoutMs: normalizeInt(readNumber(context?.cursor_cloud_timeout_ms), DEFAULT_EXTERNAL_TIMEOUT_MS, 1_000, 120_000), - }; -} - -function buildCursorHeaders(config: CursorBrokerConfig): Record { - return { - 'content-type': 'application/json', - ...config.headers, - ...(config.token ? { authorization: `Bearer ${config.token}` } : {}), - }; -} - -function resolveTemplate(template: string, externalRunId: string): string { - return template.replaceAll('{externalRunId}', externalRunId); -} - -function readHeaders(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - const record = value as Record; - const headers: Record = {}; - for (const [key, raw] of Object.entries(record)) { - if (!key) continue; - if (raw === undefined || raw === null) continue; - headers[key.toLowerCase()] = String(raw); - } - return headers; -} - -function safeParseJson(value: string): Record | null { - if (!value.trim()) return null; - try { - const parsed = JSON.parse(value) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; - return parsed as Record; - } catch { - return null; - } -} - -function readExternalRunId(value: Record | null): string | undefined { - return readString(value?.externalRunId) - ?? readString(value?.external_run_id) - ?? readString(value?.runId) - ?? readString(value?.run_id) - ?? readString(value?.id) - ?? readString(value?.agentId) - ?? readString(value?.agent_id); -} - -function resolveUrl(...values: unknown[]): string | undefined { - for (const value of values) { - const candidate = readString(value); - if (!candidate) continue; - try { - const url = new URL(candidate); - if (url.protocol === 'http:' || url.protocol === 'https:') { - return url.toString(); - } - } catch { - continue; - } - } - return undefined; -} - -function normalizeRunStatus(value: unknown): RunStatus | undefined { - const normalized = String(value ?? '').trim().toLowerCase(); - if ( - normalized === 'queued' - || normalized === 'running' - || normalized === 'succeeded' - || normalized === 'failed' - || normalized === 'cancelled' - ) { - return normalized; - } - return undefined; -} - -function compactStrings(values: Array): string[] { - return [...new Set(values.filter((entry): entry is string => Boolean(entry && entry.trim())).map((entry) => entry.trim()))]; -} - -function readString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function readNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim().length > 0) { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; -} - -function renderSummary(data: { - objective: string; - runId: string; - completed: number; - failed: number; - stepsExecuted: number; - readyRemaining: number; - openRemaining: number; - blockedRemaining: number; - activeRemaining: number; - elapsedMs: number; - claimedByAgent: Record; - completedByAgent: Record; - cancelled: boolean; -}): string { - const lines = [ - `Autonomous dispatch summary for ${data.runId}`, - `Objective: ${data.objective}`, - `Completed threads: ${data.completed}`, - `Failed completions: ${data.failed}`, - `Scheduler steps executed: ${data.stepsExecuted}`, - `Ready remaining: ${data.readyRemaining}`, - `Open remaining: ${data.openRemaining}`, - `Blocked remaining: ${data.blockedRemaining}`, - `Active remaining: ${data.activeRemaining}`, - `Elapsed ms: ${data.elapsedMs}`, - `Cancelled: ${data.cancelled ? 'yes' : 'no'}`, - '', - 'Claims by agent:', - ...Object.entries(data.claimedByAgent).map(([agent, count]) => `- ${agent}: ${count}`), - '', - 'Completions by agent:', - ...Object.entries(data.completedByAgent).map(([agent, count]) => `- ${agent}: ${count}`), - ]; - return lines.join('\n'); -} +export { CursorCloudAdapter } from '../../adapter-cursor-cloud/src/adapter.js'; diff --git a/packages/kernel/src/adapter-http-webhook.ts b/packages/kernel/src/adapter-http-webhook.ts index ac05afd..0efb1f4 100644 --- a/packages/kernel/src/adapter-http-webhook.ts +++ b/packages/kernel/src/adapter-http-webhook.ts @@ -1,243 +1 @@ -import type { - DispatchAdapter, - DispatchAdapterCreateInput, - DispatchAdapterExecutionInput, - DispatchAdapterExecutionResult, - DispatchAdapterLogEntry, - DispatchAdapterRunStatus, -} from './runtime-adapter-contracts.js'; - -const DEFAULT_POLL_MS = 1000; -const DEFAULT_MAX_WAIT_MS = 90_000; - -export class HttpWebhookAdapter implements DispatchAdapter { - name = 'http-webhook'; - - async create(_input: DispatchAdapterCreateInput): Promise { - return { runId: 'http-webhook-managed', status: 'queued' }; - } - - async status(runId: string): Promise { - return { runId, status: 'running' }; - } - - async followup(runId: string, _actor: string, _input: string): Promise { - return { runId, status: 'running' }; - } - - async stop(runId: string, _actor: string): Promise { - return { runId, status: 'cancelled' }; - } - - async logs(_runId: string): Promise { - return []; - } - - async execute(input: DispatchAdapterExecutionInput): Promise { - const logs: DispatchAdapterLogEntry[] = []; - const webhookUrl = resolveUrl(input.context?.webhook_url, process.env.WORKGRAPH_DISPATCH_WEBHOOK_URL); - if (!webhookUrl) { - return { - status: 'failed', - error: 'http-webhook adapter requires context.webhook_url or WORKGRAPH_DISPATCH_WEBHOOK_URL.', - logs, - }; - } - - const token = readString(input.context?.webhook_token) ?? process.env.WORKGRAPH_DISPATCH_WEBHOOK_TOKEN; - const headers = { - 'content-type': 'application/json', - ...extractHeaders(input.context?.webhook_headers), - ...(token ? { authorization: `Bearer ${token}` } : {}), - }; - - const payload = { - runId: input.runId, - actor: input.actor, - objective: input.objective, - workspacePath: input.workspacePath, - context: input.context ?? {}, - ts: new Date().toISOString(), - }; - - pushLog(logs, 'info', `http-webhook posting run ${input.runId} to ${webhookUrl}`); - const response = await fetch(webhookUrl, { - method: 'POST', - headers, - body: JSON.stringify(payload), - }); - const rawText = await response.text(); - const parsed = safeParseJson(rawText); - pushLog(logs, response.ok ? 'info' : 'error', `http-webhook response status: ${response.status}`); - - if (!response.ok) { - return { - status: 'failed', - error: `http-webhook request failed (${response.status}): ${rawText || response.statusText}`, - logs, - }; - } - - const immediateStatus = normalizeRunStatus(parsed?.status); - if (immediateStatus && isTerminalStatus(immediateStatus)) { - return { - status: immediateStatus, - output: typeof parsed?.output === 'string' ? parsed.output : rawText, - error: typeof parsed?.error === 'string' ? parsed.error : undefined, - logs, - metrics: { - adapter: 'http-webhook', - httpStatus: response.status, - }, - }; - } - - const pollUrl = resolveUrl(parsed?.pollUrl, input.context?.webhook_status_url, process.env.WORKGRAPH_DISPATCH_WEBHOOK_STATUS_URL); - if (!pollUrl) { - // Treat successful non-terminal response as succeeded for synchronous handlers. - return { - status: 'succeeded', - output: rawText || 'http-webhook acknowledged run successfully.', - logs, - metrics: { - adapter: 'http-webhook', - httpStatus: response.status, - }, - }; - } - - const pollMs = clampInt(readNumber(input.context?.webhook_poll_ms), DEFAULT_POLL_MS, 200, 30_000); - const maxWaitMs = clampInt(readNumber(input.context?.webhook_max_wait_ms), DEFAULT_MAX_WAIT_MS, 1000, 15 * 60_000); - const startedAt = Date.now(); - pushLog(logs, 'info', `http-webhook polling status from ${pollUrl}`); - - while (Date.now() - startedAt < maxWaitMs) { - if (input.isCancelled?.()) { - pushLog(logs, 'warn', 'http-webhook run cancelled while polling'); - return { - status: 'cancelled', - output: 'http-webhook polling cancelled by dispatcher.', - logs, - }; - } - - const pollResponse = await fetch(pollUrl, { - method: 'GET', - headers: { - ...headers, - }, - }); - const pollText = await pollResponse.text(); - const pollJson = safeParseJson(pollText); - const pollStatus = normalizeRunStatus(pollJson?.status); - pushLog(logs, 'info', `poll status=${pollResponse.status} run_status=${pollStatus ?? 'unknown'}`); - - if (pollStatus && isTerminalStatus(pollStatus)) { - return { - status: pollStatus, - output: typeof pollJson?.output === 'string' ? pollJson.output : pollText, - error: typeof pollJson?.error === 'string' ? pollJson.error : undefined, - logs, - metrics: { - adapter: 'http-webhook', - pollUrl, - pollHttpStatus: pollResponse.status, - elapsedMs: Date.now() - startedAt, - }, - }; - } - - await sleep(pollMs); - } - - return { - status: 'failed', - error: `http-webhook polling exceeded timeout (${maxWaitMs}ms) for run ${input.runId}.`, - logs, - }; - } -} - -function pushLog(target: DispatchAdapterLogEntry[], level: DispatchAdapterLogEntry['level'], message: string): void { - target.push({ - ts: new Date().toISOString(), - level, - message, - }); -} - -function readString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function resolveUrl(...values: unknown[]): string | undefined { - for (const value of values) { - const parsed = readString(value); - if (!parsed) continue; - try { - const url = new URL(parsed); - if (url.protocol === 'http:' || url.protocol === 'https:') { - return url.toString(); - } - } catch { - continue; - } - } - return undefined; -} - -function extractHeaders(input: unknown): Record { - if (!input || typeof input !== 'object' || Array.isArray(input)) return {}; - const record = input as Record; - const out: Record = {}; - for (const [key, value] of Object.entries(record)) { - if (!key || value === undefined || value === null) continue; - out[key.toLowerCase()] = String(value); - } - return out; -} - -function safeParseJson(value: string): Record | null { - if (!value || !value.trim()) return null; - try { - const parsed = JSON.parse(value) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; - return parsed as Record; - } catch { - return null; - } -} - -function normalizeRunStatus(value: unknown): DispatchAdapterRunStatus['status'] | undefined { - const normalized = String(value ?? '').toLowerCase(); - if (normalized === 'queued' || normalized === 'running' || normalized === 'succeeded' || normalized === 'failed' || normalized === 'cancelled') { - return normalized; - } - return undefined; -} - -function isTerminalStatus(status: DispatchAdapterRunStatus['status']): boolean { - return status === 'succeeded' || status === 'failed' || status === 'cancelled'; -} - -function readNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim().length > 0) { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; -} - -function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { - const raw = typeof value === 'number' ? Math.trunc(value) : fallback; - return Math.min(max, Math.max(min, raw)); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} +export { HttpWebhookAdapter } from '../../adapter-http-webhook/src/adapter.js'; diff --git a/packages/kernel/src/adapter-shell-worker.ts b/packages/kernel/src/adapter-shell-worker.ts index c835ebf..e857007 100644 --- a/packages/kernel/src/adapter-shell-worker.ts +++ b/packages/kernel/src/adapter-shell-worker.ts @@ -1,260 +1 @@ -import { spawn } from 'node:child_process'; -import { CursorCloudAdapter } from './adapter-cursor-cloud.js'; -import type { - DispatchAdapter, - DispatchAdapterCreateInput, - DispatchAdapterExecutionInput, - DispatchAdapterExecutionResult, - DispatchAdapterLogEntry, - DispatchAdapterRunStatus, -} from './runtime-adapter-contracts.js'; - -const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; -const MAX_CAPTURE_CHARS = 12000; - -export class ShellWorkerAdapter implements DispatchAdapter { - name = 'shell-worker'; - private readonly fallback = new CursorCloudAdapter(); - - async create(_input: DispatchAdapterCreateInput): Promise { - return { runId: 'shell-worker-managed', status: 'queued' }; - } - - async status(runId: string): Promise { - return { runId, status: 'running' }; - } - - async followup(runId: string, _actor: string, _input: string): Promise { - return { runId, status: 'running' }; - } - - async stop(runId: string, _actor: string): Promise { - return { runId, status: 'cancelled' }; - } - - async logs(_runId: string): Promise { - return []; - } - - async execute(input: DispatchAdapterExecutionInput): Promise { - const command = readString(input.context?.shell_command); - if (!command) { - // No explicit shell command configured: execute with coordination fallback. - return this.fallback.execute(input); - } - - const shellCwd = readString(input.context?.shell_cwd) ?? input.workspacePath; - const timeoutMs = clampInt(readNumber(input.context?.shell_timeout_ms), DEFAULT_TIMEOUT_MS, 1000, 60 * 60 * 1000); - const shellEnv = readEnv(input.context?.shell_env); - const logs: DispatchAdapterLogEntry[] = []; - const startedAt = Date.now(); - const outputParts: string[] = []; - const errorParts: string[] = []; - - pushLog(logs, 'info', `shell-worker starting command: ${command}`); - pushLog(logs, 'info', `shell-worker cwd: ${shellCwd}`); - - const result = await runShellCommand({ - command, - cwd: shellCwd, - timeoutMs, - env: shellEnv, - isCancelled: input.isCancelled, - onStdout: (chunk) => { - outputParts.push(chunk); - pushLog(logs, 'info', `[stdout] ${chunk.trimEnd()}`); - }, - onStderr: (chunk) => { - errorParts.push(chunk); - pushLog(logs, 'warn', `[stderr] ${chunk.trimEnd()}`); - }, - }); - - const elapsedMs = Date.now() - startedAt; - const stdout = truncateText(outputParts.join(''), MAX_CAPTURE_CHARS); - const stderr = truncateText(errorParts.join(''), MAX_CAPTURE_CHARS); - - if (result.cancelled) { - pushLog(logs, 'warn', `shell-worker command cancelled after ${elapsedMs}ms`); - return { - status: 'cancelled', - output: formatShellOutput(command, result.exitCode, stdout, stderr, elapsedMs, true), - logs, - }; - } - - if (result.timedOut) { - pushLog(logs, 'error', `shell-worker command timed out after ${elapsedMs}ms`); - return { - status: 'failed', - error: formatShellOutput(command, result.exitCode, stdout, stderr, elapsedMs, false), - logs, - }; - } - - if (result.exitCode !== 0) { - pushLog(logs, 'error', `shell-worker command failed with exit code ${result.exitCode}`); - return { - status: 'failed', - error: formatShellOutput(command, result.exitCode, stdout, stderr, elapsedMs, false), - logs, - }; - } - - pushLog(logs, 'info', `shell-worker command succeeded in ${elapsedMs}ms`); - return { - status: 'succeeded', - output: formatShellOutput(command, result.exitCode, stdout, stderr, elapsedMs, false), - logs, - metrics: { - elapsedMs, - exitCode: result.exitCode, - adapter: 'shell-worker', - }, - }; - } -} - -interface RunShellCommandOptions { - command: string; - cwd: string; - timeoutMs: number; - env: Record; - isCancelled?: () => boolean; - onStdout: (chunk: string) => void; - onStderr: (chunk: string) => void; -} - -interface RunShellCommandResult { - exitCode: number; - timedOut: boolean; - cancelled: boolean; -} - -async function runShellCommand(options: RunShellCommandOptions): Promise { - return new Promise((resolve) => { - const child = spawn(options.command, { - cwd: options.cwd, - env: { ...process.env, ...options.env }, - shell: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let resolved = false; - let timedOut = false; - let cancelled = false; - const timeoutHandle = setTimeout(() => { - timedOut = true; - child.kill('SIGTERM'); - setTimeout(() => child.kill('SIGKILL'), 1500).unref(); - }, options.timeoutMs); - - const cancelWatcher = setInterval(() => { - if (options.isCancelled?.()) { - cancelled = true; - child.kill('SIGTERM'); - } - }, 200); - cancelWatcher.unref(); - - child.stdout.on('data', (chunk: Buffer) => { - options.onStdout(chunk.toString('utf-8')); - }); - child.stderr.on('data', (chunk: Buffer) => { - options.onStderr(chunk.toString('utf-8')); - }); - - child.on('close', (code) => { - if (resolved) return; - resolved = true; - clearTimeout(timeoutHandle); - clearInterval(cancelWatcher); - resolve({ - exitCode: typeof code === 'number' ? code : 1, - timedOut, - cancelled, - }); - }); - - child.on('error', () => { - if (resolved) return; - resolved = true; - clearTimeout(timeoutHandle); - clearInterval(cancelWatcher); - resolve({ - exitCode: 1, - timedOut, - cancelled, - }); - }); - }); -} - -function pushLog(target: DispatchAdapterLogEntry[], level: DispatchAdapterLogEntry['level'], message: string): void { - target.push({ - ts: new Date().toISOString(), - level, - message, - }); -} - -function readEnv(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - const input = value as Record; - const result: Record = {}; - for (const [key, raw] of Object.entries(input)) { - if (!key) continue; - if (raw === undefined || raw === null) continue; - result[key] = String(raw); - } - return result; -} - -function readString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function readNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim().length > 0) { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; -} - -function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { - const raw = typeof value === 'number' ? Math.trunc(value) : fallback; - return Math.min(max, Math.max(min, raw)); -} - -function truncateText(value: string, limit: number): string { - if (value.length <= limit) return value; - return `${value.slice(0, limit)}\n...[truncated]`; -} - -function formatShellOutput( - command: string, - exitCode: number, - stdout: string, - stderr: string, - elapsedMs: number, - cancelled: boolean, -): string { - const lines = [ - 'Shell worker execution summary', - `Command: ${command}`, - `Exit code: ${exitCode}`, - `Elapsed ms: ${elapsedMs}`, - `Cancelled: ${cancelled ? 'yes' : 'no'}`, - '', - 'STDOUT:', - stdout || '(empty)', - '', - 'STDERR:', - stderr || '(empty)', - ]; - return lines.join('\n'); -} +export { ShellWorkerAdapter } from '../../adapter-shell-worker/src/adapter.js'; diff --git a/packages/kernel/src/adapters.test.ts b/packages/kernel/src/adapters.test.ts index 1c66ff9..a33ded7 100644 --- a/packages/kernel/src/adapters.test.ts +++ b/packages/kernel/src/adapters.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import http from 'node:http'; +import { registerDefaultDispatchAdaptersIntoKernelRegistry } from '@versatly/workgraph-runtime-adapter-core'; import { loadRegistry, saveRegistry } from './registry.js'; import * as dispatch from './dispatch.js'; @@ -12,6 +13,7 @@ beforeEach(() => { workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-adapters-')); const registry = loadRegistry(workspacePath); saveRegistry(workspacePath, registry); + registerDefaultDispatchAdaptersIntoKernelRegistry(); }); afterEach(() => { diff --git a/packages/kernel/src/cursor-bridge.test.ts b/packages/kernel/src/cursor-bridge.test.ts index b720428..90da9c1 100644 --- a/packages/kernel/src/cursor-bridge.test.ts +++ b/packages/kernel/src/cursor-bridge.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { registerDefaultDispatchAdaptersIntoKernelRegistry } from '@versatly/workgraph-runtime-adapter-core'; import { createCursorBridgeWebhookSignature, dispatchCursorAutomationEvent, @@ -18,6 +19,7 @@ beforeEach(() => { workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-cursor-bridge-')); const registry = loadRegistry(workspacePath); saveRegistry(workspacePath, registry); + registerDefaultDispatchAdaptersIntoKernelRegistry(); }); afterEach(() => { diff --git a/packages/kernel/src/dispatch-evidence-loop.test.ts b/packages/kernel/src/dispatch-evidence-loop.test.ts index 08b3fe4..8551d60 100644 --- a/packages/kernel/src/dispatch-evidence-loop.test.ts +++ b/packages/kernel/src/dispatch-evidence-loop.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; +import { registerDefaultDispatchAdaptersIntoKernelRegistry } from '@versatly/workgraph-runtime-adapter-core'; import { loadRegistry, saveRegistry } from './registry.js'; import { auditTrail, @@ -21,6 +22,7 @@ beforeEach(() => { workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-dispatch-evidence-')); const registry = loadRegistry(workspacePath); saveRegistry(workspacePath, registry); + registerDefaultDispatchAdaptersIntoKernelRegistry(); const gitInit = spawnSync('git', ['init'], { cwd: workspacePath, stdio: 'ignore', diff --git a/packages/kernel/src/policy-dispatch.test.ts b/packages/kernel/src/policy-dispatch.test.ts index 200118a..53275f2 100644 --- a/packages/kernel/src/policy-dispatch.test.ts +++ b/packages/kernel/src/policy-dispatch.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { registerDefaultDispatchAdaptersIntoKernelRegistry } from '@versatly/workgraph-runtime-adapter-core'; import { loadRegistry, saveRegistry } from './registry.js'; import * as store from './store.js'; import * as policy from './policy.js'; @@ -16,6 +17,7 @@ beforeEach(() => { workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-policy-dispatch-')); const registry = loadRegistry(workspacePath); saveRegistry(workspacePath, registry); + registerDefaultDispatchAdaptersIntoKernelRegistry(); }); afterEach(() => { diff --git a/packages/kernel/src/reconciler-runs.test.ts b/packages/kernel/src/reconciler-runs.test.ts index 5b3c143..e36f69b 100644 --- a/packages/kernel/src/reconciler-runs.test.ts +++ b/packages/kernel/src/reconciler-runs.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import matter from 'gray-matter'; +import { registerDefaultDispatchAdaptersIntoKernelRegistry } from '@versatly/workgraph-runtime-adapter-core'; import * as dispatch from './dispatch.js'; import * as reconciler from './reconciler.js'; import { loadRegistry, saveRegistry } from './registry.js'; @@ -24,6 +25,7 @@ describe('dispatch run reconciler', () => { beforeEach(() => { workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-reconciler-runs-')); saveRegistry(workspacePath, loadRegistry(workspacePath)); + registerDefaultDispatchAdaptersIntoKernelRegistry(); vi.restoreAllMocks(); fetchMock.mockReset(); vi.stubGlobal('fetch', fetchMock); diff --git a/packages/kernel/src/runtime-adapter-registry.test.ts b/packages/kernel/src/runtime-adapter-registry.test.ts index 233a1b4..5d01ca4 100644 --- a/packages/kernel/src/runtime-adapter-registry.test.ts +++ b/packages/kernel/src/runtime-adapter-registry.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { registerDefaultDispatchAdaptersIntoKernelRegistry } from '@versatly/workgraph-runtime-adapter-core'; import type { DispatchAdapter } from './runtime-adapter-contracts.js'; import { listDispatchAdapters, @@ -36,6 +37,7 @@ function makeAdapter(name: string): DispatchAdapter { describe('runtime adapter registry', () => { it('lists built-in adapters in sorted order', () => { + registerDefaultDispatchAdaptersIntoKernelRegistry(); const names = listDispatchAdapters(); expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); expect(names).toEqual(expect.arrayContaining([ @@ -47,6 +49,7 @@ describe('runtime adapter registry', () => { }); it('resolves built-in adapters with normalized adapter names', () => { + registerDefaultDispatchAdaptersIntoKernelRegistry(); const adapter = resolveDispatchAdapter(' CLAUDE-Code '); expect(adapter.name).toBe('claude-code'); }); diff --git a/packages/kernel/src/runtime-adapter-registry.ts b/packages/kernel/src/runtime-adapter-registry.ts index c39f71c..d984132 100644 --- a/packages/kernel/src/runtime-adapter-registry.ts +++ b/packages/kernel/src/runtime-adapter-registry.ts @@ -1,17 +1,8 @@ -import { ClaudeCodeAdapter } from './adapter-claude-code.js'; -import { CursorCloudAdapter } from './adapter-cursor-cloud.js'; -import { HttpWebhookAdapter } from './adapter-http-webhook.js'; -import { ShellWorkerAdapter } from './adapter-shell-worker.js'; import type { DispatchAdapter } from './runtime-adapter-contracts.js'; type DispatchAdapterFactory = () => DispatchAdapter; -const adapterFactories = new Map([ - ['claude-code', () => new ClaudeCodeAdapter()], - ['cursor-cloud', () => new CursorCloudAdapter()], - ['http-webhook', () => new HttpWebhookAdapter()], - ['shell-worker', () => new ShellWorkerAdapter()], -]); +const adapterFactories = new Map(); export function registerDispatchAdapter(name: string, factory: DispatchAdapterFactory): void { const safeName = normalizeName(name); diff --git a/packages/kernel/src/trigger-run-evidence-loop.test.ts b/packages/kernel/src/trigger-run-evidence-loop.test.ts index 5193c11..bec9b28 100644 --- a/packages/kernel/src/trigger-run-evidence-loop.test.ts +++ b/packages/kernel/src/trigger-run-evidence-loop.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { registerDefaultDispatchAdaptersIntoKernelRegistry } from '@versatly/workgraph-runtime-adapter-core'; import { loadRegistry, saveRegistry } from './registry.js'; import * as dispatch from './dispatch.js'; import * as store from './store.js'; @@ -17,6 +18,7 @@ beforeEach(() => { workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-trigger-run-loop-')); const registry = loadRegistry(workspacePath); saveRegistry(workspacePath, registry); + registerDefaultDispatchAdaptersIntoKernelRegistry(); }); afterEach(() => { diff --git a/packages/mcp-server/src/mcp-server.ts b/packages/mcp-server/src/mcp-server.ts index 835c90a..2a855c3 100644 --- a/packages/mcp-server/src/mcp-server.ts +++ b/packages/mcp-server/src/mcp-server.ts @@ -1,5 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { registerDefaultDispatchAdaptersIntoKernelRegistry } from '@versatly/workgraph-runtime-adapter-core'; import { registerCollaborationTools } from './mcp/tools/collaboration-tools.js'; import { registerResources } from './mcp/resources.js'; import { registerReadTools } from './mcp/tools/read-tools.js'; @@ -17,6 +18,7 @@ export interface WorkgraphMcpServerOptions { } export function createWorkgraphMcpServer(options: WorkgraphMcpServerOptions): McpServer { + registerDefaultDispatchAdaptersIntoKernelRegistry(); const server = new McpServer({ name: options.name ?? DEFAULT_SERVER_NAME, version: options.version ?? DEFAULT_SERVER_VERSION, diff --git a/packages/runtime-adapter-core/package.json b/packages/runtime-adapter-core/package.json index 2624179..dec3fb7 100644 --- a/packages/runtime-adapter-core/package.json +++ b/packages/runtime-adapter-core/package.json @@ -9,6 +9,10 @@ "main": "src/index.ts", "types": "src/index.ts", "dependencies": { + "@versatly/workgraph-adapter-claude-code": "workspace:*", + "@versatly/workgraph-adapter-cursor-cloud": "workspace:*", + "@versatly/workgraph-adapter-http-webhook": "workspace:*", + "@versatly/workgraph-adapter-shell-worker": "workspace:*", "@versatly/workgraph-kernel": "workspace:*" } } diff --git a/packages/runtime-adapter-core/src/default-composition.ts b/packages/runtime-adapter-core/src/default-composition.ts new file mode 100644 index 0000000..9e100b6 --- /dev/null +++ b/packages/runtime-adapter-core/src/default-composition.ts @@ -0,0 +1,15 @@ +import { + runtimeAdapterRegistry, +} from '@versatly/workgraph-kernel'; +import { ClaudeCodeAdapter } from '@versatly/workgraph-adapter-claude-code'; +import { CursorCloudAdapter } from '@versatly/workgraph-adapter-cursor-cloud'; +import { HttpWebhookAdapter } from '@versatly/workgraph-adapter-http-webhook'; +import { ShellWorkerAdapter } from '@versatly/workgraph-adapter-shell-worker'; + +export function registerDefaultDispatchAdaptersIntoKernelRegistry(): string[] { + runtimeAdapterRegistry.registerDispatchAdapter('claude-code', () => new ClaudeCodeAdapter()); + runtimeAdapterRegistry.registerDispatchAdapter('cursor-cloud', () => new CursorCloudAdapter()); + runtimeAdapterRegistry.registerDispatchAdapter('http-webhook', () => new HttpWebhookAdapter()); + runtimeAdapterRegistry.registerDispatchAdapter('shell-worker', () => new ShellWorkerAdapter()); + return runtimeAdapterRegistry.listDispatchAdapters(); +} diff --git a/packages/runtime-adapter-core/src/index.ts b/packages/runtime-adapter-core/src/index.ts index 72d1295..42066e1 100644 --- a/packages/runtime-adapter-core/src/index.ts +++ b/packages/runtime-adapter-core/src/index.ts @@ -2,3 +2,4 @@ export * from './contracts.js'; export * from './shell-adapter.js'; export * from './webhook-adapter.js'; export * from './adapter-registry.js'; +export * from './default-composition.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 154c281..7c90583 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,15 +50,36 @@ importers: packages/adapter-claude-code: dependencies: - '@versatly/workgraph-kernel': + '@versatly/workgraph-adapter-shell-worker': specifier: workspace:* - version: link:../kernel + version: link:../adapter-shell-worker + '@versatly/workgraph-runtime-adapter-core': + specifier: workspace:* + version: link:../runtime-adapter-core packages/adapter-cursor-cloud: dependencies: '@versatly/workgraph-kernel': specifier: workspace:* version: link:../kernel + '@versatly/workgraph-runtime-adapter-core': + specifier: workspace:* + version: link:../runtime-adapter-core + + packages/adapter-http-webhook: + dependencies: + '@versatly/workgraph-runtime-adapter-core': + specifier: workspace:* + version: link:../runtime-adapter-core + + packages/adapter-shell-worker: + dependencies: + '@versatly/workgraph-adapter-cursor-cloud': + specifier: workspace:* + version: link:../adapter-cursor-cloud + '@versatly/workgraph-runtime-adapter-core': + specifier: workspace:* + version: link:../runtime-adapter-core packages/cli: dependencies: @@ -105,9 +126,6 @@ importers: '@versatly/workgraph-kernel': specifier: workspace:* version: link:../kernel - '@versatly/workgraph-policy': - specifier: workspace:* - version: link:../policy zod: specifier: ^4.3.6 version: 4.3.6 @@ -125,6 +143,18 @@ importers: packages/runtime-adapter-core: dependencies: + '@versatly/workgraph-adapter-claude-code': + specifier: workspace:* + version: link:../adapter-claude-code + '@versatly/workgraph-adapter-cursor-cloud': + specifier: workspace:* + version: link:../adapter-cursor-cloud + '@versatly/workgraph-adapter-http-webhook': + specifier: workspace:* + version: link:../adapter-http-webhook + '@versatly/workgraph-adapter-shell-worker': + specifier: workspace:* + version: link:../adapter-shell-worker '@versatly/workgraph-kernel': specifier: workspace:* version: link:../kernel @@ -149,9 +179,6 @@ importers: '@versatly/workgraph-obsidian-integration': specifier: workspace:* version: link:../obsidian-integration - '@versatly/workgraph-policy': - specifier: workspace:* - version: link:../policy '@versatly/workgraph-runtime-adapter-core': specifier: workspace:* version: link:../runtime-adapter-core diff --git a/tsup.config.ts b/tsup.config.ts index 67df3b6..e565924 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -19,6 +19,8 @@ export default defineConfig({ '@versatly/workgraph-control-api', '@versatly/workgraph-adapter-claude-code', '@versatly/workgraph-adapter-cursor-cloud', + '@versatly/workgraph-adapter-http-webhook', + '@versatly/workgraph-adapter-shell-worker', '@versatly/workgraph-obsidian-integration', '@versatly/workgraph-runtime-adapter-core', '@versatly/workgraph-search-qmd-adapter', From 94d67962328d15e0def3dc711eb1d05b7a3c66d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Mar 2026 08:32:09 +0000 Subject: [PATCH 5/6] Add operator projections and control plane Co-authored-by: G9Pedro --- apps/web-control-plane/app.js | 43 ++++++ apps/web-control-plane/autonomy-health.html | 14 ++ apps/web-control-plane/federation-status.html | 14 ++ apps/web-control-plane/index.html | 26 ++++ apps/web-control-plane/mission-progress.html | 14 ++ apps/web-control-plane/risk-dashboard.html | 14 ++ apps/web-control-plane/run-health.html | 14 ++ apps/web-control-plane/style.css | 49 ++++++ apps/web-control-plane/transport-health.html | 14 ++ apps/web-control-plane/trigger-health.html | 14 ++ packages/control-api/src/index.ts | 1 + .../src/server-projections.test.ts | 63 ++++++++ .../control-api/src/server-projections.ts | 70 +++++++++ packages/control-api/src/server.ts | 103 +++++++++++++ packages/kernel/src/index.ts | 1 + .../kernel/src/projections/autonomy-health.ts | 29 ++++ .../src/projections/federation-status.ts | 32 ++++ packages/kernel/src/projections/index.ts | 8 + .../src/projections/mission-progress.ts | 33 ++++ .../src/projections/projections.test.ts | 98 ++++++++++++ .../kernel/src/projections/risk-dashboard.ts | 36 +++++ packages/kernel/src/projections/run-health.ts | 54 +++++++ .../src/projections/transport-health.ts | 40 +++++ .../kernel/src/projections/trigger-health.ts | 29 ++++ packages/kernel/src/projections/types.ts | 23 +++ .../mcp-server/src/mcp/tools/read-tools.ts | 142 ++++++++++++++++++ .../mcp-server/src/projection-tools.test.ts | 92 ++++++++++++ 27 files changed, 1070 insertions(+) create mode 100644 apps/web-control-plane/app.js create mode 100644 apps/web-control-plane/autonomy-health.html create mode 100644 apps/web-control-plane/federation-status.html create mode 100644 apps/web-control-plane/index.html create mode 100644 apps/web-control-plane/mission-progress.html create mode 100644 apps/web-control-plane/risk-dashboard.html create mode 100644 apps/web-control-plane/run-health.html create mode 100644 apps/web-control-plane/style.css create mode 100644 apps/web-control-plane/transport-health.html create mode 100644 apps/web-control-plane/trigger-health.html create mode 100644 packages/control-api/src/server-projections.test.ts create mode 100644 packages/control-api/src/server-projections.ts create mode 100644 packages/kernel/src/projections/autonomy-health.ts create mode 100644 packages/kernel/src/projections/federation-status.ts create mode 100644 packages/kernel/src/projections/index.ts create mode 100644 packages/kernel/src/projections/mission-progress.ts create mode 100644 packages/kernel/src/projections/projections.test.ts create mode 100644 packages/kernel/src/projections/risk-dashboard.ts create mode 100644 packages/kernel/src/projections/run-health.ts create mode 100644 packages/kernel/src/projections/transport-health.ts create mode 100644 packages/kernel/src/projections/trigger-health.ts create mode 100644 packages/kernel/src/projections/types.ts create mode 100644 packages/mcp-server/src/projection-tools.test.ts diff --git a/apps/web-control-plane/app.js b/apps/web-control-plane/app.js new file mode 100644 index 0000000..5e6475e --- /dev/null +++ b/apps/web-control-plane/app.js @@ -0,0 +1,43 @@ +async function loadProjection(name) { + const response = await fetch(`/api/projections/${name}`); + if (!response.ok) { + throw new Error(`Failed to load projection ${name}: ${response.status}`); + } + const body = await response.json(); + if (!body.ok) { + throw new Error(body.error || `Projection ${name} returned an error.`); + } + return body.projection; +} + +function renderJson(target, value) { + target.textContent = JSON.stringify(value, null, 2); +} + +function renderSummaryCards(target, summary) { + target.innerHTML = ''; + for (const [key, value] of Object.entries(summary || {})) { + const card = document.createElement('div'); + card.className = 'card'; + card.innerHTML = `

${key}

${String(value)}

`; + target.appendChild(card); + } +} + +async function boot() { + const root = document.getElementById('projection-root'); + const summaryRoot = document.getElementById('projection-summary'); + const projectionName = document.body.dataset.projection; + if (!root || !summaryRoot || !projectionName) return; + try { + const projection = await loadProjection(projectionName); + renderSummaryCards(summaryRoot, projection.summary || projection.projections || {}); + renderJson(root, projection); + } catch (error) { + root.textContent = error instanceof Error ? error.message : String(error); + } +} + +window.addEventListener('DOMContentLoaded', () => { + void boot(); +}); diff --git a/apps/web-control-plane/autonomy-health.html b/apps/web-control-plane/autonomy-health.html new file mode 100644 index 0000000..5437381 --- /dev/null +++ b/apps/web-control-plane/autonomy-health.html @@ -0,0 +1,14 @@ + + + + + + Autonomy Health + + + + +

Autonomy Health

+
+ + diff --git a/apps/web-control-plane/federation-status.html b/apps/web-control-plane/federation-status.html new file mode 100644 index 0000000..c160e7b --- /dev/null +++ b/apps/web-control-plane/federation-status.html @@ -0,0 +1,14 @@ + + + + + + Federation Status + + + + +

Federation Status

+
+ + diff --git a/apps/web-control-plane/index.html b/apps/web-control-plane/index.html new file mode 100644 index 0000000..172f9c5 --- /dev/null +++ b/apps/web-control-plane/index.html @@ -0,0 +1,26 @@ + + + + + + WorkGraph Control Plane + + + +
+

WorkGraph Operator Control Plane

+

Operator-facing projections for dispatch, transport, federation, triggers, autonomy, and missions.

+
+
+
+

Run Health

Active runs, stale runs, and failed reconciliations.

+

Risk Dashboard

Blocked threads, escalations, and policy violations.

+

Mission Progress

Mission completion and milestones.

+

Transport Health

Outbox depth, dead-letter state, and delivery success.

+

Federation Status

Remote workspace compatibility and sync status.

+

Trigger Health

Trigger states, cooldowns, and errors.

+

Autonomy Health

Autonomy daemon status and heartbeat.

+
+
+ + diff --git a/apps/web-control-plane/mission-progress.html b/apps/web-control-plane/mission-progress.html new file mode 100644 index 0000000..b4df69d --- /dev/null +++ b/apps/web-control-plane/mission-progress.html @@ -0,0 +1,14 @@ + + + + + + Mission Progress + + + + +

Mission Progress

+
+ + diff --git a/apps/web-control-plane/risk-dashboard.html b/apps/web-control-plane/risk-dashboard.html new file mode 100644 index 0000000..6c4450b --- /dev/null +++ b/apps/web-control-plane/risk-dashboard.html @@ -0,0 +1,14 @@ + + + + + + Risk Dashboard + + + + +

Risk Dashboard

+
+ + diff --git a/apps/web-control-plane/run-health.html b/apps/web-control-plane/run-health.html new file mode 100644 index 0000000..138a88e --- /dev/null +++ b/apps/web-control-plane/run-health.html @@ -0,0 +1,14 @@ + + + + + + Run Health + + + + +

Run Health

+
+ + diff --git a/apps/web-control-plane/style.css b/apps/web-control-plane/style.css new file mode 100644 index 0000000..98b630f --- /dev/null +++ b/apps/web-control-plane/style.css @@ -0,0 +1,49 @@ +body { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + margin: 0; + background: #0b1020; + color: #eef2ff; +} + +a { + color: #93c5fd; +} + +header, main { + max-width: 1200px; + margin: 0 auto; + padding: 24px; +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.card { + background: #16203a; + border: 1px solid #334155; + border-radius: 12px; + padding: 16px; +} + +.nav { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 24px; +} + +pre { + background: #020617; + border: 1px solid #334155; + border-radius: 12px; + padding: 16px; + overflow: auto; + white-space: pre-wrap; +} + +.muted { + color: #94a3b8; +} diff --git a/apps/web-control-plane/transport-health.html b/apps/web-control-plane/transport-health.html new file mode 100644 index 0000000..283d695 --- /dev/null +++ b/apps/web-control-plane/transport-health.html @@ -0,0 +1,14 @@ + + + + + + Transport Health + + + + +

Transport Health

+
+ + diff --git a/apps/web-control-plane/trigger-health.html b/apps/web-control-plane/trigger-health.html new file mode 100644 index 0000000..2b3c92c --- /dev/null +++ b/apps/web-control-plane/trigger-health.html @@ -0,0 +1,14 @@ + + + + + + Trigger Health + + + + +

Trigger Health

+
+ + diff --git a/packages/control-api/src/index.ts b/packages/control-api/src/index.ts index 079bdfc..e091fc1 100644 --- a/packages/control-api/src/index.ts +++ b/packages/control-api/src/index.ts @@ -1,3 +1,4 @@ export * from './dispatch.js'; export * from './server.js'; +export * from './server-projections.js'; export * from './webhook-gateway.js'; diff --git a/packages/control-api/src/server-projections.test.ts b/packages/control-api/src/server-projections.test.ts new file mode 100644 index 0000000..9b9f246 --- /dev/null +++ b/packages/control-api/src/server-projections.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { projections as projectionsModule, registry as registryModule, thread as threadModule } from '@versatly/workgraph-kernel'; +import { startWorkgraphServer } from './server.js'; + +const projections = projectionsModule; +const registry = registryModule; +const thread = threadModule; + +let workspacePath: string; + +describe('server projection routes', () => { + beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-server-projections-')); + registry.saveRegistry(workspacePath, registry.loadRegistry(workspacePath)); + thread.createThread(workspacePath, 'Projection thread', 'projection thread goal', 'agent-projection'); + }); + + afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); + }); + + it('serves named projection endpoints over HTTP', async () => { + const handle = await startWorkgraphServer({ + workspacePath, + host: '127.0.0.1', + port: 0, + }); + try { + const runHealth = await fetch(`${handle.baseUrl}/api/projections/run-health`); + const runHealthBody = await runHealth.json() as { ok: boolean; projection: ReturnType }; + expect(runHealth.status).toBe(200); + expect(runHealthBody.ok).toBe(true); + expect(runHealthBody.projection.scope).toBe('run'); + + const overview = await fetch(`${handle.baseUrl}/api/projections/overview`); + const overviewBody = await overview.json() as { ok: boolean; projection: { projections: Record } }; + expect(overview.status).toBe(200); + expect(overviewBody.ok).toBe(true); + expect(Object.keys(overviewBody.projection.projections)).toEqual(expect.arrayContaining([ + 'runHealth', + 'riskDashboard', + 'missionProgress', + 'transportHealth', + 'federationStatus', + 'triggerHealth', + 'autonomyHealth', + ])); + + const controlPlaneIndex = await fetch(`${handle.baseUrl}/control-plane`); + expect(controlPlaneIndex.status).toBe(200); + expect(await controlPlaneIndex.text()).toContain('WorkGraph Operator Control Plane'); + + const runHealthPage = await fetch(`${handle.baseUrl}/control-plane/run-health`); + expect(runHealthPage.status).toBe(200); + expect(await runHealthPage.text()).toContain('data-projection="run-health"'); + } finally { + await handle.close(); + } + }); +}); diff --git a/packages/control-api/src/server-projections.ts b/packages/control-api/src/server-projections.ts new file mode 100644 index 0000000..fa3984c --- /dev/null +++ b/packages/control-api/src/server-projections.ts @@ -0,0 +1,70 @@ +import { + projections as projectionsModule, +} from '@versatly/workgraph-kernel'; + +const projections = projectionsModule; + +export type ProjectionRouteName = + | 'overview' + | 'run-health' + | 'risk-dashboard' + | 'mission-progress' + | 'transport-health' + | 'federation-status' + | 'trigger-health' + | 'autonomy-health'; + +export function buildProjectionByName(workspacePath: string, name: ProjectionRouteName) { + switch (name) { + case 'overview': + return buildProjectionOverview(workspacePath); + case 'run-health': + return projections.buildRunHealthProjection(workspacePath); + case 'risk-dashboard': + return projections.buildRiskDashboardProjection(workspacePath); + case 'mission-progress': + return projections.buildMissionProgressProjection(workspacePath); + case 'transport-health': + return projections.buildTransportHealthProjection(workspacePath); + case 'federation-status': + return projections.buildFederationStatusProjection(workspacePath); + case 'trigger-health': + return projections.buildTriggerHealthProjection(workspacePath); + case 'autonomy-health': + return projections.buildAutonomyHealthProjection(workspacePath); + default: + return assertNever(name); + } +} + +export function listProjectionRouteNames(): ProjectionRouteName[] { + return [ + 'overview', + 'run-health', + 'risk-dashboard', + 'mission-progress', + 'transport-health', + 'federation-status', + 'trigger-health', + 'autonomy-health', + ]; +} + +export function buildProjectionOverview(workspacePath: string) { + return { + generatedAt: new Date().toISOString(), + projections: { + runHealth: projections.buildRunHealthProjection(workspacePath), + riskDashboard: projections.buildRiskDashboardProjection(workspacePath), + missionProgress: projections.buildMissionProgressProjection(workspacePath), + transportHealth: projections.buildTransportHealthProjection(workspacePath), + federationStatus: projections.buildFederationStatusProjection(workspacePath), + triggerHealth: projections.buildTriggerHealthProjection(workspacePath), + autonomyHealth: projections.buildAutonomyHealthProjection(workspacePath), + }, + }; +} + +function assertNever(value: never): never { + throw new Error(`Unhandled projection route "${String(value)}".`); +} diff --git a/packages/control-api/src/server.ts b/packages/control-api/src/server.ts index 7604e42..3ee5a0f 100644 --- a/packages/control-api/src/server.ts +++ b/packages/control-api/src/server.ts @@ -19,6 +19,10 @@ import { buildSpacesLens, buildTimelineLens, } from './server-lenses.js'; +import { + buildProjectionByName, + listProjectionRouteNames, +} from './server-projections.js'; import { createDashboardEventFilter, type DashboardEvent, @@ -507,6 +511,87 @@ function registerRestRoutes( } }); + app.get('/api/projections/:name', (req: any, res: any) => { + try { + const projectionName = readNonEmptyString(req.params?.name)?.toLowerCase(); + if (!projectionName) { + res.status(400).json({ + ok: false, + error: 'Missing projection name.', + }); + return; + } + const allowed = new Set(listProjectionRouteNames()); + if (!allowed.has(projectionName as ReturnType[number])) { + res.status(404).json({ + ok: false, + error: `Unknown projection "${projectionName}".`, + available: listProjectionRouteNames(), + }); + return; + } + const projection = buildProjectionByName( + workspacePath, + projectionName as ReturnType[number], + ); + res.json({ + ok: true, + projection, + }); + } catch (error) { + writeRouteError(res, error); + } + }); + + app.get('/control-plane', (_req: any, res: any) => { + try { + serveControlPlaneFile(res, 'index.html', 'text/html; charset=utf-8'); + } catch (error) { + writeRouteError(res, error); + } + }); + + app.get('/control-plane/style.css', (_req: any, res: any) => { + try { + serveControlPlaneFile(res, 'style.css', 'text/css; charset=utf-8'); + } catch (error) { + writeRouteError(res, error); + } + }); + + app.get('/control-plane/app.js', (_req: any, res: any) => { + try { + serveControlPlaneFile(res, 'app.js', 'application/javascript; charset=utf-8'); + } catch (error) { + writeRouteError(res, error); + } + }); + + app.get('/control-plane/:page', (req: any, res: any) => { + try { + const page = readNonEmptyString(req.params?.page)?.toLowerCase(); + if (!page) { + res.status(400).json({ + ok: false, + error: 'Missing control-plane page name.', + }); + return; + } + const allowedPages = new Set(listProjectionRouteNames().filter((entry) => entry !== 'overview')); + if (!allowedPages.has(page as Exclude[number], 'overview'>)) { + res.status(404).json({ + ok: false, + error: `Unknown control-plane page "${page}".`, + available: [...allowedPages], + }); + return; + } + serveControlPlaneFile(res, `${page}.html`, 'text/html; charset=utf-8'); + } catch (error) { + writeRouteError(res, error); + } + }); + app.get('/api/webhooks', (_req: any, res: any) => { try { const webhooks = listWebhooks(workspacePath); @@ -891,6 +976,24 @@ function safeStreamWrite(res: any, chunk: string): boolean { } } +function serveControlPlaneFile(res: any, fileName: string, contentType: string): void { + const root = resolveControlPlaneRoot(); + const filePath = path.join(root, fileName); + if (!fs.existsSync(filePath)) { + throw new Error(`Control plane asset not found: ${fileName}`); + } + res.setHeader('Content-Type', contentType); + res.status(200).send(fs.readFileSync(filePath, 'utf-8')); +} + +function resolveControlPlaneRoot(): string { + const cwdPath = path.resolve(process.cwd(), 'apps', 'web-control-plane'); + if (fs.existsSync(cwdPath)) return cwdPath; + const workspacePath = path.resolve('/workspace/apps/web-control-plane'); + if (fs.existsSync(workspacePath)) return workspacePath; + throw new Error('Unable to locate web control plane assets.'); +} + function logJson(level: LogLevel, event: string, data: Record): void { console.log(JSON.stringify({ ts: new Date().toISOString(), diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index 130ab3d..a806c23 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -83,3 +83,4 @@ export * as environment from './environment.js'; export * as exportImport from './export-import.js'; export * as federation from './federation.js'; export * as transport from './transport/index.js'; +export * as projections from './projections/index.js'; diff --git a/packages/kernel/src/projections/autonomy-health.ts b/packages/kernel/src/projections/autonomy-health.ts new file mode 100644 index 0000000..846c211 --- /dev/null +++ b/packages/kernel/src/projections/autonomy-health.ts @@ -0,0 +1,29 @@ +import * as autonomyDaemon from '../autonomy-daemon.js'; +import type { ProjectionSummary } from './types.js'; + +export interface AutonomyHealthProjection extends ProjectionSummary { + scope: 'autonomy'; + summary: { + running: boolean; + lastHeartbeatAt?: string; + driftIssues?: number; + }; + status: ReturnType; +} + +export function buildAutonomyHealthProjection(workspacePath: string): AutonomyHealthProjection { + const status = autonomyDaemon.readAutonomyDaemonStatus(workspacePath, { + cleanupStalePidFile: true, + }); + return { + scope: 'autonomy', + generatedAt: new Date().toISOString(), + healthy: !status.running || Boolean(status.heartbeat?.driftOk ?? status.heartbeat?.finalDriftOk ?? true), + summary: { + running: status.running, + lastHeartbeatAt: status.heartbeat?.ts, + driftIssues: status.heartbeat?.driftIssues, + }, + status, + }; +} diff --git a/packages/kernel/src/projections/federation-status.ts b/packages/kernel/src/projections/federation-status.ts new file mode 100644 index 0000000..6061398 --- /dev/null +++ b/packages/kernel/src/projections/federation-status.ts @@ -0,0 +1,32 @@ +import * as federation from '../federation.js'; +import type { ProjectionSummary } from './types.js'; + +export interface FederationStatusProjection extends ProjectionSummary { + scope: 'federation'; + summary: { + remotes: number; + compatibleRemotes: number; + staleRemotes: number; + }; + status: ReturnType; +} + +export function buildFederationStatusProjection(workspacePath: string): FederationStatusProjection { + const status = federation.federationStatus(workspacePath); + const compatibleRemotes = status.remotes.filter((entry) => entry.compatible).length; + const staleRemotes = status.remotes.filter((entry) => { + const remote = entry.remote; + return !remote.lastSyncedAt || remote.lastSyncStatus !== 'synced'; + }).length; + return { + scope: 'federation', + generatedAt: new Date().toISOString(), + healthy: status.remotes.every((entry) => entry.compatible && entry.supportsRead), + summary: { + remotes: status.remotes.length, + compatibleRemotes, + staleRemotes, + }, + status, + }; +} diff --git a/packages/kernel/src/projections/index.ts b/packages/kernel/src/projections/index.ts new file mode 100644 index 0000000..146099b --- /dev/null +++ b/packages/kernel/src/projections/index.ts @@ -0,0 +1,8 @@ +export * from './types.js'; +export * from './run-health.js'; +export * from './risk-dashboard.js'; +export * from './mission-progress.js'; +export * from './transport-health.js'; +export * from './federation-status.js'; +export * from './trigger-health.js'; +export * from './autonomy-health.js'; diff --git a/packages/kernel/src/projections/mission-progress.ts b/packages/kernel/src/projections/mission-progress.ts new file mode 100644 index 0000000..6201fab --- /dev/null +++ b/packages/kernel/src/projections/mission-progress.ts @@ -0,0 +1,33 @@ +import * as mission from '../mission.js'; +import * as store from '../store.js'; +import type { ProjectionSummary } from './types.js'; + +export interface MissionProgressProjection extends ProjectionSummary { + scope: 'mission'; + summary: { + totalMissions: number; + completedMissions: number; + averageCompletionPercent: number; + }; + missions: Array>; +} + +export function buildMissionProgressProjection(workspacePath: string): MissionProgressProjection { + const missions = store.list(workspacePath, 'mission') + .map((entry) => mission.missionProgress(workspacePath, entry.path)); + const completedMissions = missions.filter((entry) => entry.percentComplete >= 100 || entry.status === 'completed').length; + const averageCompletionPercent = missions.length === 0 + ? 0 + : Math.round((missions.reduce((sum, entry) => sum + entry.percentComplete, 0) / missions.length) * 100) / 100; + return { + scope: 'mission', + generatedAt: new Date().toISOString(), + healthy: true, + summary: { + totalMissions: missions.length, + completedMissions, + averageCompletionPercent, + }, + missions, + }; +} diff --git a/packages/kernel/src/projections/projections.test.ts b/packages/kernel/src/projections/projections.test.ts new file mode 100644 index 0000000..28af50c --- /dev/null +++ b/packages/kernel/src/projections/projections.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import * as dispatch from '../dispatch.js'; +import * as federation from '../federation.js'; +import { saveRegistry, loadRegistry } from '../registry.js'; +import * as store from '../store.js'; +import * as thread from '../thread.js'; +import * as transport from '../transport/index.js'; +import * as projections from './index.js'; + +let workspacePath: string; +let remoteWorkspacePath: string; + +beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-projections-')); + remoteWorkspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-projections-remote-')); + saveRegistry(workspacePath, loadRegistry(workspacePath)); + saveRegistry(remoteWorkspacePath, loadRegistry(remoteWorkspacePath)); +}); + +afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); + fs.rmSync(remoteWorkspacePath, { recursive: true, force: true }); +}); + +describe('projection builders', () => { + it('builds stable run, risk, transport, federation, trigger, and autonomy projections', () => { + const blockedThread = thread.createThread(workspacePath, 'Blocked projection thread', 'blocked work', 'agent-a'); + thread.claim(workspacePath, blockedThread.path, 'agent-a'); + thread.block(workspacePath, blockedThread.path, 'agent-a', 'external/dependency', 'waiting'); + + const run = dispatch.createRun(workspacePath, { + actor: 'agent-a', + objective: 'Projection run', + }); + dispatch.markRun(workspacePath, run.id, 'agent-a', 'running'); + dispatch.markRun(workspacePath, run.id, 'agent-a', 'failed', { + error: 'failed run', + }); + + const envelope = transport.createTransportEnvelope({ + direction: 'outbound', + channel: 'test', + topic: 'projection', + source: 'test', + target: 'target', + payload: { + ok: true, + }, + }); + const outbox = transport.createTransportOutboxRecord(workspacePath, { + envelope, + deliveryHandler: 'test', + deliveryTarget: 'target', + }); + transport.markTransportOutboxFailed(workspacePath, outbox.id, { + message: 'delivery failed', + }); + + federation.ensureFederationConfig(remoteWorkspacePath); + thread.createThread(remoteWorkspacePath, 'Remote projection thread', 'remote work', 'agent-remote'); + federation.addRemoteWorkspace(workspacePath, { + id: 'remote-main', + path: remoteWorkspacePath, + }); + + store.create(workspacePath, 'trigger', { + title: 'Projection trigger', + status: 'active', + condition: { type: 'manual' }, + action: { + type: 'dispatch-run', + objective: 'Projection trigger run', + }, + cooldown: 0, + }, '# Trigger\n', 'system'); + + const runHealth = projections.buildRunHealthProjection(workspacePath); + expect(runHealth.summary.totalRuns).toBeGreaterThan(0); + + const risk = projections.buildRiskDashboardProjection(workspacePath); + expect(risk.summary.blockedThreads).toBeGreaterThan(0); + + const transportHealth = projections.buildTransportHealthProjection(workspacePath); + expect(transportHealth.summary.deadLetterCount).toBe(1); + + const federationStatus = projections.buildFederationStatusProjection(workspacePath); + expect(federationStatus.summary.remotes).toBe(1); + + const triggerHealth = projections.buildTriggerHealthProjection(workspacePath); + expect(triggerHealth.summary.totalTriggers).toBe(1); + + const autonomyHealth = projections.buildAutonomyHealthProjection(workspacePath); + expect(typeof autonomyHealth.summary.running).toBe('boolean'); + }); +}); diff --git a/packages/kernel/src/projections/risk-dashboard.ts b/packages/kernel/src/projections/risk-dashboard.ts new file mode 100644 index 0000000..bf5d757 --- /dev/null +++ b/packages/kernel/src/projections/risk-dashboard.ts @@ -0,0 +1,36 @@ +import * as store from '../store.js'; +import * as threadAudit from '../thread-audit.js'; +import type { PrimitiveInstance } from '../types.js'; +import type { ProjectionSummary } from './types.js'; + +export interface RiskDashboardProjection extends ProjectionSummary { + scope: 'org'; + summary: { + blockedThreads: number; + escalations: number; + policyViolations: number; + }; + blockedThreads: PrimitiveInstance[]; + escalations: PrimitiveInstance[]; + policyViolations: ReturnType['issues']; +} + +export function buildRiskDashboardProjection(workspacePath: string): RiskDashboardProjection { + const blockedThreads = store.blockedThreads(workspacePath); + const escalations = store.list(workspacePath, 'incident') + .filter((entry) => String(entry.fields.status ?? '').toLowerCase() === 'active'); + const audit = threadAudit.reconcileThreadState(workspacePath); + return { + scope: 'org', + generatedAt: new Date().toISOString(), + healthy: audit.issues.length === 0 && blockedThreads.length === 0, + summary: { + blockedThreads: blockedThreads.length, + escalations: escalations.length, + policyViolations: audit.issues.length, + }, + blockedThreads, + escalations, + policyViolations: audit.issues, + }; +} diff --git a/packages/kernel/src/projections/run-health.ts b/packages/kernel/src/projections/run-health.ts new file mode 100644 index 0000000..4a6d851 --- /dev/null +++ b/packages/kernel/src/projections/run-health.ts @@ -0,0 +1,54 @@ +import * as dispatch from '../dispatch.js'; +import type { DispatchRun } from '../types.js'; +import type { ProjectionSummary } from './types.js'; + +export interface RunHealthProjection extends ProjectionSummary { + scope: 'run'; + summary: { + totalRuns: number; + activeRuns: number; + queuedRuns: number; + staleRuns: number; + failedRuns: number; + failedReconciliations: number; + }; + activeRuns: DispatchRun[]; + staleRuns: DispatchRun[]; + failedRuns: DispatchRun[]; + failedReconciliations: DispatchRun[]; +} + +const DEFAULT_STALE_MINUTES = 30; + +export function buildRunHealthProjection( + workspacePath: string, + options: { staleMinutes?: number } = {}, +): RunHealthProjection { + const runs = dispatch.listRuns(workspacePath); + const staleCutoff = Date.now() - (Math.max(1, options.staleMinutes ?? DEFAULT_STALE_MINUTES) * 60_000); + const activeRuns = runs.filter((run) => run.status === 'running'); + const queuedRuns = runs.filter((run) => run.status === 'queued'); + const staleRuns = runs.filter((run) => + (run.status === 'running' || run.status === 'queued') + && Date.parse(run.updatedAt) <= staleCutoff, + ); + const failedRuns = runs.filter((run) => run.status === 'failed'); + const failedReconciliations = runs.filter((run) => Boolean(run.dispatchTracking?.reconciliationError)); + return { + scope: 'run', + generatedAt: new Date().toISOString(), + healthy: failedReconciliations.length === 0, + summary: { + totalRuns: runs.length, + activeRuns: activeRuns.length, + queuedRuns: queuedRuns.length, + staleRuns: staleRuns.length, + failedRuns: failedRuns.length, + failedReconciliations: failedReconciliations.length, + }, + activeRuns, + staleRuns, + failedRuns, + failedReconciliations, + }; +} diff --git a/packages/kernel/src/projections/transport-health.ts b/packages/kernel/src/projections/transport-health.ts new file mode 100644 index 0000000..2867fb0 --- /dev/null +++ b/packages/kernel/src/projections/transport-health.ts @@ -0,0 +1,40 @@ +import * as transport from '../transport/index.js'; +import type { ProjectionSummary } from './types.js'; + +export interface TransportHealthProjection extends ProjectionSummary { + scope: 'transport'; + summary: { + outboxDepth: number; + inboxDepth: number; + deadLetterCount: number; + deliverySuccessRate: number; + }; + outbox: ReturnType; + inbox: ReturnType; + deadLetters: ReturnType; +} + +export function buildTransportHealthProjection(workspacePath: string): TransportHealthProjection { + const outbox = transport.listTransportOutbox(workspacePath); + const inbox = transport.listTransportInbox(workspacePath); + const deadLetters = transport.listTransportDeadLetters(workspacePath); + const deliveryAttempts = outbox.flatMap((record) => record.attempts); + const deliveredAttempts = deliveryAttempts.filter((entry) => entry.status === 'delivered').length; + const failedAttempts = deliveryAttempts.filter((entry) => entry.status === 'failed').length; + const denominator = deliveredAttempts + failedAttempts; + const deliverySuccessRate = denominator === 0 ? 100 : Math.round((deliveredAttempts / denominator) * 10_000) / 100; + return { + scope: 'transport', + generatedAt: new Date().toISOString(), + healthy: deadLetters.length === 0, + summary: { + outboxDepth: outbox.length, + inboxDepth: inbox.length, + deadLetterCount: deadLetters.length, + deliverySuccessRate, + }, + outbox, + inbox, + deadLetters, + }; +} diff --git a/packages/kernel/src/projections/trigger-health.ts b/packages/kernel/src/projections/trigger-health.ts new file mode 100644 index 0000000..d3ba29f --- /dev/null +++ b/packages/kernel/src/projections/trigger-health.ts @@ -0,0 +1,29 @@ +import * as triggerEngine from '../trigger-engine.js'; +import type { ProjectionSummary } from './types.js'; + +export interface TriggerHealthProjection extends ProjectionSummary { + scope: 'trigger'; + summary: { + totalTriggers: number; + errorTriggers: number; + cooldownTriggers: number; + }; + dashboard: ReturnType; +} + +export function buildTriggerHealthProjection(workspacePath: string): TriggerHealthProjection { + const dashboard = triggerEngine.triggerDashboard(workspacePath); + const errorTriggers = dashboard.triggers.filter((entry) => entry.currentState === 'error').length; + const cooldownTriggers = dashboard.triggers.filter((entry) => entry.currentState === 'cooldown').length; + return { + scope: 'trigger', + generatedAt: new Date().toISOString(), + healthy: errorTriggers === 0, + summary: { + totalTriggers: dashboard.triggers.length, + errorTriggers, + cooldownTriggers, + }, + dashboard, + }; +} diff --git a/packages/kernel/src/projections/types.ts b/packages/kernel/src/projections/types.ts new file mode 100644 index 0000000..239bc84 --- /dev/null +++ b/packages/kernel/src/projections/types.ts @@ -0,0 +1,23 @@ +export interface ProjectionTimeRange { + from?: string; + to?: string; +} + +export interface ProjectionFilters { + status?: string[]; + owner?: string[]; + tags?: string[]; + space?: string; +} + +export interface ProjectionQuery { + scope: 'thread' | 'mission' | 'org' | 'run' | 'transport' | 'federation' | 'trigger' | 'autonomy'; + timeRange?: ProjectionTimeRange; + filters?: ProjectionFilters; +} + +export interface ProjectionSummary { + healthy: boolean; + generatedAt: string; + scope: ProjectionQuery['scope']; +} diff --git a/packages/mcp-server/src/mcp/tools/read-tools.ts b/packages/mcp-server/src/mcp/tools/read-tools.ts index 488eae0..b4a2190 100644 --- a/packages/mcp-server/src/mcp/tools/read-tools.ts +++ b/packages/mcp-server/src/mcp/tools/read-tools.ts @@ -6,6 +6,7 @@ import { ledger as ledgerModule, mission as missionModule, orientation as orientationModule, + projections as projectionsModule, query as queryModule, registry as registryModule, store as storeModule, @@ -22,6 +23,7 @@ const graph = graphModule; const ledger = ledgerModule; const mission = missionModule; const orientation = orientationModule; +const projections = projectionsModule; const query = queryModule; const registry = registryModule; const store = storeModule; @@ -463,6 +465,146 @@ export function registerReadTools(server: McpServer, options: WorkgraphMcpServer }, ); + server.registerTool( + 'wg_run_health', + { + title: 'Run Health Projection', + description: 'Return the run health projection.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const projection = projections.buildRunHealthProjection(options.workspacePath); + return okResult(projection, `Run health: active=${projection.summary.activeRuns}, stale=${projection.summary.staleRuns}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'wg_risk_dashboard', + { + title: 'Risk Dashboard Projection', + description: 'Return the risk dashboard projection.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const projection = projections.buildRiskDashboardProjection(options.workspacePath); + return okResult(projection, `Risk dashboard: blocked=${projection.summary.blockedThreads}, violations=${projection.summary.policyViolations}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'wg_mission_progress_projection', + { + title: 'Mission Progress Projection', + description: 'Return the mission progress projection.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const projection = projections.buildMissionProgressProjection(options.workspacePath); + return okResult(projection, `Mission progress projection covers ${projection.summary.totalMissions} mission(s).`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'wg_transport_health', + { + title: 'Transport Health Projection', + description: 'Return the transport health projection.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const projection = projections.buildTransportHealthProjection(options.workspacePath); + return okResult(projection, `Transport health: outbox=${projection.summary.outboxDepth}, dead-letter=${projection.summary.deadLetterCount}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'wg_federation_status_projection', + { + title: 'Federation Status Projection', + description: 'Return the federation status projection.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const projection = projections.buildFederationStatusProjection(options.workspacePath); + return okResult(projection, `Federation projection covers ${projection.summary.remotes} remote(s).`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'wg_trigger_health', + { + title: 'Trigger Health Projection', + description: 'Return the trigger health projection.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const projection = projections.buildTriggerHealthProjection(options.workspacePath); + return okResult(projection, `Trigger health: total=${projection.summary.totalTriggers}, errors=${projection.summary.errorTriggers}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'wg_autonomy_health', + { + title: 'Autonomy Health Projection', + description: 'Return the autonomy health projection.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const projection = projections.buildAutonomyHealthProjection(options.workspacePath); + return okResult(projection, `Autonomy health: running=${projection.summary.running}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + server.registerTool( 'workgraph_ledger_reconcile', { diff --git a/packages/mcp-server/src/projection-tools.test.ts b/packages/mcp-server/src/projection-tools.test.ts new file mode 100644 index 0000000..312917e --- /dev/null +++ b/packages/mcp-server/src/projection-tools.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { + registry as registryModule, + thread as threadModule, +} from '@versatly/workgraph-kernel'; +import { createWorkgraphMcpServer } from './mcp-server.js'; + +const registry = registryModule; +const thread = threadModule; + +let workspacePath: string; + +describe('projection MCP tools', () => { + beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-mcp-projections-')); + registry.saveRegistry(workspacePath, registry.loadRegistry(workspacePath)); + thread.createThread(workspacePath, 'Projection thread', 'projection thread goal', 'agent-projection'); + }); + + afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); + }); + + it('exposes projection tools over MCP', async () => { + const server = createWorkgraphMcpServer({ + workspacePath, + defaultActor: 'agent-mcp', + }); + const client = new Client({ + name: 'workgraph-mcp-projection-client', + version: '1.0.0', + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + + try { + const tools = await client.listTools(); + const toolNames = tools.tools.map((entry) => entry.name); + expect(toolNames).toEqual(expect.arrayContaining([ + 'wg_run_health', + 'wg_risk_dashboard', + 'wg_mission_progress_projection', + 'wg_transport_health', + 'wg_federation_status_projection', + 'wg_trigger_health', + 'wg_autonomy_health', + ])); + + const runHealth = await client.callTool({ + name: 'wg_run_health', + arguments: {}, + }); + expect(isToolError(runHealth)).toBe(false); + const runHealthPayload = getStructured<{ scope: string }>(runHealth); + expect(runHealthPayload.scope).toBe('run'); + + const triggerHealth = await client.callTool({ + name: 'wg_trigger_health', + arguments: {}, + }); + expect(isToolError(triggerHealth)).toBe(false); + const triggerHealthPayload = getStructured<{ scope: string }>(triggerHealth); + expect(triggerHealthPayload.scope).toBe('trigger'); + } finally { + await client.close(); + await server.close(); + } + }); +}); + +function getStructured(result: unknown): T { + if (!result || typeof result !== 'object' || !('structuredContent' in result)) { + throw new Error('Expected structuredContent in MCP tool response.'); + } + const typed = result as { structuredContent?: unknown }; + if (!typed.structuredContent) { + throw new Error('Expected structuredContent in MCP tool response.'); + } + return typed.structuredContent as T; +} + +function isToolError(result: unknown): boolean { + return Boolean(result && typeof result === 'object' && 'isError' in result && (result as { isError?: boolean }).isError); +} From e14cd87703425a45fdb3389a0c8d54553c151bf2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Mar 2026 08:42:42 +0000 Subject: [PATCH 6/6] Stabilize full CI after architecture rollout Co-authored-by: G9Pedro --- packages/adapter-shell-worker/tsconfig.json | 5 +- .../schema-drift-regression.test.ts.snap | 520 ++++++++++++++++++ packages/kernel/src/autonomy.test.ts | 2 + packages/kernel/tsconfig.json | 4 + tests/stress/trigger-cascade.test.ts | 8 + 5 files changed, 538 insertions(+), 1 deletion(-) diff --git a/packages/adapter-shell-worker/tsconfig.json b/packages/adapter-shell-worker/tsconfig.json index 79e486b..92d3d69 100644 --- a/packages/adapter-shell-worker/tsconfig.json +++ b/packages/adapter-shell-worker/tsconfig.json @@ -4,5 +4,8 @@ "composite": true, "noEmit": true }, - "include": ["src/**/*"] + "include": [ + "src/**/*", + "../adapter-cursor-cloud/src/**/*" + ] } 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 3dcfc8e..cc6ebe3 100644 --- a/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap +++ b/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap @@ -248,6 +248,19 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`] "name": "wg_ask", "title": "WorkGraph Ask", }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Return the autonomy health projection.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "wg_autonomy_health", + "title": "Autonomy Health Projection", + }, { "annotations": { "destructiveHint": true, @@ -314,6 +327,102 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`] "name": "wg_create_thread", "title": "WorkGraph Create Thread", }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Resolve one typed or legacy federated reference with authority and staleness metadata.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string", + }, + { + "additionalProperties": {}, + "properties": {}, + "type": "object", + }, + ], + }, + }, + "required": [ + "ref", + ], + "type": "object", + }, + "name": "wg_federation_resolve_ref", + "title": "Federation Resolve Ref", + }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Search local and remote workspaces through read-only federation capability negotiation.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "includeLocal": { + "type": "boolean", + }, + "limit": { + "maximum": 1000, + "minimum": 0, + "type": "integer", + }, + "query": { + "minLength": 1, + "type": "string", + }, + "remoteIds": { + "items": { + "type": "string", + }, + "type": "array", + }, + "type": { + "type": "string", + }, + }, + "required": [ + "query", + ], + "type": "object", + }, + "name": "wg_federation_search", + "title": "Federation Search", + }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Read workspace federation identity and remote handshake status.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "wg_federation_status", + "title": "Federation Status", + }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Return the federation status projection.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "wg_federation_status_projection", + "title": "Federation Status Projection", + }, { "annotations": { "destructiveHint": true, @@ -363,6 +472,19 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`] "name": "wg_heartbeat", "title": "WorkGraph Heartbeat", }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Return the mission progress projection.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "wg_mission_progress_projection", + "title": "Mission Progress Projection", + }, { "annotations": { "destructiveHint": true, @@ -478,6 +600,32 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`] "name": "wg_post_message", "title": "WorkGraph Post Message", }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Return the risk dashboard projection.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "wg_risk_dashboard", + "title": "Risk Dashboard Projection", + }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Return the run health projection.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "wg_run_health", + "title": "Run Health Projection", + }, { "annotations": { "destructiveHint": true, @@ -716,6 +864,104 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`] "name": "wg_thread_context_search", "title": "WorkGraph Thread Context Search", }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "List failed transport deliveries available for inspection and replay.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "wg_transport_dead_letter_list", + "title": "Transport Dead Letter List", + }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Return the transport health projection.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "wg_transport_health", + "title": "Transport Health Projection", + }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "List persistent inbound transport records.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "wg_transport_inbox_list", + "title": "Transport Inbox List", + }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "List persistent outbound transport records.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "wg_transport_outbox_list", + "title": "Transport Outbox List", + }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + }, + "description": "Replay an outbox or dead-letter transport delivery.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "actor": { + "type": "string", + }, + "id": { + "minLength": 1, + "type": "string", + }, + "recordType": { + "enum": [ + "outbox", + "dead-letter", + ], + "type": "string", + }, + }, + "required": [ + "recordType", + "id", + ], + "type": "object", + }, + "name": "wg_transport_replay", + "title": "Transport Replay", + }, + { + "annotations": { + "idempotentHint": true, + "readOnlyHint": true, + }, + "description": "Return the trigger health projection.", + "inputSchema": { + "properties": {}, + "type": "object", + }, + "name": "wg_trigger_health", + "title": "Trigger Health Projection", + }, { "annotations": { "destructiveHint": true, @@ -1707,6 +1953,110 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`] "name": "workgraph_thread_show", "title": "Thread Show", }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + }, + "description": "Create a trigger primitive with programmable condition/action payloads.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "action": { + "anyOf": [ + { + "type": "string", + }, + { + "additionalProperties": {}, + "properties": {}, + "type": "object", + }, + ], + }, + "actor": { + "type": "string", + }, + "body": { + "type": "string", + }, + "condition": { + "anyOf": [ + { + "type": "string", + }, + { + "additionalProperties": {}, + "properties": {}, + "type": "object", + }, + ], + }, + "cooldown": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "enabled": { + "type": "boolean", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "path": { + "type": "string", + }, + "tags": { + "items": { + "type": "string", + }, + "type": "array", + }, + "type": { + "enum": [ + "cron", + "webhook", + "event", + "manual", + ], + "type": "string", + }, + }, + "required": [ + "name", + "type", + ], + "type": "object", + }, + "name": "workgraph_trigger_create", + "title": "Trigger Create", + }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + }, + "description": "Delete a trigger primitive.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "actor": { + "type": "string", + }, + "triggerRef": { + "minLength": 1, + "type": "string", + }, + }, + "required": [ + "triggerRef", + ], + "type": "object", + }, + "name": "workgraph_trigger_delete", + "title": "Trigger Delete", + }, { "annotations": { "destructiveHint": true, @@ -1725,5 +2075,175 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`] "name": "workgraph_trigger_engine_cycle", "title": "Trigger Engine Cycle", }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + }, + "description": "Manually fire a trigger into a dispatch run, optionally executing it immediately.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "actor": { + "type": "string", + }, + "adapter": { + "type": "string", + }, + "agents": { + "items": { + "type": "string", + }, + "type": "array", + }, + "context": { + "additionalProperties": {}, + "properties": {}, + "type": "object", + }, + "createCheckpoint": { + "type": "boolean", + }, + "eventKey": { + "type": "string", + }, + "execute": { + "type": "boolean", + }, + "maxSteps": { + "maximum": 5000, + "minimum": 1, + "type": "integer", + }, + "objective": { + "type": "string", + }, + "retryFailed": { + "type": "boolean", + }, + "space": { + "type": "string", + }, + "stepDelayMs": { + "maximum": 5000, + "minimum": 0, + "type": "integer", + }, + "timeoutMs": { + "maximum": 3600000, + "minimum": 1, + "type": "integer", + }, + "triggerRef": { + "minLength": 1, + "type": "string", + }, + }, + "required": [ + "triggerRef", + ], + "type": "object", + }, + "name": "workgraph_trigger_fire", + "title": "Trigger Fire", + }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + }, + "description": "Update trigger metadata or programmable condition/action payloads.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "action": { + "anyOf": [ + { + "type": "string", + }, + { + "additionalProperties": {}, + "properties": {}, + "type": "object", + }, + ], + }, + "actor": { + "type": "string", + }, + "body": { + "type": "string", + }, + "condition": { + "anyOf": [ + { + "type": "string", + }, + { + "additionalProperties": {}, + "properties": {}, + "type": "object", + }, + ], + }, + "cooldown": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "enabled": { + "type": "boolean", + }, + "lastFired": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "null", + }, + ], + }, + "name": { + "type": "string", + }, + "nextFireAt": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "null", + }, + ], + }, + "tags": { + "items": { + "type": "string", + }, + "type": "array", + }, + "triggerRef": { + "minLength": 1, + "type": "string", + }, + "type": { + "enum": [ + "cron", + "webhook", + "event", + "manual", + ], + "type": "string", + }, + }, + "required": [ + "triggerRef", + ], + "type": "object", + }, + "name": "workgraph_trigger_update", + "title": "Trigger Update", + }, ] `; diff --git a/packages/kernel/src/autonomy.test.ts b/packages/kernel/src/autonomy.test.ts index 5a228b3..89faffd 100644 --- a/packages/kernel/src/autonomy.test.ts +++ b/packages/kernel/src/autonomy.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { registerDefaultDispatchAdaptersIntoKernelRegistry } from '@versatly/workgraph-runtime-adapter-core'; import { loadRegistry, saveRegistry } from './registry.js'; import * as store from './store.js'; import * as thread from './thread.js'; @@ -13,6 +14,7 @@ beforeEach(() => { workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-autonomy-')); const registry = loadRegistry(workspacePath); saveRegistry(workspacePath, registry); + registerDefaultDispatchAdaptersIntoKernelRegistry(); }); afterEach(() => { diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json index 07eb465..be0cbf8 100644 --- a/packages/kernel/tsconfig.json +++ b/packages/kernel/tsconfig.json @@ -6,6 +6,10 @@ }, "include": [ "src/**/*", + "../adapter-claude-code/src/**/*", + "../adapter-cursor-cloud/src/**/*", + "../adapter-http-webhook/src/**/*", + "../adapter-shell-worker/src/**/*", "../../tests/helpers/cli-build.ts" ] } diff --git a/tests/stress/trigger-cascade.test.ts b/tests/stress/trigger-cascade.test.ts index 5b0f384..914eb84 100644 --- a/tests/stress/trigger-cascade.test.ts +++ b/tests/stress/trigger-cascade.test.ts @@ -152,6 +152,14 @@ describe('stress: trigger cascade, throttling, and breaker behavior', () => { expect(decisions[5]?.allowed).toBe(false); expect(decisions[5]?.reasons.join(' ')).toContain('Rate limit exceeded'); + safety.resetSafetyRails(workspacePath, { actor: 'safety-admin', clearKillSwitch: true }); + safety.updateSafetyConfig(workspacePath, 'safety-admin', { + rateLimit: { + enabled: false, + }, + circuitBreaker: { enabled: false }, + }); + const bulkCount = 120; const bulkTriggerPaths: string[] = []; for (let idx = 0; idx < bulkCount; idx += 1) {