Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { registerDispatchCommands } from './cli/commands/dispatch.js';
import { registerMcpCommands } from './cli/commands/mcp.js';
import { registerSafetyCommands } from './cli/commands/safety.js';
import { registerPortabilityCommands } from './cli/commands/portability.js';
import { registerFederationCommands } from './cli/commands/federation.js';
import { registerTriggerCommands } from './cli/commands/trigger.js';
import {
addWorkspaceOption,
Expand Down Expand Up @@ -2222,6 +2223,7 @@ registerConversationCommands(program, DEFAULT_ACTOR);

registerSafetyCommands(program, DEFAULT_ACTOR);
registerPortabilityCommands(program);
registerFederationCommands(program, threadCmd, DEFAULT_ACTOR);

// ============================================================================
// onboarding
Expand Down
148 changes: 148 additions & 0 deletions packages/cli/src/cli/commands/federation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Command } from 'commander';
import * as workgraph from '@versatly/workgraph-kernel';
import {
addWorkspaceOption,
csv,
resolveWorkspacePath,
runCommand,
} from '../core.js';

export function registerFederationCommands(program: Command, threadCmd: Command, defaultActor: string): void {
const federationCmd = program
.command('federation')
.description('Manage cross-workspace federation remotes and sync state');

addWorkspaceOption(
federationCmd
.command('add <workspaceId> <remoteWorkspacePath>')
.description('Add or update a federated remote workspace')
.option('--name <name>', 'Friendly display name')
.option('--tags <tags>', 'Comma-separated tags')
.option('--disabled', 'Store remote as disabled')
.option('--json', 'Emit structured JSON output'),
).action((workspaceId, remoteWorkspacePath, opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
return workgraph.federation.addRemoteWorkspace(workspacePath, {
id: workspaceId,
path: remoteWorkspacePath,
name: opts.name,
enabled: !opts.disabled,
tags: csv(opts.tags),
});
},
(result) => [
`${result.created ? 'Added' : 'Updated'} federation remote: ${result.remote.id}`,
`Path: ${result.remote.path}`,
`Enabled: ${result.remote.enabled}`,
`Config: ${result.configPath}`,
],
),
);

addWorkspaceOption(
federationCmd
.command('remove <workspaceId>')
.description('Remove a federated remote workspace')
.option('--json', 'Emit structured JSON output'),
).action((workspaceId, opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
return workgraph.federation.removeRemoteWorkspace(workspacePath, workspaceId);
},
(result) => result.changed
? [
`Removed federation remote: ${result.removed?.id ?? 'unknown'}`,
`Config: ${result.configPath}`,
]
: [`No federation remote found for id: ${workspaceId}`],
),
);

addWorkspaceOption(
federationCmd
.command('list')
.description('List configured federation remotes')
.option('--enabled-only', 'Only show enabled remotes')
.option('--json', 'Emit structured JSON output'),
).action((opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
const remotes = workgraph.federation.listRemoteWorkspaces(workspacePath, {
includeDisabled: !opts.enabledOnly,
});
return {
remotes,
count: remotes.length,
};
},
(result) => {
if (result.remotes.length === 0) return ['No federation remotes configured.'];
return [
...result.remotes.map((remote) =>
`${remote.enabled ? '[enabled]' : '[disabled]'} ${remote.id} ${remote.path}`),
`${result.count} remote(s)`,
];
},
),
);

addWorkspaceOption(
federationCmd
.command('sync')
.description('Sync metadata from federated remote workspaces')
.option('-a, --actor <name>', 'Actor', defaultActor)
.option('--remote <ids>', 'Comma-separated remote ids to sync')
.option('--include-disabled', 'Include disabled remotes')
.option('--json', 'Emit structured JSON output'),
).action((opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
return workgraph.federation.syncFederation(workspacePath, opts.actor, {
remoteIds: csv(opts.remote),
includeDisabled: !!opts.includeDisabled,
});
},
(result) => [
`Synced federation at: ${result.syncedAt}`,
`Actor: ${result.actor}`,
...result.remotes.map((remote) =>
`${remote.id} ${remote.status} threads=${remote.threadCount} open=${remote.openThreadCount}${remote.error ? ` error=${remote.error}` : ''}`),
],
),
);

addWorkspaceOption(
threadCmd
.command('link <threadRef> <remoteWorkspaceId> <remoteThreadRef>')
.description('Link a local thread to a remote federated thread')
.option('-a, --actor <name>', 'Actor', defaultActor)
.option('--json', 'Emit structured JSON output'),
).action((threadRef, remoteWorkspaceId, remoteThreadRef, opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
return workgraph.federation.linkThreadToRemoteWorkspace(
workspacePath,
threadRef,
remoteWorkspaceId,
remoteThreadRef,
opts.actor,
);
},
(result) => [
`${result.created ? 'Linked' : 'Already linked'} thread: ${result.thread.path}`,
`Federation link: ${result.link}`,
],
),
);
}
141 changes: 141 additions & 0 deletions packages/kernel/src/federation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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 { createThread } from './thread.js';

let workspacePath: string;
let remoteWorkspacePath: string;

beforeEach(() => {
workspacePath = createWorkspace('wg-federation-');
remoteWorkspacePath = createWorkspace('wg-federation-remote-');
});

afterEach(() => {
fs.rmSync(workspacePath, { recursive: true, force: true });
fs.rmSync(remoteWorkspacePath, { recursive: true, force: true });
});

describe('federation config', () => {
it('adds, lists, and removes remote workspaces', () => {
const added = federation.addRemoteWorkspace(workspacePath, {
id: 'remote-main',
path: remoteWorkspacePath,
name: 'Remote Main',
tags: ['prod', 'shared'],
});
expect(added.created).toBe(true);
expect(fs.existsSync(path.join(workspacePath, '.workgraph/federation.yaml'))).toBe(true);

const remotes = federation.listRemoteWorkspaces(workspacePath);
expect(remotes).toHaveLength(1);
expect(remotes[0].id).toBe('remote-main');
expect(remotes[0].name).toBe('Remote Main');
expect(remotes[0].tags).toEqual(['prod', 'shared']);

const removed = federation.removeRemoteWorkspace(workspacePath, 'remote-main');
expect(removed.changed).toBe(true);
expect(removed.removed?.id).toBe('remote-main');
expect(federation.listRemoteWorkspaces(workspacePath)).toHaveLength(0);
});
});

describe('thread federation links', () => {
it('links a local thread to a remote thread idempotently', () => {
createThread(workspacePath, 'Local Thread', 'Coordinate cross-workspace handoff', 'agent-local');
createThread(remoteWorkspacePath, 'Remote Thread', 'Remote dependency', 'agent-remote');
federation.addRemoteWorkspace(workspacePath, {
id: 'remote-main',
path: remoteWorkspacePath,
name: 'Remote Main',
});

const first = federation.linkThreadToRemoteWorkspace(
workspacePath,
'threads/local-thread.md',
'remote-main',
'threads/remote-thread.md',
'agent-local',
);
expect(first.created).toBe(true);
expect(first.link).toBe('federation://remote-main/threads/remote-thread.md');
expect(readStringList(first.thread.fields.federation_links)).toContain(first.link);
expect(first.thread.body).toContain('## Federated links');

const second = federation.linkThreadToRemoteWorkspace(
workspacePath,
'threads/local-thread.md',
'remote-main',
'threads/remote-thread.md',
'agent-local',
);
expect(second.created).toBe(false);
expect(readStringList(second.thread.fields.federation_links)).toEqual([
'federation://remote-main/threads/remote-thread.md',
]);
});
});

describe('federated search', () => {
it('returns local and remote matches', () => {
createThread(workspacePath, 'Auth rollout', 'Coordinate auth migration', 'agent-local');
createThread(remoteWorkspacePath, 'Auth dashboard', 'Build dashboard for auth metrics', 'agent-remote');
federation.addRemoteWorkspace(workspacePath, {
id: 'remote-main',
path: remoteWorkspacePath,
});

const result = federation.searchFederated(workspacePath, 'auth', {
type: 'thread',
});
expect(result.errors).toEqual([]);
expect(result.results.some((entry) => entry.workspaceId === 'local')).toBe(true);
expect(result.results.some((entry) => entry.workspaceId === 'remote-main')).toBe(true);
});
});

describe('federation sync', () => {
it('captures per-remote sync status and updates sync timestamps', () => {
createThread(remoteWorkspacePath, 'Remote queue item', 'Process the remote queue', 'agent-remote');
federation.addRemoteWorkspace(workspacePath, {
id: 'remote-main',
path: remoteWorkspacePath,
});
federation.addRemoteWorkspace(workspacePath, {
id: 'missing-remote',
path: path.join(remoteWorkspacePath, 'missing'),
});

const syncResult = federation.syncFederation(workspacePath, 'sync-agent');
expect(syncResult.actor).toBe('sync-agent');
expect(syncResult.remotes).toHaveLength(2);

const remoteOk = syncResult.remotes.find((entry) => entry.id === 'remote-main');
expect(remoteOk?.status).toBe('synced');
expect(remoteOk?.threadCount).toBe(1);

const remoteMissing = syncResult.remotes.find((entry) => entry.id === 'missing-remote');
expect(remoteMissing?.status).toBe('error');
expect(remoteMissing?.error).toContain('not found');

const refreshed = federation.listRemoteWorkspaces(workspacePath);
const refreshedRemote = refreshed.find((entry) => entry.id === 'remote-main');
expect(typeof refreshedRemote?.lastSyncedAt).toBe('string');
expect(refreshedRemote?.lastSyncStatus).toBe('synced');
});
});

function createWorkspace(prefix: string): string {
const target = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const registry = loadRegistry(target);
saveRegistry(target, registry);
return target;
}

function readStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((entry) => String(entry));
}
Loading
Loading