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
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
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.
+
+
+
+
Active runs, stale runs, and failed reconciliations.
+
Blocked threads, escalations, and policy violations.
+
Mission completion and milestones.
+
Outbox depth, dead-letter state, and delivery success.
+
Remote workspace compatibility and sync status.
+
Trigger states, cooldowns, and errors.
+
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
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
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..92d3d69
--- /dev/null
+++ b/packages/adapter-shell-worker/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "noEmit": true
+ },
+ "include": [
+ "src/**/*",
+ "../adapter-cursor-cloud/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/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 4ca2bd8..3ee5a0f 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,
@@ -18,6 +19,10 @@ import {
buildSpacesLens,
buildTimelineLens,
} from './server-lenses.js';
+import {
+ buildProjectionByName,
+ listProjectionRouteNames,
+} from './server-projections.js';
import {
createDashboardEventFilter,
type DashboardEvent,
@@ -114,6 +119,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);
@@ -505,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);
@@ -889,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/__snapshots__/schema-drift-regression.test.ts.snap b/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap
index 0539c70..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,
@@ -729,6 +877,19 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`]
"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,
@@ -788,6 +949,19 @@ exports[`schema drift regression > locks MCP tool metadata and input schemas 1`]
"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,
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/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/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/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[] {
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/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/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/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-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/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/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/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-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/mcp-server/src/mcp/tools/read-tools.ts b/packages/mcp-server/src/mcp/tools/read-tools.ts
index e8ffb31..b4a2190 100644
--- a/packages/mcp-server/src/mcp/tools/read-tools.ts
+++ b/packages/mcp-server/src/mcp/tools/read-tools.ts
@@ -1,10 +1,12 @@
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,
orientation as orientationModule,
+ projections as projectionsModule,
query as queryModule,
registry as registryModule,
store as storeModule,
@@ -16,10 +18,12 @@ 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;
const orientation = orientationModule;
+const projections = projectionsModule;
const query = queryModule;
const registry = registryModule;
const store = storeModule;
@@ -380,6 +384,227 @@ 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(
+ '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/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/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);
+}
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();
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/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) {
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',