diff --git a/examples/multi-agent-showcase/README.md b/examples/multi-agent-showcase/README.md new file mode 100644 index 0000000..41009f0 --- /dev/null +++ b/examples/multi-agent-showcase/README.md @@ -0,0 +1,83 @@ +# OBJ-09: Signature Multi-Agent Showcase + +This showcase demonstrates a full WorkGraph collaboration lifecycle with three agents: + +- `governance-admin` (governance + approvals) +- `agent-intake` (triage + routing) +- `agent-builder` (implementation) +- `agent-reviewer` (self-assembly + QA closure) + +The flow is intentionally end-to-end and reproducible from a fresh workspace. Every WorkGraph CLI invocation uses `--json`. + +## What this demonstrates + +1. **Agent registration and governance** + - Bootstrap admin registration + - Approval-based registration requests for agents + - Credential issuance and heartbeat publication + +2. **Thread lifecycle and plan-step coordination** + - Multi-thread objective decomposition + - Conversation and plan-step creation + - Claim/start/progress/done transitions across multiple actors + +3. **Self-assembly** + - Capability advertisement + requirements matching + - `assembleAgent()` claims the next suitable thread + - Existing plan-step is automatically activated for the assembled agent + +4. **Trigger -> run -> evidence loop** + - Trigger creation with a structured `dispatch-run` action + - Trigger engine cycle executes runs automatically + - Dispatch run evidence chain is validated from CLI output + - Ledger hash-chain integrity is verified + +## Run it + +From repo root: + +```bash +bash examples/multi-agent-showcase/run.sh --json +``` + +Optional arguments: + +- `--workspace `: use a specific workspace directory +- `--skip-build`: skip `pnpm run build` (useful in tests) +- `--json`: emit machine-readable summary only + +## Script breakdown + +- `scripts/01-governance.mjs` + - Initializes workspace + - Registers `governance-admin` with bootstrap token + - Runs request/review approval flow for all collaborating agents + - Outputs issued API keys and governance snapshot + +- `scripts/02-collaboration.mjs` + - Creates threads, conversation, and plan-steps + - Drives intake + builder thread lifecycle transitions + - Runs self-assembly for reviewer via SDK + - Completes reviewer plan-step and closes conversation + +- `scripts/03-trigger-loop.mjs` + - Creates active trigger via SDK with `dispatch-run` action + - Executes trigger engine loop with run execution + - Validates run status/evidence and ledger integrity + +- `scripts/run-showcase.mjs` + - Orchestrates all phases + - Collects rollup metrics and boolean capability checks + - Returns one final JSON report + +## Expected outcome + +The final JSON output contains: + +- `checks.governance` +- `checks.selfAssemblyClaimedReviewerThread` +- `checks.planStepCoordinated` +- `checks.triggerRunEvidence` +- `checks.ledgerActivity` + +When all checks are `true`, the showcase has completed successfully. diff --git a/examples/multi-agent-showcase/run.sh b/examples/multi-agent-showcase/run.sh new file mode 100755 index 0000000..2dcb187 --- /dev/null +++ b/examples/multi-agent-showcase/run.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +WORKSPACE="" +SKIP_BUILD=0 +JSON_MODE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --workspace|-w) + if [[ $# -lt 2 ]]; then + echo "Missing value for $1" >&2 + exit 1 + fi + WORKSPACE="$2" + shift 2 + ;; + --skip-build) + SKIP_BUILD=1 + shift + ;; + --json) + JSON_MODE=1 + shift + ;; + *) + echo "Unknown argument: $1" >&2 + echo "Usage: run.sh [--workspace ] [--skip-build] [--json]" >&2 + exit 1 + ;; + esac +done + +if [[ -z "${WORKSPACE}" ]]; then + WORKSPACE="$(mktemp -d /tmp/workgraph-obj09-showcase-XXXXXX)" +fi + +if [[ "${SKIP_BUILD}" -ne 1 ]]; then + echo "[obj-09] building repository artifacts..." >&2 + ( + cd "${REPO_ROOT}" + pnpm run build >/dev/null + ) +fi + +SHOWCASE_ARGS=(--workspace "${WORKSPACE}" --json) +if [[ "${SKIP_BUILD}" -eq 1 ]]; then + SHOWCASE_ARGS+=(--skip-build) +fi + +if [[ "${JSON_MODE}" -ne 1 ]]; then + echo "[obj-09] running showcase in ${WORKSPACE}" >&2 +fi + +node "${SCRIPT_DIR}/scripts/run-showcase.mjs" "${SHOWCASE_ARGS[@]}" diff --git a/examples/multi-agent-showcase/scripts/01-governance.mjs b/examples/multi-agent-showcase/scripts/01-governance.mjs new file mode 100755 index 0000000..7215647 --- /dev/null +++ b/examples/multi-agent-showcase/scripts/01-governance.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +import path from 'node:path'; +import { + ensureBuild, + logLine, + resolveRepoRoot, + resolveWorkspace, + runCliJson, +} from './lib/demo-utils.mjs'; + +const AGENTS = { + admin: 'governance-admin', + intake: 'agent-intake', + builder: 'agent-builder', + reviewer: 'agent-reviewer', +}; + +const roleByAgent = { + [AGENTS.intake]: 'roles/contributor.md', + [AGENTS.builder]: 'roles/contributor.md', + [AGENTS.reviewer]: 'roles/viewer.md', +}; + +async function main() { + const repoRoot = resolveRepoRoot(import.meta.url); + const resolved = resolveWorkspace(process.argv.slice(2)); + if (!resolved.skipBuild) { + logLine('building dist artifacts', resolved.json); + await ensureBuild(repoRoot); + } + const workspacePath = resolved.workspacePath; + + logLine('initializing workspace', resolved.json); + const init = await runCliJson(repoRoot, ['init', workspacePath, '--json']); + const bootstrapTrustToken = String(init.data.bootstrapTrustToken); + + logLine('registering governance admin', resolved.json); + const adminRegistration = await runCliJson(repoRoot, [ + 'agent', + 'register', + AGENTS.admin, + '-w', + workspacePath, + '--token', + bootstrapTrustToken, + '--role', + 'roles/admin.md', + '--capabilities', + 'policy:manage,agent:approve-registration,agent:register,dispatch:run,thread:claim,thread:manage', + '--actor', + AGENTS.admin, + '--json', + ]); + const adminApiKey = String(adminRegistration.data.apiKey ?? ''); + + const approvals = []; + for (const agent of [AGENTS.intake, AGENTS.builder, AGENTS.reviewer]) { + logLine(`requesting registration for ${agent}`, resolved.json); + const request = await runCliJson( + repoRoot, + [ + 'agent', + 'request', + agent, + '-w', + workspacePath, + '--actor', + agent, + '--role', + roleByAgent[agent], + '--capabilities', + 'thread:claim,thread:manage,dispatch:run,agent:heartbeat', + '--note', + `OBJ-09 demo onboarding for ${agent}`, + '--json', + ], + { env: adminApiKey ? { WORKGRAPH_API_KEY: adminApiKey } : undefined }, + ); + const requestPath = String(request.data.request.path); + + logLine(`approving registration for ${agent}`, resolved.json); + const review = await runCliJson( + repoRoot, + [ + 'agent', + 'review', + requestPath, + '-w', + workspacePath, + '--decision', + 'approved', + '--actor', + AGENTS.admin, + '--role', + roleByAgent[agent], + '--capabilities', + 'thread:claim,thread:manage,dispatch:run,agent:heartbeat', + '--json', + ], + { env: adminApiKey ? { WORKGRAPH_API_KEY: adminApiKey } : undefined }, + ); + approvals.push({ + agent, + requestPath, + approvalPath: String(review.data.approval.path), + apiKey: String(review.data.apiKey ?? ''), + }); + } + + logLine('publishing initial agent heartbeats', resolved.json); + for (const approval of approvals) { + await runCliJson( + repoRoot, + [ + 'agent', + 'heartbeat', + approval.agent, + '-w', + workspacePath, + '--actor', + approval.agent, + '--status', + 'online', + '--capabilities', + 'thread:claim,thread:manage,dispatch:run,agent:heartbeat', + '--json', + ], + { env: approval.apiKey ? { WORKGRAPH_API_KEY: approval.apiKey } : undefined }, + ); + } + + const agents = await runCliJson( + repoRoot, + ['agent', 'list', '-w', workspacePath, '--json'], + { env: adminApiKey ? { WORKGRAPH_API_KEY: adminApiKey } : undefined }, + ); + const credentials = await runCliJson( + repoRoot, + ['agent', 'credential-list', '-w', workspacePath, '--json'], + { env: adminApiKey ? { WORKGRAPH_API_KEY: adminApiKey } : undefined }, + ); + + const output = { + workspacePath, + bootstrapTrustToken, + admin: { + actor: AGENTS.admin, + apiKey: adminApiKey, + credentialId: String(adminRegistration.data.credential?.id ?? ''), + }, + approvals, + governanceSnapshot: { + agentCount: Number(agents.data.count ?? 0), + credentialCount: Number(credentials.data.count ?? 0), + }, + }; + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exit(1); +}); diff --git a/examples/multi-agent-showcase/scripts/02-collaboration.mjs b/examples/multi-agent-showcase/scripts/02-collaboration.mjs new file mode 100755 index 0000000..a863735 --- /dev/null +++ b/examples/multi-agent-showcase/scripts/02-collaboration.mjs @@ -0,0 +1,471 @@ +#!/usr/bin/env node + +import { + ensureBuild, + loadSdk, + logLine, + resolveRepoRoot, + runCliJson, +} from './lib/demo-utils.mjs'; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.workspacePath) { + throw new Error('Missing required --workspace argument.'); + } + + const repoRoot = resolveRepoRoot(import.meta.url); + if (!args.skipBuild) { + logLine('building dist artifacts', args.json); + await ensureBuild(repoRoot); + } + const sdk = await loadSdk(repoRoot); + + const workspacePath = args.workspacePath; + const apiKeyEnvByActor = { + [args.adminActor]: args.adminApiKey, + [args.intakeActor]: args.intakeApiKey, + [args.builderActor]: args.builderApiKey, + [args.reviewerActor]: args.reviewerApiKey, + }; + + logLine('creating lifecycle threads', args.json); + const intakeThread = await runCliJson( + repoRoot, + [ + 'thread', + 'create', + 'OBJ-09 intake triage', + '-w', + workspacePath, + '--goal', + 'Collect triage context and route implementation work', + '--priority', + 'high', + '--actor', + args.adminActor, + '--tags', + 'obj-09,intake', + '--json', + ], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + const intakeThreadPath = String(intakeThread.data.thread.path); + + const builderThread = await runCliJson( + repoRoot, + [ + 'thread', + 'create', + 'OBJ-09 implementation', + '-w', + workspacePath, + '--goal', + 'Implement coordinated fix and capture build evidence', + '--priority', + 'high', + '--deps', + intakeThreadPath, + '--actor', + args.adminActor, + '--tags', + 'obj-09,implementation', + '--json', + ], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + const builderThreadPath = String(builderThread.data.thread.path); + + const reviewerThread = await runCliJson( + repoRoot, + [ + 'thread', + 'create', + 'OBJ-09 verification', + '-w', + workspacePath, + '--goal', + 'Verify the fix and close the coordination loop', + '--priority', + 'medium', + '--deps', + builderThreadPath, + '--actor', + args.adminActor, + '--tags', + 'obj-09,verification', + '--json', + ], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + const reviewerThreadPath = String(reviewerThread.data.thread.path); + + logLine('creating conversation and plan steps', args.json); + const conversation = await runCliJson( + repoRoot, + [ + 'conversation', + 'create', + 'OBJ-09 execution room', + '-w', + workspacePath, + '--actor', + args.adminActor, + '--threads', + `${intakeThreadPath},${builderThreadPath},${reviewerThreadPath}`, + '--tags', + 'obj-09,multi-agent', + '--status', + 'active', + '--json', + ], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + const conversationPath = String(conversation.data.conversation.path); + + const intakePlanStep = await runCliJson( + repoRoot, + [ + 'plan-step', + 'create', + conversationPath, + 'Triage incoming issue and hand off implementation', + '-w', + workspacePath, + '--actor', + args.adminActor, + '--thread', + intakeThreadPath, + '--assignee', + args.intakeActor, + '--order', + '1', + '--json', + ], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + const intakeStepPath = String(intakePlanStep.data.step.path); + + const builderPlanStep = await runCliJson( + repoRoot, + [ + 'plan-step', + 'create', + conversationPath, + 'Implement and validate coordinated fix', + '-w', + workspacePath, + '--actor', + args.adminActor, + '--thread', + builderThreadPath, + '--assignee', + args.builderActor, + '--order', + '2', + '--json', + ], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + const builderStepPath = String(builderPlanStep.data.step.path); + + const reviewerPlanStep = await runCliJson( + repoRoot, + [ + 'plan-step', + 'create', + conversationPath, + 'Run independent QA verification', + '-w', + workspacePath, + '--actor', + args.adminActor, + '--thread', + reviewerThreadPath, + '--assignee', + args.reviewerActor, + '--order', + '3', + '--json', + ], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + const reviewerStepPath = String(reviewerPlanStep.data.step.path); + + logLine('running intake and builder lifecycle', args.json); + await runCliJson( + repoRoot, + ['dispatch', 'claim', intakeThreadPath, '-w', workspacePath, '--actor', args.intakeActor, '--json'], + { env: toApiKeyEnv(args.intakeApiKey) }, + ); + await runCliJson( + repoRoot, + ['plan-step', 'start', intakeStepPath, '-w', workspacePath, '--actor', args.intakeActor, '--json'], + { env: toApiKeyEnv(args.intakeApiKey) }, + ); + await runCliJson( + repoRoot, + ['plan-step', 'progress', intakeStepPath, '100', '-w', workspacePath, '--actor', args.intakeActor, '--json'], + { env: toApiKeyEnv(args.intakeApiKey) }, + ); + await runCliJson( + repoRoot, + [ + 'thread', + 'done', + intakeThreadPath, + '-w', + workspacePath, + '--actor', + args.intakeActor, + '--output', + 'Triage completed with evidence https://github.com/versatly/workgraph/pull/obj-09-intake', + '--json', + ], + { env: toApiKeyEnv(args.intakeApiKey) }, + ); + await runCliJson( + repoRoot, + ['plan-step', 'done', intakeStepPath, '-w', workspacePath, '--actor', args.intakeActor, '--json'], + { env: toApiKeyEnv(args.intakeApiKey) }, + ); + + await runCliJson( + repoRoot, + ['dispatch', 'claim', builderThreadPath, '-w', workspacePath, '--actor', args.builderActor, '--json'], + { env: toApiKeyEnv(args.builderApiKey) }, + ); + await runCliJson( + repoRoot, + ['plan-step', 'start', builderStepPath, '-w', workspacePath, '--actor', args.builderActor, '--json'], + { env: toApiKeyEnv(args.builderApiKey) }, + ); + await runCliJson( + repoRoot, + ['plan-step', 'progress', builderStepPath, '75', '-w', workspacePath, '--actor', args.builderActor, '--json'], + { env: toApiKeyEnv(args.builderApiKey) }, + ); + await runCliJson( + repoRoot, + [ + 'thread', + 'done', + builderThreadPath, + '-w', + workspacePath, + '--actor', + args.builderActor, + '--output', + 'Implementation completed with verification logs https://github.com/versatly/workgraph/pull/obj-09-build', + '--json', + ], + { env: toApiKeyEnv(args.builderApiKey) }, + ); + await runCliJson( + repoRoot, + ['plan-step', 'done', builderStepPath, '-w', workspacePath, '--actor', args.builderActor, '--json'], + { env: toApiKeyEnv(args.builderApiKey) }, + ); + + logLine('advertising reviewer capabilities and running self-assembly', args.json); + await runCliJson( + repoRoot, + [ + 'primitive', + 'update', + reviewerThreadPath, + '-w', + workspacePath, + '--actor', + args.reviewerActor, + '--set', + 'required_capabilities=quality:review', + '--set', + 'required_skills=qa-verification', + '--set', + 'required_adapters=shell-worker', + '--json', + ], + { env: toApiKeyEnv(args.reviewerApiKey) }, + ); + await runCliJson( + repoRoot, + [ + 'agent', + 'heartbeat', + args.reviewerActor, + '-w', + workspacePath, + '--actor', + args.reviewerActor, + '--status', + 'online', + '--current-task', + reviewerThreadPath, + '--capabilities', + 'thread:claim,thread:manage,dispatch:run,quality:review,skill:qa-verification,adapter:shell-worker', + '--json', + ], + { env: toApiKeyEnv(args.reviewerApiKey) }, + ); + + const selfAssembly = sdk.agentSelfAssembly.assembleAgent( + workspacePath, + args.reviewerActor, + { + credentialToken: args.reviewerApiKey, + advertise: { + capabilities: ['quality:review'], + skills: ['qa-verification'], + adapters: ['shell-worker'], + }, + createPlanStepIfMissing: true, + recoverStaleClaims: true, + }, + ); + + await runCliJson( + repoRoot, + ['plan-step', 'progress', reviewerStepPath, '100', '-w', workspacePath, '--actor', args.reviewerActor, '--json'], + { env: toApiKeyEnv(args.reviewerApiKey) }, + ); + await runCliJson( + repoRoot, + ['plan-step', 'done', reviewerStepPath, '-w', workspacePath, '--actor', args.reviewerActor, '--json'], + { env: toApiKeyEnv(args.reviewerApiKey) }, + ); + await runCliJson( + repoRoot, + [ + 'thread', + 'done', + reviewerThreadPath, + '-w', + workspacePath, + '--actor', + args.reviewerActor, + '--output', + 'QA sign-off completed with green checks https://github.com/versatly/workgraph/pull/obj-09-qa', + '--json', + ], + { env: toApiKeyEnv(args.reviewerApiKey) }, + ); + await runCliJson( + repoRoot, + [ + 'conversation', + 'message', + conversationPath, + 'All coordination plan-steps completed by intake, builder, and reviewer agents.', + '-w', + workspacePath, + '--actor', + args.adminActor, + '--kind', + 'decision', + '--thread', + reviewerThreadPath, + '--json', + ], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + + const conversationState = await runCliJson( + repoRoot, + ['conversation', 'state', conversationPath, '-w', workspacePath, '--json'], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + const readyThreads = await runCliJson( + repoRoot, + ['thread', 'list', '-w', workspacePath, '--ready', '--json'], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + + const output = { + workspacePath, + conversationPath, + threadPaths: { + intakeThreadPath, + builderThreadPath, + reviewerThreadPath, + }, + planStepPaths: { + intakeStepPath, + builderStepPath, + reviewerStepPath, + }, + selfAssembly: { + agentName: selfAssembly.agentName, + claimedThreadPath: selfAssembly.claimedThread?.path, + planStepPath: selfAssembly.planStep?.path, + warnings: selfAssembly.warnings, + }, + conversationSummary: conversationState.data.summary, + readyThreadCount: Number(readyThreads.data.count ?? 0), + actorApiKeys: apiKeyEnvByActor, + }; + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +} + +function parseArgs(args) { + const parsed = { + workspacePath: '', + adminActor: 'governance-admin', + intakeActor: 'agent-intake', + builderActor: 'agent-builder', + reviewerActor: 'agent-reviewer', + adminApiKey: '', + intakeApiKey: '', + builderApiKey: '', + reviewerApiKey: '', + skipBuild: false, + json: false, + }; + for (let idx = 0; idx < args.length; idx += 1) { + const arg = String(args[idx] ?? ''); + if ((arg === '--workspace' || arg === '-w') && idx + 1 < args.length) { + parsed.workspacePath = String(args[idx + 1]); + idx += 1; + continue; + } + if (arg === '--admin-api-key' && idx + 1 < args.length) { + parsed.adminApiKey = String(args[idx + 1]); + idx += 1; + continue; + } + if (arg === '--intake-api-key' && idx + 1 < args.length) { + parsed.intakeApiKey = String(args[idx + 1]); + idx += 1; + continue; + } + if (arg === '--builder-api-key' && idx + 1 < args.length) { + parsed.builderApiKey = String(args[idx + 1]); + idx += 1; + continue; + } + if (arg === '--reviewer-api-key' && idx + 1 < args.length) { + parsed.reviewerApiKey = String(args[idx + 1]); + idx += 1; + continue; + } + if (arg === '--skip-build') { + parsed.skipBuild = true; + continue; + } + if (arg === '--json') { + parsed.json = true; + } + } + return parsed; +} + +function toApiKeyEnv(apiKey) { + if (!apiKey) return undefined; + return { WORKGRAPH_API_KEY: apiKey }; +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exit(1); +}); diff --git a/examples/multi-agent-showcase/scripts/03-trigger-loop.mjs b/examples/multi-agent-showcase/scripts/03-trigger-loop.mjs new file mode 100755 index 0000000..654e391 --- /dev/null +++ b/examples/multi-agent-showcase/scripts/03-trigger-loop.mjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node + +import { + ensureBuild, + loadSdk, + logLine, + resolveRepoRoot, + runCliJson, +} from './lib/demo-utils.mjs'; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.workspacePath) { + throw new Error('Missing required --workspace argument.'); + } + + const repoRoot = resolveRepoRoot(import.meta.url); + if (!args.skipBuild) { + logLine('building dist artifacts', args.json); + await ensureBuild(repoRoot); + } + const sdk = await loadSdk(repoRoot); + const workspacePath = args.workspacePath; + + logLine('creating active trigger for thread-complete events', args.json); + const shellCommand = `"${process.execPath}" -e "console.log('obj09-trigger-ok'); console.log('https://github.com/versatly/workgraph/pull/obj-09-trigger');"`; + const trigger = sdk.store.create( + workspacePath, + 'trigger', + { + title: 'OBJ-09 thread completion trigger', + status: 'active', + condition: { + type: 'event', + event: 'thread-complete', + }, + action: { + type: 'dispatch-run', + objective: 'React to completed thread {{matched_event_latest_target}}', + adapter: 'shell-worker', + context: { + shell_command: shellCommand, + }, + }, + cooldown: 0, + tags: ['obj-09', 'trigger'], + }, + '# OBJ-09 Trigger\n\nDispatches a shell-worker run after thread completion events.\n', + args.adminActor, + ); + + // First cycle initializes event cursor for deterministic behavior. + await runCliJson( + repoRoot, + [ + 'trigger', + 'engine', + 'run', + '-w', + workspacePath, + '--actor', + args.adminActor, + '--execute-runs', + '--agents', + `${args.intakeActor},${args.builderActor},${args.reviewerActor}`, + '--max-steps', + '40', + '--step-delay-ms', + '0', + '--timeout-ms', + '30000', + '--json', + ], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + + logLine('creating a source thread and completing it', args.json); + const sourceThread = await runCliJson( + repoRoot, + [ + 'thread', + 'create', + 'OBJ-09 trigger source', + '-w', + workspacePath, + '--goal', + 'Emit one completion event for trigger execution', + '--actor', + args.adminActor, + '--priority', + 'high', + '--tags', + 'obj-09,trigger-source', + '--json', + ], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + const sourceThreadPath = String(sourceThread.data.thread.path); + + await runCliJson( + repoRoot, + ['thread', 'claim', sourceThreadPath, '-w', workspacePath, '--actor', args.intakeActor, '--json'], + { env: toApiKeyEnv(args.intakeApiKey) }, + ); + await runCliJson( + repoRoot, + [ + 'thread', + 'done', + sourceThreadPath, + '-w', + workspacePath, + '--actor', + args.intakeActor, + '--output', + 'Trigger source completed for OBJ-09 evidence loop https://github.com/versatly/workgraph/pull/obj-09-trigger-source', + '--json', + ], + { env: toApiKeyEnv(args.intakeApiKey) }, + ); + + logLine('running trigger-run-evidence loop', args.json); + const secondCycle = await runCliJson( + repoRoot, + [ + 'trigger', + 'engine', + 'run', + '-w', + workspacePath, + '--actor', + args.adminActor, + '--execute-runs', + '--agents', + `${args.intakeActor},${args.builderActor},${args.reviewerActor}`, + '--max-steps', + '40', + '--step-delay-ms', + '0', + '--timeout-ms', + '30000', + '--json', + ], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + + const executedRuns = Array.isArray(secondCycle.data.executedRuns) ? secondCycle.data.executedRuns : []; + const triggeredRun = executedRuns[0]; + if (!triggeredRun || !triggeredRun.runId) { + throw new Error('Expected at least one executed run from trigger engine.'); + } + const runId = String(triggeredRun.runId); + + const runStatus = await runCliJson( + repoRoot, + ['dispatch', 'status', runId, '-w', workspacePath, '--json'], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + const runLogs = await runCliJson( + repoRoot, + ['dispatch', 'logs', runId, '-w', workspacePath, '--json'], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + const ledgerSnapshot = await runCliJson( + repoRoot, + ['ledger', 'show', '-w', workspacePath, '--count', '20', '--json'], + { env: toApiKeyEnv(args.adminApiKey) }, + ); + + const output = { + workspacePath, + triggerPath: trigger.path, + sourceThreadPath, + triggerLoop: { + runId, + status: String(runStatus.data.run.status), + evidenceCount: Number(runStatus.data.run?.evidenceChain?.count ?? 0), + logEntries: Array.isArray(runLogs.data.logs) ? runLogs.data.logs.length : 0, + cycleFired: Number(secondCycle.data.cycle?.fired ?? 0), + }, + ledgerSnapshotCount: Number(ledgerSnapshot.data.count ?? 0), + }; + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +} + +function parseArgs(args) { + const parsed = { + workspacePath: '', + adminActor: 'governance-admin', + intakeActor: 'agent-intake', + builderActor: 'agent-builder', + reviewerActor: 'agent-reviewer', + adminApiKey: '', + intakeApiKey: '', + builderApiKey: '', + reviewerApiKey: '', + skipBuild: false, + json: false, + }; + for (let idx = 0; idx < args.length; idx += 1) { + const arg = String(args[idx] ?? ''); + if ((arg === '--workspace' || arg === '-w') && idx + 1 < args.length) { + parsed.workspacePath = String(args[idx + 1]); + idx += 1; + continue; + } + if (arg === '--admin-api-key' && idx + 1 < args.length) { + parsed.adminApiKey = String(args[idx + 1]); + idx += 1; + continue; + } + if (arg === '--intake-api-key' && idx + 1 < args.length) { + parsed.intakeApiKey = String(args[idx + 1]); + idx += 1; + continue; + } + if (arg === '--builder-api-key' && idx + 1 < args.length) { + parsed.builderApiKey = String(args[idx + 1]); + idx += 1; + continue; + } + if (arg === '--reviewer-api-key' && idx + 1 < args.length) { + parsed.reviewerApiKey = String(args[idx + 1]); + idx += 1; + continue; + } + if (arg === '--skip-build') { + parsed.skipBuild = true; + continue; + } + if (arg === '--json') { + parsed.json = true; + } + } + return parsed; +} + +function toApiKeyEnv(apiKey) { + if (!apiKey) return undefined; + return { WORKGRAPH_API_KEY: apiKey }; +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exit(1); +}); diff --git a/examples/multi-agent-showcase/scripts/lib/demo-utils.mjs b/examples/multi-agent-showcase/scripts/lib/demo-utils.mjs new file mode 100644 index 0000000..8ea5e9f --- /dev/null +++ b/examples/multi-agent-showcase/scripts/lib/demo-utils.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const execFileAsync = promisify(execFile); + +export function resolveRepoRoot(fromImportMetaUrl) { + let current = path.resolve(path.dirname(fileURLToPath(fromImportMetaUrl))); + for (let depth = 0; depth < 8; depth += 1) { + const pkgPath = path.join(current, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + if (pkg && pkg.name === '@versatly/workgraph') { + return current; + } + } catch { + // Keep traversing upward. + } + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + throw new Error('Unable to resolve WorkGraph repository root from showcase script location.'); +} + +export function resolveWorkspace(args) { + const parsed = parseArgs(args); + if (parsed.workspace) { + return { + workspacePath: path.resolve(parsed.workspace), + providedByUser: true, + json: parsed.json, + skipBuild: parsed.skipBuild, + }; + } + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'workgraph-obj09-showcase-')); + return { + workspacePath, + providedByUser: false, + json: parsed.json, + skipBuild: parsed.skipBuild, + }; +} + +export async function runCliJson(repoRoot, args, options = {}) { + const cliPath = path.join(repoRoot, 'bin', 'workgraph.js'); + const fullArgs = args.includes('--json') ? [...args] : [...args, '--json']; + const env = { + ...process.env, + ...(options.env ?? {}), + }; + const { stdout, stderr } = await execFileAsync('node', [cliPath, ...fullArgs], { + cwd: repoRoot, + env, + maxBuffer: 10 * 1024 * 1024, + }); + const output = String(stdout ?? '').trim(); + let parsed; + try { + parsed = JSON.parse(output); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`CLI output was not valid JSON (${fullArgs.join(' ')}): ${detail}\n${output}`); + } + if (!parsed || parsed.ok !== true) { + const rendered = JSON.stringify(parsed, null, 2); + const err = String(stderr ?? '').trim(); + throw new Error(`CLI command failed (${fullArgs.join(' ')}): ${rendered}${err ? `\n${err}` : ''}`); + } + return parsed; +} + +export async function ensureBuild(repoRoot) { + const distCli = path.join(repoRoot, 'dist', 'cli.js'); + const distIndex = path.join(repoRoot, 'dist', 'index.js'); + if (fs.existsSync(distCli) && fs.existsSync(distIndex)) { + return; + } + await execFileAsync(resolvePnpmCommand(), ['run', 'build'], { + cwd: repoRoot, + env: process.env, + maxBuffer: 20 * 1024 * 1024, + }); +} + +export async function loadSdk(repoRoot) { + const sdkUrl = pathToFileURL(path.join(repoRoot, 'dist', 'index.js')).href; + return import(sdkUrl); +} + +export function logLine(message, jsonMode) { + if (!jsonMode) { + process.stderr.write(`${message}\n`); + } +} + +function parseArgs(args) { + const parsed = { + workspace: '', + json: false, + skipBuild: false, + }; + for (let idx = 0; idx < args.length; idx += 1) { + const arg = String(args[idx] ?? ''); + if ((arg === '--workspace' || arg === '-w') && idx + 1 < args.length) { + parsed.workspace = String(args[idx + 1]); + idx += 1; + continue; + } + if (arg === '--json') { + parsed.json = true; + continue; + } + if (arg === '--skip-build') { + parsed.skipBuild = true; + } + } + return parsed; +} + +function resolvePnpmCommand() { + return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +} diff --git a/examples/multi-agent-showcase/scripts/run-showcase.mjs b/examples/multi-agent-showcase/scripts/run-showcase.mjs new file mode 100755 index 0000000..10bcaa8 --- /dev/null +++ b/examples/multi-agent-showcase/scripts/run-showcase.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node + +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { + ensureBuild, + logLine, + resolveRepoRoot, + resolveWorkspace, + runCliJson, +} from './lib/demo-utils.mjs'; + +const execFileAsync = promisify(execFile); + +async function main() { + const repoRoot = resolveRepoRoot(import.meta.url); + const resolved = resolveWorkspace(process.argv.slice(2)); + const workspacePath = resolved.workspacePath; + + if (!resolved.skipBuild) { + logLine('building dist artifacts', resolved.json); + await ensureBuild(repoRoot); + } + + const scriptDir = path.resolve(path.dirname(fileURLToPath(import.meta.url))); + logLine('phase 1/3: governance and registration', resolved.json); + const governance = await runScriptJson(scriptDir, '01-governance.mjs', [ + '--workspace', + workspacePath, + '--json', + ...(resolved.skipBuild ? ['--skip-build'] : []), + ]); + + const approvalByAgent = new Map(); + for (const approval of governance.approvals ?? []) { + approvalByAgent.set(String(approval.agent), String(approval.apiKey ?? '')); + } + + logLine('phase 2/3: collaborative execution with self-assembly', resolved.json); + const collaboration = await runScriptJson(scriptDir, '02-collaboration.mjs', [ + '--workspace', + workspacePath, + '--admin-api-key', + String(governance.admin?.apiKey ?? ''), + '--intake-api-key', + String(approvalByAgent.get('agent-intake') ?? ''), + '--builder-api-key', + String(approvalByAgent.get('agent-builder') ?? ''), + '--reviewer-api-key', + String(approvalByAgent.get('agent-reviewer') ?? ''), + '--json', + ...(resolved.skipBuild ? ['--skip-build'] : []), + ]); + + logLine('phase 3/3: trigger -> run -> evidence loop', resolved.json); + const triggerLoop = await runScriptJson(scriptDir, '03-trigger-loop.mjs', [ + '--workspace', + workspacePath, + '--admin-api-key', + String(governance.admin?.apiKey ?? ''), + '--intake-api-key', + String(approvalByAgent.get('agent-intake') ?? ''), + '--builder-api-key', + String(approvalByAgent.get('agent-builder') ?? ''), + '--reviewer-api-key', + String(approvalByAgent.get('agent-reviewer') ?? ''), + '--json', + ...(resolved.skipBuild ? ['--skip-build'] : []), + ]); + + const threadList = await runCliJson( + repoRoot, + ['thread', 'list', '-w', workspacePath, '--json'], + { + env: governance.admin?.apiKey ? { WORKGRAPH_API_KEY: String(governance.admin.apiKey) } : undefined, + }, + ); + const dispatchRuns = await runCliJson( + repoRoot, + ['dispatch', 'list', '-w', workspacePath, '--json'], + { + env: governance.admin?.apiKey ? { WORKGRAPH_API_KEY: String(governance.admin.apiKey) } : undefined, + }, + ); + const ledgerRecent = await runCliJson( + repoRoot, + ['ledger', 'show', '-w', workspacePath, '--count', '25', '--json'], + { + env: governance.admin?.apiKey ? { WORKGRAPH_API_KEY: String(governance.admin.apiKey) } : undefined, + }, + ); + + const demoChecks = { + governance: Number(governance.governanceSnapshot?.agentCount ?? 0) >= 4, + selfAssemblyClaimedReviewerThread: + String(collaboration.selfAssembly?.claimedThreadPath ?? '') === String(collaboration.threadPaths?.reviewerThreadPath ?? ''), + planStepCoordinated: + String(collaboration.selfAssembly?.planStepPath ?? '') === String(collaboration.planStepPaths?.reviewerStepPath ?? ''), + triggerRunEvidence: + String(triggerLoop.triggerLoop?.status ?? '') === 'succeeded' + && Number(triggerLoop.triggerLoop?.evidenceCount ?? 0) > 0, + ledgerActivity: + Number(triggerLoop.ledgerSnapshotCount ?? 0) > 0, + }; + const pass = Object.values(demoChecks).every(Boolean); + + const output = { + ok: pass, + workspacePath, + providedWorkspacePath: resolved.providedByUser, + checks: demoChecks, + phases: { + governance, + collaboration, + triggerLoop, + }, + rollup: { + threadCount: Number(threadList.data.count ?? 0), + runCount: Array.isArray(dispatchRuns.data.runs) ? dispatchRuns.data.runs.length : 0, + ledgerEntryCount: Number(ledgerRecent.data.count ?? 0), + }, + }; + + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + if (!pass) { + process.exitCode = 1; + } +} + +async function runScriptJson(scriptDir, scriptName, args) { + const scriptPath = path.join(scriptDir, scriptName); + const { stdout, stderr } = await execFileAsync('node', [scriptPath, ...args], { + maxBuffer: 10 * 1024 * 1024, + env: process.env, + }); + const output = String(stdout ?? '').trim(); + try { + return JSON.parse(output); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Script ${scriptName} did not emit valid JSON: ${detail}\nstdout:\n${output}\nstderr:\n${String(stderr ?? '')}`); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exit(1); +}); diff --git a/tests/integration/multi-agent-showcase.test.ts b/tests/integration/multi-agent-showcase.test.ts new file mode 100644 index 0000000..dac6792 --- /dev/null +++ b/tests/integration/multi-agent-showcase.test.ts @@ -0,0 +1,61 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { ensureCliBuiltForTests } from '../helpers/cli-build.js'; + +describe('OBJ-09 multi-agent showcase', () => { + beforeAll(() => { + ensureCliBuiltForTests(); + }); + + it('runs end-to-end from a fresh workspace', () => { + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-obj09-showcase-')); + try { + const result = spawnSync( + 'bash', + [ + path.resolve('examples/multi-agent-showcase/run.sh'), + '--workspace', + workspacePath, + '--skip-build', + '--json', + ], + { + encoding: 'utf-8', + cwd: path.resolve('.'), + env: process.env, + }, + ); + expect(result.status).toBe(0); + + const output = String(result.stdout ?? '').trim(); + let parsed: { + ok: boolean; + checks: Record; + rollup: { threadCount: number; runCount: number; ledgerEntryCount: number }; + }; + try { + parsed = JSON.parse(output) as typeof parsed; + } catch { + throw new Error(`Showcase output was not valid JSON:\n${output}`); + } + + expect(parsed.ok).toBe(true); + expect(parsed.checks.governance).toBe(true); + expect(parsed.checks.selfAssemblyClaimedReviewerThread).toBe(true); + expect(parsed.checks.planStepCoordinated).toBe(true); + expect(parsed.checks.triggerRunEvidence).toBe(true); + expect(parsed.checks.ledgerActivity).toBe(true); + expect(parsed.rollup.threadCount).toBeGreaterThanOrEqual(4); + expect(parsed.rollup.runCount).toBeGreaterThanOrEqual(1); + expect(parsed.rollup.ledgerEntryCount).toBeGreaterThan(0); + + expect(fs.existsSync(path.join(workspacePath, '.workgraph', 'ledger.jsonl'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'threads'))).toBe(true); + } finally { + fs.rmSync(workspacePath, { recursive: true, force: true }); + } + }); +});