diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 40dfa12..1fc92a1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -51,11 +51,11 @@ program.showHelpAfterError(); addWorkspaceOption( program .command('init [path]') - .description('Initialize a pure workgraph workspace (no memory category scaffolding)') + .description('Initialize or repair a workgraph workspace starter kit') .option('-n, --name ', 'Workspace name') .option('--no-type-dirs', 'Do not pre-create built-in type directories') .option('--no-bases', 'Do not generate .base files from primitive registry') - .option('--no-readme', 'Do not create README.md') + .option('--no-readme', 'Do not create README.md/QUICKSTART.md') .option('--json', 'Emit structured JSON output') ).action((targetPath, opts) => runCommand( @@ -70,12 +70,27 @@ addWorkspaceOption( }); return result; }, - (result) => [ - `Initialized workgraph workspace: ${result.workspacePath}`, - `Seeded types: ${result.seededTypes.join(', ')}`, - `Generated .base files: ${result.generatedBases.length}`, - `Config: ${result.configPath}`, - ] + (result) => { + const roleSeeded = result.starterKit.roles.created.length + result.starterKit.roles.existing.length; + const policySeeded = result.starterKit.policies.created.length + result.starterKit.policies.existing.length; + const gateSeeded = result.starterKit.gates.created.length + result.starterKit.gates.existing.length; + const spaceSeeded = result.starterKit.spaces.created.length + result.starterKit.spaces.existing.length; + return [ + `${result.alreadyInitialized ? 'Updated' : 'Initialized'} workgraph workspace: ${result.workspacePath}`, + `Seeded types: ${result.seededTypes.join(', ')}`, + `Generated .base files: ${result.generatedBases.length}`, + `Config: ${result.configPath}`, + `Server config: ${result.serverConfigPath}`, + `Starter kit primitives: roles=${roleSeeded} policies=${policySeeded} gates=${gateSeeded} spaces=${spaceSeeded}`, + `Bootstrap trust token (${result.bootstrapTrustTokenPath}): ${result.bootstrapTrustToken}`, + ...(result.quickstartPath ? [`Quickstart: ${result.quickstartPath}`] : []), + '', + 'Next steps:', + `1) Start server: workgraph serve -w "${result.workspacePath}"`, + `2) Register first agent: workgraph agent register agent-1 -w "${result.workspacePath}" --token ${result.bootstrapTrustToken}`, + `3) Create first thread: workgraph thread create "First coordinated task" -w "${result.workspacePath}" --goal "Validate onboarding flow" --actor agent-1`, + ]; + } ) ); @@ -495,6 +510,46 @@ addWorkspaceOption( ) ); +addWorkspaceOption( + agentCmd + .command('register ') + .description('Register an agent using the bootstrap trust token') + .option('--token ', 'Bootstrap trust token (or WORKGRAPH_TRUST_TOKEN env)') + .option('--role ', 'Role slug/path override (default from trust token)') + .option('--capabilities ', 'Comma-separated extra capabilities') + .option('--status ', 'online | busy | offline', 'online') + .option('--current-task ', 'Optional current task/thread ref') + .option('-a, --actor ', 'Actor writing registration artifacts') + .option('--json', 'Emit structured JSON output') +).action((name, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const token = String(opts.token ?? process.env.WORKGRAPH_TRUST_TOKEN ?? '').trim(); + if (!token) { + throw new Error('Missing trust token. Provide --token or set WORKGRAPH_TRUST_TOKEN.'); + } + return workgraph.agent.registerAgent(workspacePath, name, { + token, + role: opts.role, + capabilities: csv(opts.capabilities), + status: normalizeAgentPresenceStatus(opts.status), + currentTask: opts.currentTask, + actor: opts.actor, + }); + }, + (result) => [ + `Registered agent: ${result.agentName}`, + `Role: ${result.role} (${result.rolePath})`, + `Capabilities: ${result.capabilities.join(', ') || 'none'}`, + `Presence: ${result.presence.path}`, + `Policy party: ${result.policyParty.id}`, + `Bootstrap token: ${result.trustTokenPath} [${result.trustTokenStatus}]`, + ], + ) +); + addWorkspaceOption( agentCmd .command('list') @@ -2112,20 +2167,33 @@ addWorkspaceOption( program .command('serve') .description('Serve Workgraph HTTP MCP server + REST API') - .option('--port ', 'HTTP port (default: 8787)') - .option('--host ', 'Bind host (default: 0.0.0.0)') + .option('--port ', 'HTTP port (defaults to server config or 8787)') + .option('--host ', 'Bind host (defaults to server config or 0.0.0.0)') .option('--token ', 'Optional bearer token for MCP + REST auth') - .option('-a, --actor ', 'Default actor for thread mutations', DEFAULT_ACTOR), + .option('-a, --actor ', 'Default actor for thread mutations'), ).action(async (opts) => { const workspacePath = resolveWorkspacePath(opts); - const port = opts.port !== undefined ? parsePortOption(opts.port) : 8787; - const host = opts.host ? String(opts.host) : '0.0.0.0'; + const serverConfig = workgraph.serverConfig.loadServerConfig(workspacePath); + const port = opts.port !== undefined + ? parsePortOption(opts.port) + : (serverConfig?.port ?? 8787); + const host = opts.host + ? String(opts.host) + : (serverConfig?.host ?? '0.0.0.0'); + const defaultActor = opts.actor + ? String(opts.actor) + : (serverConfig?.defaultActor ?? DEFAULT_ACTOR); + const endpointPath = serverConfig?.endpointPath; + const bearerToken = opts.token + ? String(opts.token) + : serverConfig?.bearerToken; const handle = await startWorkgraphServer({ workspacePath, host, port, - bearerToken: opts.token ? String(opts.token) : undefined, - defaultActor: opts.actor, + endpointPath, + bearerToken, + defaultActor, }); console.log(`Server URL: ${handle.baseUrl}`); console.log(`MCP endpoint: ${handle.url}`); diff --git a/packages/kernel/src/agent.test.ts b/packages/kernel/src/agent.test.ts index 09a80cd..f129dfb 100644 --- a/packages/kernel/src/agent.test.ts +++ b/packages/kernel/src/agent.test.ts @@ -4,7 +4,9 @@ import path from 'node:path'; import os from 'node:os'; import { loadRegistry, saveRegistry } from './registry.js'; import * as agent from './agent.js'; +import { getParty } from './policy.js'; import * as store from './store.js'; +import { initWorkspace } from './workspace.js'; let workspacePath: string; @@ -74,4 +76,61 @@ describe('agent presence', () => { expect(listed[0].path).toBe(beta.path); expect(listed[1].path).toBe(alpha.path); }); + + it('registers first agent from bootstrap trust token and creates policy party', () => { + const initResult = initWorkspace(workspacePath, { createReadme: false }); + + const registration = agent.registerAgent(workspacePath, 'agent-alpha', { + token: initResult.bootstrapTrustToken, + }); + + expect(registration.agentName).toBe('agent-alpha'); + expect(registration.role).toBe('admin'); + expect(registration.rolePath).toBe('roles/admin.md'); + expect(registration.trustTokenPath).toBe(initResult.bootstrapTrustTokenPath); + expect(registration.trustTokenStatus).toBe('used'); + expect(registration.policyParty.id).toBe('agent-alpha'); + expect(registration.policyParty.roles).toEqual(['admin']); + expect(registration.policyParty.capabilities).toContain('agent:register'); + expect(registration.presence.path).toBe('agents/agent-alpha.md'); + + const persistedParty = getParty(workspacePath, 'agent-alpha'); + expect(persistedParty?.roles).toEqual(['admin']); + expect(persistedParty?.capabilities).toContain('promote:sensitive'); + + const trustToken = store.read(workspacePath, initResult.bootstrapTrustTokenPath); + expect(trustToken).not.toBeNull(); + expect(trustToken?.fields.status).toBe('used'); + expect(trustToken?.fields.used_by).toEqual(['agent-alpha']); + expect(trustToken?.fields.used_count).toBe(1); + }); + + it('allows idempotent re-registration for the same agent token holder', () => { + const initResult = initWorkspace(workspacePath, { createReadme: false }); + + const first = agent.registerAgent(workspacePath, 'agent-alpha', { + token: initResult.bootstrapTrustToken, + }); + const second = agent.registerAgent(workspacePath, 'agent-alpha', { + token: initResult.bootstrapTrustToken, + }); + + expect(first.agentName).toBe('agent-alpha'); + expect(second.agentName).toBe('agent-alpha'); + const trustToken = store.read(workspacePath, initResult.bootstrapTrustTokenPath); + expect(trustToken?.fields.used_by).toEqual(['agent-alpha']); + expect(trustToken?.fields.used_count).toBe(1); + }); + + it('rejects registering a second distinct agent with single-use bootstrap token', () => { + const initResult = initWorkspace(workspacePath, { createReadme: false }); + + agent.registerAgent(workspacePath, 'agent-alpha', { + token: initResult.bootstrapTrustToken, + }); + + expect(() => agent.registerAgent(workspacePath, 'agent-beta', { + token: initResult.bootstrapTrustToken, + })).toThrow('already been used'); + }); }); diff --git a/packages/kernel/src/agent.ts b/packages/kernel/src/agent.ts index 8372ec6..1b89d2b 100644 --- a/packages/kernel/src/agent.ts +++ b/packages/kernel/src/agent.ts @@ -2,8 +2,11 @@ * Agent presence primitives. */ +import path from 'node:path'; +import * as policy from './policy.js'; import * as store from './store.js'; -import type { PrimitiveInstance } from './types.js'; +import { loadServerConfig } from './server-config.js'; +import type { PolicyParty, PrimitiveInstance } from './types.js'; export type AgentPresenceStatus = 'online' | 'busy' | 'offline'; @@ -14,7 +17,29 @@ export interface AgentHeartbeatOptions { actor?: string; } +export interface AgentRegistrationOptions { + token: string; + role?: string; + capabilities?: string[]; + status?: AgentPresenceStatus; + currentTask?: string; + actor?: string; +} + +export interface AgentRegistrationResult { + agentName: string; + rolePath: string; + role: string; + capabilities: string[]; + trustTokenPath: string; + trustTokenStatus: string; + policyParty: PolicyParty; + presence: PrimitiveInstance; +} + const PRESENCE_TYPE = 'presence'; +const ROLE_TYPE = 'role'; +const TRUST_TOKEN_TYPE = 'trust-token'; const PRESENCE_STATUS_VALUES = new Set(['online', 'busy', 'offline']); export function heartbeat( @@ -80,6 +105,104 @@ export function getPresence(workspacePath: string, name: string): PrimitiveInsta .find((entry) => normalizeName(entry.fields.name) === target) ?? null; } +export function registerAgent( + workspacePath: string, + name: string, + options: AgentRegistrationOptions, +): AgentRegistrationResult { + const registrationToken = String(options.token ?? '').trim(); + if (!registrationToken) { + throw new Error('Trust token is required for agent registration.'); + } + + const serverConfig = loadServerConfig(workspacePath); + if (!serverConfig) { + throw new Error('Workspace server config not found. Run `workgraph init` to seed onboarding defaults.'); + } + if (!serverConfig.registration.enabled) { + throw new Error('Agent registration is disabled by workspace server config.'); + } + + const trustTokenPath = normalizePathLike(serverConfig.registration.bootstrapTokenPath); + const trustToken = store.read(workspacePath, trustTokenPath); + if (!trustToken) { + throw new Error(`Bootstrap trust token primitive not found: ${trustTokenPath}`); + } + if (trustToken.type !== TRUST_TOKEN_TYPE) { + throw new Error(`Invalid bootstrap token primitive type at ${trustTokenPath}: ${trustToken.type}`); + } + + const storedToken = String(trustToken.fields.token ?? '').trim(); + if (!storedToken) { + throw new Error(`Bootstrap trust token primitive ${trustTokenPath} has no token field.`); + } + if (storedToken !== registrationToken) { + throw new Error('Invalid trust token.'); + } + + const tokenStatus = String(trustToken.fields.status ?? 'active').trim().toLowerCase(); + const normalizedAgentName = normalizeAgentId(name); + if (!normalizedAgentName) { + throw new Error(`Invalid agent name "${name}".`); + } + const usedBy = asStringList(trustToken.fields.used_by).map(normalizeAgentId); + if (tokenStatus === 'revoked') { + throw new Error(`Trust token at ${trustTokenPath} has been revoked.`); + } + if (tokenStatus === 'used' && !usedBy.includes(normalizedAgentName)) { + throw new Error(`Trust token at ${trustTokenPath} has already been used.`); + } + + const roleRef = options.role + ?? readNonEmptyString(trustToken.fields.default_role) + ?? 'admin'; + const rolePath = resolveRolePath(roleRef); + const role = store.read(workspacePath, rolePath); + if (!role) { + throw new Error(`Role primitive not found: ${rolePath}`); + } + if (role.type !== ROLE_TYPE) { + throw new Error(`Expected role primitive at ${rolePath}, found ${role.type}.`); + } + + const roleCapabilities = normalizeCapabilities(role.fields.capabilities); + const mergedCapabilities = dedupeStrings([ + ...roleCapabilities, + ...normalizeCapabilities(options.capabilities), + ]); + const roleName = inferRoleName(role.path); + + const policyParty = policy.upsertParty(workspacePath, normalizedAgentName, { + roles: [roleName], + capabilities: mergedCapabilities, + }); + + const presence = heartbeat(workspacePath, normalizedAgentName, { + actor: options.actor ?? normalizedAgentName, + status: options.status ?? 'online', + currentTask: options.currentTask, + capabilities: mergedCapabilities, + }); + + const updatedTrustToken = consumeBootstrapTrustToken( + workspacePath, + trustToken, + normalizedAgentName, + options.actor ?? normalizedAgentName, + ); + + return { + agentName: normalizedAgentName, + rolePath: role.path, + role: roleName, + capabilities: mergedCapabilities, + trustTokenPath: updatedTrustToken.path, + trustTokenStatus: String(updatedTrustToken.fields.status ?? 'active'), + policyParty, + presence, + }; +} + function normalizeStatus(value: unknown): AgentPresenceStatus | null { const normalized = String(value ?? '').trim().toLowerCase() as AgentPresenceStatus; if (!PRESENCE_STATUS_VALUES.has(normalized)) return null; @@ -93,6 +216,45 @@ function normalizeCapabilities(value: unknown): string[] { .filter(Boolean); } +function consumeBootstrapTrustToken( + workspacePath: string, + trustToken: PrimitiveInstance, + agentName: string, + actor: string, +): PrimitiveInstance { + const status = String(trustToken.fields.status ?? 'active').trim().toLowerCase(); + const usedBy = dedupeStrings(asStringList(trustToken.fields.used_by).map(normalizeAgentId)); + const alreadyUsedByAgent = usedBy.includes(agentName); + if (status === 'used' && alreadyUsedByAgent) { + return trustToken; + } + if (status === 'revoked') { + return trustToken; + } + + const maxUses = asPositiveNumber(trustToken.fields.max_uses) ?? 1; + const usedCount = asNonNegativeNumber(trustToken.fields.used_count) ?? usedBy.length; + const nextUsedBy = alreadyUsedByAgent + ? usedBy + : dedupeStrings([...usedBy, agentName]); + const nextUsedCount = alreadyUsedByAgent + ? usedCount + : usedCount + 1; + const nextStatus = nextUsedCount >= maxUses ? 'used' : 'active'; + + return store.update( + workspacePath, + trustToken.path, + { + used_by: nextUsedBy, + used_count: nextUsedCount, + status: nextStatus, + }, + undefined, + actor, + ); +} + function normalizeTask(value: unknown): string | null { const normalized = String(value ?? '').trim(); return normalized ? normalized : null; @@ -102,6 +264,100 @@ function normalizeName(value: unknown): string { return String(value ?? '').trim().toLowerCase(); } +function normalizeAgentId(value: unknown): string { + return String(value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-|-$/g, ''); +} + +function resolveRolePath(roleRef: string): string { + const normalizedRef = normalizePathLike(roleRef); + if (normalizedRef.includes('/')) return normalizedRef; + const roleSlugSource = normalizedRef.endsWith('.md') + ? normalizedRef.slice(0, -3) + : normalizedRef; + return `roles/${slugify(roleSlugSource)}.md`; +} + +function normalizePathLike(value: unknown): string { + const trimmed = String(value ?? '') + .trim() + .replace(/\\/g, '/') + .replace(/^\.\//, ''); + if (!trimmed) return ''; + const unwrapped = trimmed.startsWith('[[') && trimmed.endsWith(']]') + ? trimmed.slice(2, -2) + : trimmed; + return unwrapped.endsWith('.md') ? unwrapped : `${unwrapped}.md`; +} + +function slugify(value: string): string { + const normalized = String(value) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + return normalized || 'role'; +} + +function inferRoleName(rolePath: string): string { + const basename = path.basename(rolePath, '.md').trim().toLowerCase(); + return basename || 'role'; +} + +function dedupeStrings(values: string[]): string[] { + const seen = new Set(); + const output: string[] = []; + for (const value of values) { + const normalized = String(value).trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + output.push(normalized); + } + return output; +} + +function asStringList(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((entry) => String(entry).trim()) + .filter(Boolean); + } + if (typeof value === 'string') { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function asPositiveNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return null; +} + +function asNonNegativeNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed >= 0) return parsed; + } + return null; +} + +function readNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function renderPresenceBody( name: string, status: AgentPresenceStatus, diff --git a/packages/kernel/src/diagnostics.test.ts b/packages/kernel/src/diagnostics.test.ts index 841e392..92e1f8b 100644 --- a/packages/kernel/src/diagnostics.test.ts +++ b/packages/kernel/src/diagnostics.test.ts @@ -91,6 +91,12 @@ describe('diagnostics tooling', () => { }); it('stats reports deterministic primitive, link, and velocity metrics', () => { + const baseline = computeVaultStats(workspacePath); + const baselineThreadCount = baseline.primitives.byType.thread ?? 0; + const baselineDecisionCount = baseline.primitives.byType.decision ?? 0; + const baselineLinkTotal = baseline.links.total; + const baselineOrphanCount = baseline.links.orphanCount; + const alpha = thread.createThread(workspacePath, 'Alpha Thread', 'goal alpha', 'agent-a'); const beta = thread.createThread(workspacePath, 'Beta Thread', 'goal beta', 'agent-a'); thread.claim(workspacePath, alpha.path, 'agent-a'); @@ -108,11 +114,11 @@ describe('diagnostics tooling', () => { }, '', 'agent-a'); const stats = computeVaultStats(workspacePath); - expect(stats.primitives.total).toBe(3); - expect(stats.primitives.byType.thread).toBe(2); - expect(stats.primitives.byType.decision).toBe(1); - expect(stats.links.total).toBe(1); - expect(stats.links.orphanCount).toBe(1); + expect(stats.primitives.total).toBe(baseline.primitives.total + 3); + expect(stats.primitives.byType.thread).toBe(baselineThreadCount + 2); + expect(stats.primitives.byType.decision).toBe(baselineDecisionCount + 1); + expect(stats.links.total).toBe(baselineLinkTotal + 1); + expect(stats.links.orphanCount).toBe(baselineOrphanCount + 1); expect(stats.links.mostConnectedNodes.length).toBeGreaterThan(0); expect(stats.frontmatter.averageCompleteness).toBeCloseTo(1, 5); expect(stats.ledger.totalEvents).toBeGreaterThan(0); diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index a39c9ee..cfa90eb 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -4,6 +4,8 @@ export * as ledger from './ledger.js'; export * as store from './store.js'; export * as thread from './thread.js'; export * as workspace from './workspace.js'; +export * as serverConfig from './server-config.js'; +export * as starterKit from './starter-kit.js'; export * as query from './query.js'; export * as orientation from './orientation.js'; export * as lens from './lens.js'; diff --git a/packages/kernel/src/schema-drift-regression.test.ts b/packages/kernel/src/schema-drift-regression.test.ts index 5143d49..41bc121 100644 --- a/packages/kernel/src/schema-drift-regression.test.ts +++ b/packages/kernel/src/schema-drift-regression.test.ts @@ -7,9 +7,9 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { createWorkgraphMcpServer } from '@versatly/workgraph-mcp-server'; import { loadRegistry, saveRegistry } from './registry.js'; +import { ensureCliBuiltForTests } from '../../../tests/helpers/cli-build.js'; let workspacePath: string; -let cliBuilt = false; beforeEach(() => { workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-schema-drift-')); @@ -22,7 +22,7 @@ afterEach(() => { }); beforeAll(() => { - ensureBuiltCli(); + ensureCliBuiltForTests(); }); describe('schema drift regression', () => { @@ -78,28 +78,6 @@ describe('schema drift regression', () => { }); }); -function ensureBuiltCli(): void { - if (cliBuilt) return; - const npmExecPath = process.env.npm_execpath; - const result = npmExecPath - ? spawnSync(process.execPath, [npmExecPath, 'run', 'build', '--silent'], { - encoding: 'utf-8', - }) - : process.platform === 'win32' - ? spawnSync('cmd.exe', ['/d', '/s', '/c', 'pnpm run build --silent'], { - encoding: 'utf-8', - }) - : spawnSync('pnpm', ['run', 'build', '--silent'], { - encoding: 'utf-8', - }); - if (result.status !== 0) { - throw new Error( - `Failed to build CLI for schema drift tests.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\nspawnError:\n${result.error?.message ?? 'none'}`, - ); - } - cliBuilt = true; -} - function runCliHelp(args: string[]): string { const result = spawnSync('node', [path.resolve('bin/workgraph.js'), ...args, '--help'], { encoding: 'utf-8', diff --git a/packages/kernel/src/server-config.ts b/packages/kernel/src/server-config.ts new file mode 100644 index 0000000..6ca2756 --- /dev/null +++ b/packages/kernel/src/server-config.ts @@ -0,0 +1,176 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const WORKGRAPH_SERVER_CONFIG_FILE = '.workgraph/server.json'; +const DEFAULT_SERVER_HOST = '127.0.0.1'; +const DEFAULT_SERVER_PORT = 8787; +const DEFAULT_SERVER_ENDPOINT_PATH = '/mcp'; +const DEFAULT_SERVER_ACTOR = 'system'; +const DEFAULT_BOOTSTRAP_TOKEN_PATH = 'trust-tokens/bootstrap-first-agent.md'; + +export interface WorkgraphServerRegistrationConfig { + enabled: boolean; + bootstrapTokenPath: string; +} + +export interface WorkgraphServerConfig { + host: string; + port: number; + endpointPath: string; + defaultActor: string; + bearerToken?: string; + registration: WorkgraphServerRegistrationConfig; +} + +export interface EnsureServerConfigOptions { + bootstrapTokenPath?: string; +} + +export interface EnsureServerConfigResult { + config: WorkgraphServerConfig; + path: string; + created: boolean; + updated: boolean; +} + +export function serverConfigPath(workspacePath: string): string { + return path.join(workspacePath, WORKGRAPH_SERVER_CONFIG_FILE); +} + +export function loadServerConfig(workspacePath: string): WorkgraphServerConfig | null { + const targetPath = serverConfigPath(workspacePath); + if (!fs.existsSync(targetPath)) return null; + + try { + const parsed = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as Record; + return normalizeServerConfig(parsed); + } catch { + return null; + } +} + +export function ensureServerConfig( + workspacePath: string, + options: EnsureServerConfigOptions = {}, +): EnsureServerConfigResult { + const targetPath = serverConfigPath(workspacePath); + const desiredBootstrapTokenPath = normalizePathRef( + options.bootstrapTokenPath ?? DEFAULT_BOOTSTRAP_TOKEN_PATH, + ); + const existing = loadServerConfig(workspacePath); + if (!existing) { + const createdConfig = normalizeServerConfig({ + registration: { + bootstrapTokenPath: desiredBootstrapTokenPath, + }, + }); + writeServerConfig(workspacePath, createdConfig); + return { + config: createdConfig, + path: targetPath, + created: true, + updated: false, + }; + } + + const needsBootstrapPath = !existing.registration.bootstrapTokenPath; + if (!needsBootstrapPath) { + return { + config: existing, + path: targetPath, + created: false, + updated: false, + }; + } + + const updated = { + ...existing, + registration: { + ...existing.registration, + bootstrapTokenPath: desiredBootstrapTokenPath, + }, + }; + writeServerConfig(workspacePath, updated); + return { + config: updated, + path: targetPath, + created: false, + updated: true, + }; +} + +function writeServerConfig(workspacePath: string, config: WorkgraphServerConfig): void { + const targetPath = serverConfigPath(workspacePath); + const directory = path.dirname(targetPath); + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + fs.writeFileSync(targetPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); +} + +function normalizeServerConfig(input: Record): WorkgraphServerConfig { + const registrationInput = asRecord(input.registration); + return { + host: readString(input.host) ?? DEFAULT_SERVER_HOST, + port: normalizePort(input.port), + endpointPath: normalizeEndpointPath(readString(input.endpointPath)), + defaultActor: readString(input.defaultActor) ?? DEFAULT_SERVER_ACTOR, + bearerToken: readString(input.bearerToken), + registration: { + enabled: readBoolean(registrationInput.enabled) ?? true, + bootstrapTokenPath: normalizePathRef( + readString(registrationInput.bootstrapTokenPath) ?? DEFAULT_BOOTSTRAP_TOKEN_PATH, + ), + }, + }; +} + +function normalizePort(value: unknown): number { + if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 65535) { + return value; + } + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number.parseInt(value, 10); + if (Number.isInteger(parsed) && parsed >= 0 && parsed <= 65535) { + return parsed; + } + } + return DEFAULT_SERVER_PORT; +} + +function normalizeEndpointPath(rawPath: string | undefined): string { + const trimmed = String(rawPath ?? '').trim(); + if (!trimmed) return DEFAULT_SERVER_ENDPOINT_PATH; + if (trimmed.startsWith('/')) return trimmed; + return `/${trimmed}`; +} + +function normalizePathRef(rawPath: string): string { + const normalized = String(rawPath) + .trim() + .replace(/\\/g, '/') + .replace(/^\.\//, ''); + if (!normalized) return DEFAULT_BOOTSTRAP_TOKEN_PATH; + return normalized.endsWith('.md') ? normalized : `${normalized}.md`; +} + +function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function readString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function readBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value; + 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 undefined; +} diff --git a/packages/kernel/src/starter-kit.ts b/packages/kernel/src/starter-kit.ts new file mode 100644 index 0000000..a0337f6 --- /dev/null +++ b/packages/kernel/src/starter-kit.ts @@ -0,0 +1,449 @@ +import { randomBytes } from 'node:crypto'; +import * as registry from './registry.js'; +import * as store from './store.js'; +import { + ensureServerConfig, + loadServerConfig, + type EnsureServerConfigResult, +} from './server-config.js'; +import type { FieldDefinition, PrimitiveInstance } from './types.js'; + +const ROLE_TYPE = 'role'; +const TRUST_TOKEN_TYPE = 'trust-token'; +const BOOTSTRAP_TRUST_TOKEN_PATH = 'trust-tokens/bootstrap-first-agent.md'; +const STARTER_ACTOR = 'system'; + +interface PrimitiveSeedSpec { + typeName: string; + path: string; + fields: Record; + body: string; +} + +export interface StarterKitSeedSummary { + created: string[]; + existing: string[]; +} + +export interface StarterKitSeedResult { + roles: StarterKitSeedSummary; + policies: StarterKitSeedSummary; + gates: StarterKitSeedSummary; + spaces: StarterKitSeedSummary; + trustTokens: StarterKitSeedSummary; + bootstrapTrustToken: string; + bootstrapTrustTokenPath: string; + serverConfig: EnsureServerConfigResult; +} + +export function seedStarterKit(workspacePath: string): StarterKitSeedResult { + ensureStarterTypeDefinitions(workspacePath); + + const roleSeeds = seedGroup(workspacePath, buildRoleSeeds()); + const policySeeds = seedGroup(workspacePath, buildPolicySeeds()); + const gateSeeds = seedGroup(workspacePath, buildGateSeeds()); + const spaceSeeds = seedGroup(workspacePath, buildSpaceSeeds()); + const configuredBootstrapPath = loadServerConfig(workspacePath)?.registration.bootstrapTokenPath + ?? BOOTSTRAP_TRUST_TOKEN_PATH; + const tokenSeed = seedBootstrapTrustToken(workspacePath, configuredBootstrapPath); + const serverConfig = ensureServerConfig(workspacePath, { + bootstrapTokenPath: tokenSeed.instance.path, + }); + + return { + roles: roleSeeds, + policies: policySeeds, + gates: gateSeeds, + spaces: spaceSeeds, + trustTokens: { + created: tokenSeed.created ? [tokenSeed.instance.path] : [], + existing: tokenSeed.created ? [] : [tokenSeed.instance.path], + }, + bootstrapTrustToken: String(tokenSeed.instance.fields.token), + bootstrapTrustTokenPath: tokenSeed.instance.path, + serverConfig, + }; +} + +function ensureStarterTypeDefinitions(workspacePath: string): void { + ensureType(workspacePath, ROLE_TYPE, { + description: 'Workspace role profile used for registration + policy defaults.', + directory: 'roles', + fields: { + description: { type: 'string', required: true }, + capabilities: { type: 'list', default: [] }, + can_register_agents: { type: 'boolean', default: false }, + default_for_bootstrap: { type: 'boolean', default: false }, + }, + }); + + ensureType(workspacePath, TRUST_TOKEN_TYPE, { + description: 'Bootstrap trust token used to register agents.', + directory: 'trust-tokens', + fields: { + token: { type: 'string', required: true }, + status: { type: 'string', default: 'active', enum: ['active', 'used', 'revoked'] }, + max_uses: { type: 'number', default: 1 }, + used_count: { type: 'number', default: 0 }, + used_by: { type: 'list', default: [] }, + default_role: { type: 'string', default: 'admin' }, + expires_at: { type: 'date' }, + }, + }); +} + +function ensureType( + workspacePath: string, + typeName: string, + definition: { + description: string; + directory: string; + fields: Record; + }, +): void { + const existing = registry.getType(workspacePath, typeName); + if (existing) return; + registry.defineType( + workspacePath, + typeName, + definition.description, + definition.fields, + STARTER_ACTOR, + definition.directory, + ); +} + +function buildRoleSeeds(): PrimitiveSeedSpec[] { + return [ + { + typeName: ROLE_TYPE, + path: 'roles/admin.md', + fields: { + title: 'Admin', + description: 'Full workspace governance, registration, and policy authority.', + capabilities: ['promote:sensitive', 'policy:manage', 'dispatch:run', 'gate:manage', 'agent:register'], + can_register_agents: true, + default_for_bootstrap: true, + tags: ['starter-kit', 'role'], + }, + body: [ + '# Admin Role', + '', + 'Use this role for trusted maintainers who can manage policy, registration, and escalations.', + '', + '## Editable defaults', + '', + '- Update `capabilities` in frontmatter to fit your team.', + '- Add onboarding conventions or runbooks below.', + '', + ].join('\n'), + }, + { + typeName: ROLE_TYPE, + path: 'roles/ops.md', + fields: { + title: 'Ops', + description: 'Operations-focused role for incident response and runtime coordination.', + capabilities: ['dispatch:run', 'thread:manage', 'incident:respond', 'agent:register'], + can_register_agents: true, + tags: ['starter-kit', 'role'], + }, + body: [ + '# Ops Role', + '', + 'Use this role for reliability, incident response, and production operations.', + '', + '## Editable defaults', + '', + '- Tune escalation responsibilities.', + '- Add service-specific operational checklists.', + '', + ].join('\n'), + }, + { + typeName: ROLE_TYPE, + path: 'roles/contributor.md', + fields: { + title: 'Contributor', + description: 'Default builder role for day-to-day delivery work.', + capabilities: ['thread:create', 'thread:update', 'thread:complete'], + tags: ['starter-kit', 'role'], + }, + body: [ + '# Contributor Role', + '', + 'Use this role for most implementation agents or team members.', + '', + '## Editable defaults', + '', + '- Add capability constraints as needed.', + '- Define contributor expectations and review boundaries.', + '', + ].join('\n'), + }, + { + typeName: ROLE_TYPE, + path: 'roles/viewer.md', + fields: { + title: 'Viewer', + description: 'Read-only observer role for visibility without mutation authority.', + capabilities: ['thread:read', 'ledger:read', 'status:read'], + tags: ['starter-kit', 'role'], + }, + body: [ + '# Viewer Role', + '', + 'Use this role for stakeholders who need visibility but should not mutate state.', + '', + '## Editable defaults', + '', + '- Add reporting/query capabilities only.', + '', + ].join('\n'), + }, + ]; +} + +function buildPolicySeeds(): PrimitiveSeedSpec[] { + return [ + { + typeName: 'policy', + path: 'policies/registration-approval.md', + fields: { + title: 'Registration Approval', + status: 'active', + scope: 'workspace', + approvers: ['roles/admin.md'], + tags: ['starter-kit', 'onboarding'], + }, + body: [ + '# Registration Approval Policy', + '', + 'Defines who can approve and manage new agent registrations.', + '', + '## Defaults', + '', + '- Initial approvers: [[roles/admin.md]]', + '- Bootstrap registrations should be audited in ledger history.', + '', + 'Edit this policy to match your governance model.', + '', + ].join('\n'), + }, + { + typeName: 'policy', + path: 'policies/thread-lifecycle.md', + fields: { + title: 'Thread Lifecycle', + status: 'active', + scope: 'thread', + approvers: ['roles/admin.md', 'roles/ops.md'], + tags: ['starter-kit', 'threads'], + }, + body: [ + '# Thread Lifecycle Policy', + '', + 'Defines ownership, transitions, and quality expectations for thread state changes.', + '', + '## Defaults', + '', + '- Sensitive lifecycle transitions should be reviewed by [[roles/admin.md]] or [[roles/ops.md]].', + '- Thread completion should satisfy the completion gate.', + '', + ].join('\n'), + }, + { + typeName: 'policy', + path: 'policies/escalation.md', + fields: { + title: 'Escalation', + status: 'active', + scope: 'incident', + approvers: ['roles/ops.md', 'roles/admin.md'], + tags: ['starter-kit', 'incident'], + }, + body: [ + '# Escalation Policy', + '', + 'Defines escalation expectations when blocked work or incidents require intervention.', + '', + '## Defaults', + '', + '- Operational escalation: [[roles/ops.md]]', + '- Final escalation authority: [[roles/admin.md]]', + '', + ].join('\n'), + }, + ]; +} + +function buildGateSeeds(): PrimitiveSeedSpec[] { + return [ + { + typeName: 'policy-gate', + path: 'policy-gates/completion.md', + fields: { + title: 'Completion Gate', + status: 'active', + required_facts: [], + required_approvals: [], + min_age_seconds: 0, + requiredDescendants: true, + evidencePolicy: 'relaxed', + tags: ['starter-kit', 'gate'], + }, + body: [ + '# Completion Gate', + '', + 'Default quality gate evaluated before claiming/completing gated threads.', + '', + '## Defaults', + '', + '- Requires descendants to be done/cancelled (`requiredDescendants: true`).', + '- Uses relaxed evidence policy for fast onboarding.', + '', + 'Tighten these rules as your team matures.', + '', + ].join('\n'), + }, + ]; +} + +function buildSpaceSeeds(): PrimitiveSeedSpec[] { + return [ + { + typeName: 'space', + path: 'spaces/general.md', + fields: { + title: 'General', + description: 'Default coordination lane for newly created threads.', + members: [], + thread_refs: [], + tags: ['starter-kit', 'default'], + }, + body: [ + '# General Space', + '', + 'Default shared space for team coordination.', + '', + '## Suggested use', + '', + '- Start initial onboarding threads here.', + '- Create additional spaces for domain lanes as needed.', + '', + ].join('\n'), + }, + ]; +} + +function seedBootstrapTrustToken(workspacePath: string, tokenPath: string): { + created: boolean; + instance: PrimitiveInstance; +} { + const normalizedTokenPath = normalizePrimitivePath(tokenPath); + const existing = store.read(workspacePath, normalizedTokenPath); + if (existing) { + if (existing.type !== TRUST_TOKEN_TYPE) { + throw new Error( + `Starter-kit seed conflict at ${normalizedTokenPath}: expected ${TRUST_TOKEN_TYPE}, found ${existing.type}.`, + ); + } + const existingToken = String(existing.fields.token ?? '').trim(); + if (existingToken.length > 0) { + return { created: false, instance: existing }; + } + const repairedToken = generateBootstrapToken(); + const updated = store.update( + workspacePath, + existing.path, + { + token: repairedToken, + status: existing.fields.status ?? 'active', + }, + undefined, + STARTER_ACTOR, + ); + return { created: false, instance: updated }; + } + + const created = store.create( + workspacePath, + TRUST_TOKEN_TYPE, + { + title: 'Bootstrap First Agent Token', + token: generateBootstrapToken(), + status: 'active', + max_uses: 1, + used_count: 0, + used_by: [], + default_role: 'admin', + tags: ['starter-kit', 'bootstrap'], + }, + [ + '# Bootstrap Trust Token', + '', + 'Use this token to register the first agent in a fresh workspace.', + '', + '## Notes', + '', + '- Token defaults to a single use.', + '- Rotate or revoke this token after first registration.', + '', + ].join('\n'), + STARTER_ACTOR, + { pathOverride: normalizedTokenPath }, + ); + return { created: true, instance: created }; +} + +function seedGroup(workspacePath: string, specs: PrimitiveSeedSpec[]): StarterKitSeedSummary { + const created: string[] = []; + const existing: string[] = []; + for (const spec of specs) { + const seeded = seedPrimitiveIfMissing(workspacePath, spec); + if (seeded.created) { + created.push(seeded.instance.path); + } else { + existing.push(seeded.instance.path); + } + } + return { created, existing }; +} + +function seedPrimitiveIfMissing( + workspacePath: string, + spec: PrimitiveSeedSpec, +): { created: boolean; instance: PrimitiveInstance } { + const normalizedPath = normalizePrimitivePath(spec.path); + const existing = store.read(workspacePath, normalizedPath); + if (existing) { + if (existing.type !== spec.typeName) { + throw new Error( + `Starter-kit seed conflict at ${normalizedPath}: expected ${spec.typeName}, found ${existing.type}.`, + ); + } + return { created: false, instance: existing }; + } + + const created = store.create( + workspacePath, + spec.typeName, + spec.fields, + spec.body, + STARTER_ACTOR, + { pathOverride: normalizedPath }, + ); + return { created: true, instance: created }; +} + +function normalizePrimitivePath(rawPath: string): string { + const normalized = String(rawPath) + .trim() + .replace(/\\/g, '/') + .replace(/^\.\//, ''); + return normalized.endsWith('.md') ? normalized : `${normalized}.md`; +} + +function generateBootstrapToken(): string { + return `wg-bootstrap-${randomBytes(12).toString('hex')}`; +} diff --git a/packages/kernel/src/workspace.test.ts b/packages/kernel/src/workspace.test.ts index 268b876..71aac3f 100644 --- a/packages/kernel/src/workspace.test.ts +++ b/packages/kernel/src/workspace.test.ts @@ -16,10 +16,11 @@ afterEach(() => { }); describe('workspace init', () => { - it('creates a pure workgraph workspace with registry and type directories', () => { + it('creates a starter workspace with seeded primitives and onboarding docs', () => { const result = initWorkspace(workspacePath, { name: 'agent-space' }); expect(result.config.name).toBe('agent-space'); + expect(result.alreadyInitialized).toBe(false); expect(isWorkgraphWorkspace(workspacePath)).toBe(true); expect(fs.existsSync(path.join(workspacePath, '.workgraph/registry.json'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, 'threads'))).toBe(true); @@ -28,25 +29,60 @@ describe('workspace init', () => { expect(fs.existsSync(path.join(workspacePath, 'skills'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, 'onboarding'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, 'README.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'QUICKSTART.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, '.workgraph/server.json'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, '.workgraph/primitive-registry.yaml'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, '.workgraph/bases/thread.base'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, '.workgraph/graph-index.json'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'roles/admin.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'roles/ops.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'roles/contributor.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'roles/viewer.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'policies/registration-approval.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'policies/thread-lifecycle.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'policies/escalation.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'policy-gates/completion.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'spaces/general.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, result.bootstrapTrustTokenPath))).toBe(true); + expect(result.bootstrapTrustToken).toMatch(/^wg-bootstrap-[a-f0-9]{24}$/); expect(result.seededTypes).toContain('thread'); + expect(result.seededTypes).toContain('role'); + expect(result.seededTypes).toContain('trust-token'); expect(result.generatedBases.length).toBeGreaterThan(0); + + expect(readFile(path.join(workspacePath, 'roles/admin.md'))).toMatch(/^---\n[\s\S]+?\n---\n/); + expect(readFile(path.join(workspacePath, 'policies/registration-approval.md'))).toMatch(/^---\n[\s\S]+?\n---\n/); + expect(readFile(path.join(workspacePath, 'policy-gates/completion.md'))).toMatch(/^---\n[\s\S]+?\n---\n/); + expect(readFile(path.join(workspacePath, 'QUICKSTART.md'))).toContain('Start the server'); + expect(readFile(path.join(workspacePath, 'QUICKSTART.md'))).toContain('Register your first agent'); }); it('supports no-type-dirs and no-readme mode', () => { - initWorkspace(workspacePath, { createTypeDirs: false, createReadme: false, createBases: false }); + const result = initWorkspace(workspacePath, { createTypeDirs: false, createReadme: false, createBases: false }); expect(fs.existsSync(path.join(workspacePath, '.workgraph/registry.json'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, '.workgraph/primitive-registry.yaml'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, 'threads'))).toBe(false); expect(fs.existsSync(path.join(workspacePath, 'README.md'))).toBe(false); + expect(fs.existsSync(path.join(workspacePath, 'QUICKSTART.md'))).toBe(false); + expect(fs.existsSync(path.join(workspacePath, 'roles/admin.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'policy-gates/completion.md'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, result.bootstrapTrustTokenPath))).toBe(true); expect(fs.existsSync(path.join(workspacePath, '.workgraph/bases/thread.base'))).toBe(false); }); - it('fails on re-initialization', () => { - initWorkspace(workspacePath); - expect(() => initWorkspace(workspacePath)).toThrow('already initialized'); + it('is idempotent and preserves existing workspace data on re-run', () => { + const first = initWorkspace(workspacePath); + const adminPath = path.join(workspacePath, 'roles/admin.md'); + const edited = `${readFile(adminPath).trim()}\n\n## Team override\n\nKeep this custom note.\n`; + fs.writeFileSync(adminPath, edited, 'utf-8'); + + const second = initWorkspace(workspacePath); + + expect(second.alreadyInitialized).toBe(true); + expect(second.bootstrapTrustToken).toBe(first.bootstrapTrustToken); + expect(readFile(adminPath)).toContain('## Team override'); + expect(readFile(adminPath)).toContain('Keep this custom note.'); + expect(fs.existsSync(path.join(workspacePath, 'roles/admin.md'))).toBe(true); }); it('writes workspace config in predictable location', () => { @@ -54,3 +90,7 @@ describe('workspace init', () => { expect(fs.existsSync(workspaceConfigPath(workspacePath))).toBe(true); }); }); + +function readFile(filePath: string): string { + return fs.readFileSync(filePath, 'utf-8'); +} diff --git a/packages/kernel/src/workspace.ts b/packages/kernel/src/workspace.ts index 898f544..3af1391 100644 --- a/packages/kernel/src/workspace.ts +++ b/packages/kernel/src/workspace.ts @@ -8,6 +8,7 @@ import { loadRegistry, saveRegistry, listTypes } from './registry.js'; import { syncPrimitiveRegistryManifest, generateBasesFromPrimitiveRegistry } from './bases.js'; import { refreshWikiLinkGraphIndex } from './graph.js'; import { loadPolicyRegistry } from './policy.js'; +import { seedStarterKit, type StarterKitSeedResult } from './starter-kit.js'; import type { WorkgraphWorkspaceConfig } from './types.js'; const WORKGRAPH_CONFIG_FILE = '.workgraph.json'; @@ -23,10 +24,26 @@ export interface InitWorkspaceResult { workspacePath: string; configPath: string; config: WorkgraphWorkspaceConfig; + alreadyInitialized: boolean; createdDirectories: string[]; seededTypes: string[]; generatedBases: string[]; primitiveRegistryManifestPath: string; + readmePath?: string; + quickstartPath?: string; + starterKit: StarterKitSeedResult; + bootstrapTrustToken: string; + bootstrapTrustTokenPath: string; + serverConfigPath: string; +} + +interface QuickstartTemplateInput { + workspaceName: string; + workspacePath: string; + bootstrapTrustToken: string; + bootstrapTrustTokenPath: string; + serverHost: string; + serverPort: number; } export function workspaceConfigPath(workspacePath: string): string { @@ -40,9 +57,7 @@ export function isWorkgraphWorkspace(workspacePath: string): boolean { export function initWorkspace(targetPath: string, options: InitWorkspaceOptions = {}): InitWorkspaceResult { const resolvedPath = path.resolve(targetPath); const configPath = workspaceConfigPath(resolvedPath); - if (fs.existsSync(configPath)) { - throw new Error(`Workgraph workspace already initialized at ${resolvedPath}`); - } + const alreadyInitialized = fs.existsSync(configPath); const createdDirectories: string[] = []; ensureDir(resolvedPath, createdDirectories); @@ -50,6 +65,8 @@ export function initWorkspace(targetPath: string, options: InitWorkspaceOptions const registry = loadRegistry(resolvedPath); saveRegistry(resolvedPath, registry); + + const starterKit = seedStarterKit(resolvedPath); syncPrimitiveRegistryManifest(resolvedPath); if (options.createTypeDirs !== false) { @@ -59,19 +76,22 @@ export function initWorkspace(targetPath: string, options: InitWorkspaceOptions } } - const now = new Date().toISOString(); - const config: WorkgraphWorkspaceConfig = { - name: options.name ?? path.basename(resolvedPath), - version: '1.0.0', - mode: 'workgraph', - createdAt: now, - updatedAt: now, - }; - fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); - - if (options.createReadme !== false) { - writeReadmeIfMissing(resolvedPath, config.name); - } + const config = ensureWorkspaceConfig(configPath, resolvedPath, options.name); + + const readmeEnabled = options.createReadme !== false; + const readmePath = readmeEnabled + ? writeReadmeIfMissing(resolvedPath, config.name) + : undefined; + const quickstartPath = readmeEnabled + ? writeQuickstartIfMissing(resolvedPath, { + workspaceName: config.name, + workspacePath: resolvedPath, + bootstrapTrustToken: starterKit.bootstrapTrustToken, + bootstrapTrustTokenPath: starterKit.bootstrapTrustTokenPath, + serverHost: starterKit.serverConfig.config.host, + serverPort: starterKit.serverConfig.config.port, + }) + : undefined; const bases = options.createBases === false ? { generated: [] } @@ -83,33 +103,139 @@ export function initWorkspace(targetPath: string, options: InitWorkspaceOptions workspacePath: resolvedPath, configPath, config, + alreadyInitialized, createdDirectories, - seededTypes: listTypes(resolvedPath).map(t => t.name), + seededTypes: listTypes(resolvedPath).map((typeDef) => typeDef.name), generatedBases: bases.generated, primitiveRegistryManifestPath: '.workgraph/primitive-registry.yaml', + readmePath, + quickstartPath, + starterKit, + bootstrapTrustToken: starterKit.bootstrapTrustToken, + bootstrapTrustTokenPath: starterKit.bootstrapTrustTokenPath, + serverConfigPath: starterKit.serverConfig.path, }; } +function ensureWorkspaceConfig( + configPath: string, + workspacePath: string, + requestedName?: string, +): WorkgraphWorkspaceConfig { + const now = new Date().toISOString(); + if (fs.existsSync(configPath)) { + try { + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Partial; + return { + name: readNonEmptyString(parsed.name) ?? requestedName ?? path.basename(workspacePath), + version: readNonEmptyString(parsed.version) ?? '1.0.0', + mode: 'workgraph', + createdAt: readNonEmptyString(parsed.createdAt) ?? now, + updatedAt: readNonEmptyString(parsed.updatedAt) ?? now, + }; + } catch { + // Fall through and rewrite a valid workspace config. + } + } + + const created: WorkgraphWorkspaceConfig = { + name: requestedName ?? path.basename(workspacePath), + version: '1.0.0', + mode: 'workgraph', + createdAt: now, + updatedAt: now, + }; + fs.writeFileSync(configPath, JSON.stringify(created, null, 2) + '\n', 'utf-8'); + return created; +} + function ensureDir(dirPath: string, createdDirectories: string[]): void { if (fs.existsSync(dirPath)) return; fs.mkdirSync(dirPath, { recursive: true }); createdDirectories.push(dirPath); } -function writeReadmeIfMissing(workspacePath: string, name: string): void { +function writeReadmeIfMissing(workspacePath: string, name: string): string { const readmePath = path.join(workspacePath, 'README.md'); - if (fs.existsSync(readmePath)) return; + if (fs.existsSync(readmePath)) return readmePath; const content = `# ${name} -Agent-first workgraph workspace for multi-agent coordination. +Starter workgraph workspace seeded by \`workgraph init\`. + +This workspace includes editable default primitives: -## Quickstart +- Roles: \`roles/admin.md\`, \`roles/ops.md\`, \`roles/contributor.md\`, \`roles/viewer.md\` +- Policies: \`policies/registration-approval.md\`, \`policies/thread-lifecycle.md\`, \`policies/escalation.md\` +- Gate: \`policy-gates/completion.md\` +- Space: \`spaces/general.md\` +- Bootstrap trust token: \`trust-tokens/bootstrap-first-agent.md\` + +## Next step + +Open \`QUICKSTART.md\` for the first-run flow: + +1. Start server +2. Register first agent +3. Create first thread +`; + fs.writeFileSync(readmePath, content, 'utf-8'); + return readmePath; +} + +function writeQuickstartIfMissing(workspacePath: string, input: QuickstartTemplateInput): string { + const quickstartPath = path.join(workspacePath, 'QUICKSTART.md'); + if (fs.existsSync(quickstartPath)) return quickstartPath; + + const content = `# ${input.workspaceName} Quickstart + +This workspace is ready to use. Follow these steps in order. + +## 1) Start the server \`\`\`bash -workgraph thread list --json -workgraph thread next --claim --actor agent-a --json -workgraph ledger show --count 20 --json +workgraph serve -w "${input.workspacePath}" \`\`\` + +Default server config is in \`.workgraph/server.json\` (host: ${input.serverHost}, port: ${input.serverPort}). + +## 2) Register your first agent + +Bootstrap trust token path: \`${input.bootstrapTrustTokenPath}\` +Bootstrap trust token value: \`${input.bootstrapTrustToken}\` + +\`\`\`bash +workgraph agent register agent-1 -w "${input.workspacePath}" --token ${input.bootstrapTrustToken} +\`\`\` + +After registration, edit role/policy primitives as needed: + +- \`roles/admin.md\`, \`roles/ops.md\`, \`roles/contributor.md\`, \`roles/viewer.md\` +- \`policies/registration-approval.md\` +- \`policies/thread-lifecycle.md\` +- \`policies/escalation.md\` +- \`policy-gates/completion.md\` + +## 3) Create your first thread + +\`\`\`bash +workgraph thread create "First coordinated task" \\ + -w "${input.workspacePath}" \\ + --goal "Validate end-to-end flow in this new workspace" \\ + --actor agent-1 +\`\`\` + +Optional next steps: + +- \`workgraph thread list -w "${input.workspacePath}"\` +- \`workgraph thread next --claim -w "${input.workspacePath}" --actor agent-1\` +- \`workgraph ledger show -w "${input.workspacePath}" --count 20\` `; - fs.writeFileSync(readmePath, content, 'utf-8'); + fs.writeFileSync(quickstartPath, content, 'utf-8'); + return quickstartPath; +} + +function readNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; } diff --git a/tests/helpers/cli-build.ts b/tests/helpers/cli-build.ts new file mode 100644 index 0000000..4fdf5d1 --- /dev/null +++ b/tests/helpers/cli-build.ts @@ -0,0 +1,72 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; + +const BUILD_LOCK_DIR = path.join(os.tmpdir(), 'workgraph-cli-build-lock'); +const DIST_ENTRY = path.resolve('dist/cli.js'); +const PNPM_COMMAND = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const BUILD_LOCK_POLL_MS = 50; +const SLEEP_BUFFER = new SharedArrayBuffer(4); +const SLEEP_ARRAY = new Int32Array(SLEEP_BUFFER); + +export function ensureCliBuiltForTests(): void { + if (fs.existsSync(DIST_ENTRY)) return; + + acquireBuildLock(); + try { + if (fs.existsSync(DIST_ENTRY)) return; + const result = runBuild(); + if (result.status !== 0) { + throw new Error(`Failed to build CLI for tests.\n${formatBuildFailure(result)}`); + } + } finally { + releaseBuildLock(); + } +} + +function acquireBuildLock(): void { + // Directory creation is atomic; retries coordinate concurrent test workers. + while (true) { + try { + fs.mkdirSync(BUILD_LOCK_DIR); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EEXIST') throw error; + sleep(BUILD_LOCK_POLL_MS); + } + } +} + +function releaseBuildLock(): void { + fs.rmSync(BUILD_LOCK_DIR, { recursive: true, force: true }); +} + +function runBuild(): SpawnSyncReturns { + const npmExecPath = process.env.npm_execpath; + if (npmExecPath) { + return spawnSync(process.execPath, [npmExecPath, 'run', '--silent', 'build'], { + encoding: 'utf-8', + }); + } + return spawnSync(PNPM_COMMAND, ['run', '--silent', 'build'], { + encoding: 'utf-8', + }); +} + +function formatBuildFailure(result: SpawnSyncReturns): string { + const stderr = result.stderr?.trim(); + const stdout = result.stdout?.trim(); + const error = result.error?.message; + return [ + `status=${String(result.status)} signal=${String(result.signal)}`, + error ? `error=${error}` : '', + `stdout:\n${stdout || '(empty)'}`, + `stderr:\n${stderr || '(empty)'}`, + ].filter(Boolean).join('\n'); +} + +function sleep(ms: number): void { + Atomics.wait(SLEEP_ARRAY, 0, 0, ms); +} diff --git a/tests/integration/cli-compat.test.ts b/tests/integration/cli-compat.test.ts index 35cc603..7052923 100644 --- a/tests/integration/cli-compat.test.ts +++ b/tests/integration/cli-compat.test.ts @@ -2,13 +2,11 @@ import { describe, it, expect, beforeAll } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; - -let cliBuilt = false; -const PNPM_COMMAND = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +import { spawnSync } from 'node:child_process'; +import { ensureCliBuiltForTests } from '../helpers/cli-build.js'; function runCli(args: string[]): { ok: boolean; data?: unknown; error?: string } { - ensureBuiltCli(); + ensureCliBuiltForTests(); const result = spawnSync('node', [path.resolve('bin/workgraph.js'), ...args], { encoding: 'utf-8', }); @@ -22,25 +20,9 @@ function runCli(args: string[]): { ok: boolean; data?: unknown; error?: string } return parsed; } -function ensureBuiltCli(): void { - if (cliBuilt) return; - const result = runBuild(); - if (result.status !== 0) { - throw new Error( - `Failed to build CLI before compatibility test: ${formatBuildFailure(result)}`, - ); - } - cliBuilt = true; -} - describe('CLI compatibility smoke', () => { beforeAll(() => { - const build = runBuild(); - if (build.status !== 0) { - throw new Error( - `Failed to build CLI for compatibility test.\n${formatBuildFailure(build)}`, - ); - } + ensureCliBuiltForTests(); }); it('keeps existing JSON envelope and legacy command behaviors', () => { @@ -237,27 +219,3 @@ describe('CLI compatibility smoke', () => { expect(primitiveUpdateHelp.stdout).toContain('--etag'); }); }); - -function runBuild(): SpawnSyncReturns { - const npmExecPath = process.env.npm_execpath; - if (npmExecPath) { - return spawnSync(process.execPath, [npmExecPath, 'run', '--silent', 'build'], { - encoding: 'utf-8', - }); - } - return spawnSync(PNPM_COMMAND, ['run', '--silent', 'build'], { - encoding: 'utf-8', - }); -} - -function formatBuildFailure(result: SpawnSyncReturns): string { - const stderr = result.stderr?.trim(); - const stdout = result.stdout?.trim(); - const error = result.error?.message; - return [ - `status=${String(result.status)} signal=${String(result.signal)}`, - error ? `error=${error}` : '', - `stdout:\n${stdout || '(empty)'}`, - `stderr:\n${stderr || '(empty)'}`, - ].filter(Boolean).join('\n'); -}