diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 18702c3..3b871bd 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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, @@ -2222,6 +2223,7 @@ registerConversationCommands(program, DEFAULT_ACTOR); registerSafetyCommands(program, DEFAULT_ACTOR); registerPortabilityCommands(program); +registerFederationCommands(program, threadCmd, DEFAULT_ACTOR); // ============================================================================ // onboarding diff --git a/packages/cli/src/cli/commands/federation.ts b/packages/cli/src/cli/commands/federation.ts new file mode 100644 index 0000000..be966a5 --- /dev/null +++ b/packages/cli/src/cli/commands/federation.ts @@ -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 ') + .description('Add or update a federated remote workspace') + .option('--name ', 'Friendly display name') + .option('--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 ') + .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 ', 'Actor', defaultActor) + .option('--remote ', '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 ') + .description('Link a local thread to a remote federated thread') + .option('-a, --actor ', '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}`, + ], + ), + ); +} diff --git a/packages/kernel/src/federation.test.ts b/packages/kernel/src/federation.test.ts new file mode 100644 index 0000000..a2de0bd --- /dev/null +++ b/packages/kernel/src/federation.test.ts @@ -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)); +} diff --git a/packages/kernel/src/federation.ts b/packages/kernel/src/federation.ts new file mode 100644 index 0000000..2342cef --- /dev/null +++ b/packages/kernel/src/federation.ts @@ -0,0 +1,596 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import YAML from 'yaml'; +import * as query from './query.js'; +import * as store from './store.js'; +import type { PrimitiveInstance } from './types.js'; + +const FEDERATION_CONFIG_FILE = '.workgraph/federation.yaml'; + +export interface RemoteWorkspaceRef { + id: string; + name: string; + path: string; + enabled: boolean; + tags: string[]; + addedAt: string; + lastSyncedAt?: string; + lastSyncStatus?: 'synced' | 'error'; + lastSyncError?: string; +} + +export interface FederationConfig { + version: number; + updatedAt: string; + remotes: RemoteWorkspaceRef[]; +} + +export interface AddRemoteWorkspaceInput { + id: string; + path: string; + name?: string; + enabled?: boolean; + tags?: string[]; +} + +export interface AddRemoteWorkspaceResult { + configPath: string; + created: boolean; + remote: RemoteWorkspaceRef; + config: FederationConfig; +} + +export interface RemoveRemoteWorkspaceResult { + configPath: string; + changed: boolean; + removed?: RemoteWorkspaceRef; + config: FederationConfig; +} + +export interface LinkFederatedThreadResult { + thread: PrimitiveInstance; + created: boolean; + link: string; +} + +export interface FederatedSearchOptions { + type?: string; + limit?: number; + remoteIds?: string[]; + includeLocal?: boolean; +} + +export interface FederatedSearchResultItem { + workspaceId: string; + workspacePath: string; + instance: PrimitiveInstance; +} + +export interface FederatedSearchError { + workspaceId: string; + message: string; +} + +export interface FederatedSearchResult { + query: string; + results: FederatedSearchResultItem[]; + errors: FederatedSearchError[]; +} + +export interface SyncFederationOptions { + remoteIds?: string[]; + includeDisabled?: boolean; +} + +export interface FederationSyncRemoteResult { + id: string; + workspacePath: string; + enabled: boolean; + status: 'synced' | 'skipped' | 'error'; + threadCount: number; + openThreadCount: number; + syncedAt?: string; + error?: string; +} + +export interface FederationSyncResult { + actor: string; + syncedAt: string; + configPath: string; + remotes: FederationSyncRemoteResult[]; +} + +export function federationConfigPath(workspacePath: string): string { + return path.join(workspacePath, FEDERATION_CONFIG_FILE); +} + +export function loadFederationConfig(workspacePath: string): FederationConfig { + const configPath = federationConfigPath(workspacePath); + if (!fs.existsSync(configPath)) { + return defaultFederationConfig(); + } + try { + const raw = fs.readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(raw) as unknown; + return normalizeFederationConfig(parsed); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse federation config at ${configPath}: ${message}`); + } +} + +export function saveFederationConfig(workspacePath: string, config: FederationConfig): FederationConfig { + const normalized = normalizeFederationConfig(config); + const configPath = federationConfigPath(workspacePath); + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(configPath, YAML.stringify(normalized), 'utf-8'); + return normalized; +} + +export function ensureFederationConfig(workspacePath: string): FederationConfig { + const configPath = federationConfigPath(workspacePath); + if (fs.existsSync(configPath)) { + return loadFederationConfig(workspacePath); + } + const created = defaultFederationConfig(); + return saveFederationConfig(workspacePath, created); +} + +export function listRemoteWorkspaces( + workspacePath: string, + options: { includeDisabled?: boolean } = {}, +): RemoteWorkspaceRef[] { + const includeDisabled = options.includeDisabled !== false; + const config = loadFederationConfig(workspacePath); + return includeDisabled + ? config.remotes + : config.remotes.filter((remote) => remote.enabled); +} + +export function addRemoteWorkspace( + workspacePath: string, + input: AddRemoteWorkspaceInput, +): AddRemoteWorkspaceResult { + const workspaceId = normalizeIdentifier(input.id, 'id'); + const remotePath = normalizeRemoteWorkspacePath(input.path); + const workspaceRoot = path.resolve(workspacePath).replace(/\\/g, '/'); + if (remotePath === workspaceRoot) { + throw new Error('Remote workspace path cannot point to the current workspace.'); + } + + const config = ensureFederationConfig(workspacePath); + const now = new Date().toISOString(); + const index = config.remotes.findIndex((remote) => remote.id === workspaceId); + const previous = index >= 0 ? config.remotes[index] : undefined; + const nextTags = input.tags === undefined + ? previous?.tags ?? [] + : normalizeTags(input.tags); + const remote: RemoteWorkspaceRef = { + id: workspaceId, + name: normalizeOptionalString(input.name) ?? previous?.name ?? workspaceId, + path: remotePath, + enabled: input.enabled ?? previous?.enabled ?? true, + tags: nextTags, + addedAt: previous?.addedAt ?? now, + lastSyncedAt: previous?.lastSyncedAt, + lastSyncStatus: previous?.lastSyncStatus, + lastSyncError: previous?.lastSyncError, + }; + + const remotes = [...config.remotes]; + if (index >= 0) { + remotes[index] = remote; + } else { + remotes.push(remote); + } + + const updated: FederationConfig = { + ...config, + updatedAt: now, + remotes: remotes.sort((a, b) => a.id.localeCompare(b.id)), + }; + const saved = saveFederationConfig(workspacePath, updated); + return { + configPath: federationConfigPath(workspacePath), + created: index === -1, + remote, + config: saved, + }; +} + +export function removeRemoteWorkspace( + workspacePath: string, + workspaceId: string, +): RemoveRemoteWorkspaceResult { + const remoteId = normalizeIdentifier(workspaceId, 'id'); + const config = ensureFederationConfig(workspacePath); + const removed = config.remotes.find((remote) => remote.id === remoteId); + if (!removed) { + return { + configPath: federationConfigPath(workspacePath), + changed: false, + config, + }; + } + + const updated: FederationConfig = { + ...config, + updatedAt: new Date().toISOString(), + remotes: config.remotes.filter((remote) => remote.id !== remoteId), + }; + const saved = saveFederationConfig(workspacePath, updated); + return { + configPath: federationConfigPath(workspacePath), + changed: true, + removed, + config: saved, + }; +} + +export function linkThreadToRemoteWorkspace( + workspacePath: string, + threadRef: string, + remoteWorkspaceId: string, + remoteThreadRef: string, + actor: string, +): LinkFederatedThreadResult { + const remoteId = normalizeIdentifier(remoteWorkspaceId, 'remoteWorkspaceId'); + const localThreadPath = normalizeThreadPathRef(threadRef, 'threadRef'); + const targetThreadPath = normalizeThreadPathRef(remoteThreadRef, 'remoteThreadRef'); + const config = ensureFederationConfig(workspacePath); + const remote = config.remotes.find((entry) => entry.id === remoteId); + if (!remote) { + throw new Error(`Unknown federated workspace "${remoteId}". Add it first with \`workgraph federation add\`.`); + } + if (!remote.enabled) { + throw new Error(`Federated workspace "${remoteId}" is disabled. Re-enable it before linking.`); + } + + const localThread = store.read(workspacePath, localThreadPath); + if (!localThread || localThread.type !== 'thread') { + throw new Error(`Thread not found: ${localThreadPath}`); + } + + if (!fs.existsSync(remote.path)) { + throw new Error(`Federated workspace path not found for "${remoteId}": ${remote.path}`); + } + 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 existingLinks = readStringArray(localThread.fields.federation_links); + const created = !existingLinks.includes(link); + const links = created ? [...existingLinks, link] : existingLinks; + const body = created + ? appendThreadFederationLink(localThread.body, link, remote.name) + : undefined; + + const updated = store.update( + workspacePath, + localThread.path, + { federation_links: links }, + body, + actor, + { + skipAuthorization: true, + action: 'federation.thread-link', + requiredCapabilities: ['thread:update', 'thread:manage'], + }, + ); + return { + thread: updated, + created, + link, + }; +} + +export function searchFederated( + workspacePath: string, + text: string, + options: FederatedSearchOptions = {}, +): FederatedSearchResult { + const queryText = String(text ?? '').trim(); + if (!queryText) { + throw new Error('Federated search query cannot be empty.'); + } + const selectedRemoteIds = new Set((options.remoteIds ?? []).map((value) => normalizeIdentifier(value, 'remoteId'))); + const includeAllRemotes = selectedRemoteIds.size === 0; + const includeLocal = options.includeLocal !== false; + const remotes = listRemoteWorkspaces(workspacePath, { includeDisabled: false }) + .filter((remote) => includeAllRemotes || selectedRemoteIds.has(remote.id)); + + const results: FederatedSearchResultItem[] = []; + const errors: FederatedSearchError[] = []; + + if (includeLocal) { + const localResults = query.keywordSearch(workspacePath, queryText, { + type: options.type, + }); + for (const instance of localResults) { + results.push({ + workspaceId: 'local', + workspacePath: path.resolve(workspacePath).replace(/\\/g, '/'), + instance, + }); + } + } + + for (const remote of remotes) { + try { + if (!fs.existsSync(remote.path)) { + throw new Error(`Remote workspace path not found: ${remote.path}`); + } + const remoteResults = query.keywordSearch(remote.path, queryText, { + type: options.type, + }); + for (const instance of remoteResults) { + results.push({ + workspaceId: remote.id, + workspacePath: remote.path, + instance, + }); + } + } catch (error) { + errors.push({ + workspaceId: remote.id, + message: error instanceof Error ? error.message : String(error), + }); + } + } + + const limitedResults = typeof options.limit === 'number' && options.limit >= 0 + ? results.slice(0, options.limit) + : results; + return { + query: queryText, + results: limitedResults, + errors, + }; +} + +export function syncFederation( + workspacePath: string, + actor: string, + options: SyncFederationOptions = {}, +): FederationSyncResult { + const config = ensureFederationConfig(workspacePath); + const now = new Date().toISOString(); + const selectedRemoteIds = new Set((options.remoteIds ?? []).map((value) => normalizeIdentifier(value, 'remoteId'))); + const syncAll = selectedRemoteIds.size === 0; + const remotesResult: FederationSyncRemoteResult[] = []; + const remotes = config.remotes.map((remote) => { + const selected = syncAll || selectedRemoteIds.has(remote.id); + if (!selected) { + remotesResult.push({ + id: remote.id, + workspacePath: remote.path, + enabled: remote.enabled, + status: 'skipped', + threadCount: 0, + openThreadCount: 0, + }); + return remote; + } + if (!remote.enabled && options.includeDisabled !== true) { + remotesResult.push({ + id: remote.id, + workspacePath: remote.path, + enabled: remote.enabled, + status: 'skipped', + threadCount: 0, + openThreadCount: 0, + }); + return remote; + } + + try { + if (!fs.existsSync(remote.path)) { + throw new Error(`Remote workspace path not found: ${remote.path}`); + } + const threads = store.list(remote.path, 'thread'); + const openThreadCount = threads.filter((thread) => String(thread.fields.status ?? '') === 'open').length; + remotesResult.push({ + id: remote.id, + workspacePath: remote.path, + enabled: remote.enabled, + status: 'synced', + threadCount: threads.length, + openThreadCount, + syncedAt: now, + }); + return { + ...remote, + lastSyncedAt: now, + lastSyncStatus: 'synced' as const, + lastSyncError: undefined, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + remotesResult.push({ + id: remote.id, + workspacePath: remote.path, + enabled: remote.enabled, + status: 'error', + threadCount: 0, + openThreadCount: 0, + syncedAt: now, + error: message, + }); + return { + ...remote, + lastSyncedAt: now, + lastSyncStatus: 'error' as const, + lastSyncError: message, + }; + } + }); + + saveFederationConfig(workspacePath, { + ...config, + updatedAt: now, + remotes, + }); + return { + actor: String(actor || 'system'), + syncedAt: now, + configPath: federationConfigPath(workspacePath), + remotes: remotesResult, + }; +} + +function defaultFederationConfig(now: string = new Date().toISOString()): FederationConfig { + return { + version: 1, + updatedAt: now, + remotes: [], + }; +} + +function normalizeFederationConfig(value: unknown): FederationConfig { + const root = asRecord(value); + const now = new Date().toISOString(); + const remotes = asArray(root.remotes) + .map((entry) => normalizeRemoteWorkspaceRef(entry)) + .filter((entry): entry is RemoteWorkspaceRef => entry !== null) + .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; + return { + version, + updatedAt: normalizeOptionalString(root.updatedAt) ?? now, + remotes, + }; +} + +function normalizeRemoteWorkspaceRef(value: unknown): RemoteWorkspaceRef | null { + const raw = asRecord(value); + const id = normalizeOptionalString(raw.id); + const workspacePath = normalizeOptionalString(raw.path); + if (!id || !workspacePath) return null; + const now = new Date().toISOString(); + return { + 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))), + addedAt: normalizeOptionalString(raw.addedAt) ?? now, + lastSyncedAt: normalizeOptionalString(raw.lastSyncedAt), + lastSyncStatus: normalizeSyncStatus(raw.lastSyncStatus), + lastSyncError: normalizeOptionalString(raw.lastSyncError), + }; +} + +function normalizeSyncStatus(value: unknown): 'synced' | 'error' | undefined { + const normalized = normalizeOptionalString(value)?.toLowerCase(); + if (normalized === 'synced' || normalized === 'error') { + return normalized; + } + return undefined; +} + +function normalizeIdentifier(value: unknown, label: string): string { + const normalized = String(value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!normalized) { + throw new Error(`Invalid ${label}. Expected a non-empty identifier.`); + } + return normalized; +} + +function normalizeRemoteWorkspacePath(value: unknown): string { + const normalized = normalizeOptionalString(value); + if (!normalized) { + throw new Error('Invalid remote workspace path. Expected a non-empty path.'); + } + return path.resolve(normalized).replace(/\\/g, '/'); +} + +function normalizeThreadPathRef(value: unknown, label: string): string { + const normalized = normalizeMarkdownRef(value); + if (!normalized) { + throw new Error(`Invalid ${label}. Expected a markdown thread reference.`); + } + if (!normalized.startsWith('threads/')) { + throw new Error(`Invalid ${label}. Expected a thread ref under "threads/". Received "${normalized}".`); + } + return normalized; +} + +function normalizeMarkdownRef(value: unknown): string { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + const unwrapped = raw.startsWith('[[') && raw.endsWith(']]') + ? raw.slice(2, -2) + : raw; + const primary = unwrapped.split('|')[0].trim().split('#')[0].trim(); + if (!primary) return ''; + return primary.endsWith('.md') ? primary : `${primary}.md`; +} + +function normalizeTags(values: unknown): string[] { + if (!Array.isArray(values)) return []; + const seen = new Set(); + for (const value of values) { + const tag = String(value ?? '').trim(); + if (!tag) continue; + seen.add(tag); + } + 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; + const sectionTitle = '## Federated links'; + const line = `- ${remoteName}: ${link}`; + if (currentBody.includes(sectionTitle)) { + return `${currentBody.trimEnd()}\n${line}\n`; + } + const trimmed = currentBody.trimEnd(); + return trimmed + ? `${trimmed}\n\n${sectionTitle}\n\n${line}\n` + : `${sectionTitle}\n\n${line}\n`; +} + +function readStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => String(entry ?? '').trim()) + .filter((entry) => entry.length > 0); +} + +function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asBoolean(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true; + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; + } + return fallback; +} diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index b7e8fdb..286c9c1 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -69,3 +69,4 @@ export * from './errors.js'; export * as storageAdapter from './storage-adapter.js'; export * as environment from './environment.js'; export * as exportImport from './export-import.js'; +export * as federation from './federation.js';