diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index 22dfc15..1d2bd7c 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -1,4 +1,4 @@ -name: Windows smoke +name: Windows beta gate on: push: diff --git a/.gitignore b/.gitignore index 44f9ed1..536a5b3 100644 --- a/.gitignore +++ b/.gitignore @@ -96,7 +96,4 @@ examples/ # Local-only publishing and planning documents .npmrc -MVP2_BETA_TEST_PLAN.md -MVP2_BETA_TEST_PLAN.zh-CN.md -OPTIMIZATION_PLAN.md -COMMAND_REFACTOR_*.md +CONTEXT_TEAM_REFACTOR_PLAN.md diff --git a/docs/12-lifecycle.md b/docs/12-lifecycle.md index 1b42ab9..aea6818 100644 --- a/docs/12-lifecycle.md +++ b/docs/12-lifecycle.md @@ -214,7 +214,15 @@ review policy v2 只允许 `clarification` 和用户明确要求跳过的整个 Windows CI 会在同一个 `windows-latest` runner 上分别从 CMD、PowerShell 和 Git Bash 执行 smoke test。测试清空子进程 PATH 来模拟 Git/Bash 不可用,验证 Codex -初始化、solo 降级、Claude Code 初始化和两个 Node hooks 均能完成。 +初始化、solo 降级、Claude Code 初始化和两个 Node hooks 均能完成。它还会让 +PowerShell 以不允许删除的共享模式实际打开 V3 session evidence 文件,再运行一次 +`context session spike`;原子替换必须在句柄释放后自行重试并成功。该 job 名为 +`Windows beta gate`,应作为 Beta 发布的必需 CI 检查。 + +V3 Beta 还必须在目标项目执行 `mancode context beta --json`。该命令会拒绝 +未激活或不兼容的 V3 authority、未安装的 V3 bootstrap、未完成的 repair journal、 +缺失 checkout binding,以及没有五个平台真实双窗口与子进程传播证据的项目。session +evidence 文件不保存宿主 session key;只有在每个平台宿主中完成 spike 后才能通过。 --- diff --git a/package-lock.json b/package-lock.json index ac80867..ce02795 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.3.9", "license": "AGPL-3.0-only", "dependencies": { - "commander": "^12.1.0" + "commander": "^12.1.0", + "js-tiktoken": "^1.0.21" }, "bin": { "mancode": "dist/cli.js" @@ -1396,6 +1397,26 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", @@ -1881,6 +1902,15 @@ "node": ">=10" } }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", diff --git a/package.json b/package.json index 79f00ef..1edf11e 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,8 @@ "prepublishOnly": "npm run lint && npm run typecheck && npm run build && npm test" }, "dependencies": { - "commander": "^12.1.0" + "commander": "^12.1.0", + "js-tiktoken": "^1.0.21" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/scripts/windows-smoke.mjs b/scripts/windows-smoke.mjs index 8a29e6d..3c28ff2 100644 --- a/scripts/windows-smoke.mjs +++ b/scripts/windows-smoke.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -119,6 +119,8 @@ try { JSON.stringify({ prompt: 'update README' }), ); + await assertV3SessionEvidenceRenameUnderOpenWindowsHandle(); + console.log('Windows shell smoke passed.'); } finally { await rm(root, { recursive: true, force: true }); @@ -183,6 +185,150 @@ function runHook(cwd, fileName, input = '') { ); } +async function assertV3SessionEvidenceRenameUnderOpenWindowsHandle() { + if (process.platform !== 'win32') { + console.log('Skipping Windows open-file rename assertion outside Windows.'); + return; + } + const project = await createProject('v3-open-file-rename-project'); + const spikeEnv = { + ...noToolPath, + MANCODE_SPIKE_HOST_SESSION_KEY: 'windows-smoke-host-a', + MANCODE_SPIKE_SECOND_WINDOW_HOST_SESSION_KEY: 'windows-smoke-host-b', + }; + runCli(project, ['init', '--v3'], spikeEnv); + const spikeArgs = [ + 'context', + 'session', + 'spike', + '--platform', + 'codex', + '--host-session-source', + 'api', + ]; + runCli(project, spikeArgs, spikeEnv); + const evidenceTarget = path.join( + project, + '.mancode', + 'local', + 'evidence', + 'platform-session', + 'codex.json', + ); + await assertReplaceAfterOpenWindowsHandle(evidenceTarget, () => + runCli(project, spikeArgs, spikeEnv), + ); + const evidence = await readFile(evidenceTarget, 'utf8'); + assert( + evidence.includes('"platform": "codex"'), + 'V3 session evidence was not atomically replaced after the Windows handle closed', + ); + + runCli(project, ['team', 'identity', 'create', '--name', 'Windows Smoke']); + const sessionResult = runCli(project, [ + 'context', + 'session', + 'new', + '--client', + 'windows-smoke', + ]); + const sessionId = JSON.parse(sessionResult.stdout).session.sessionId; + const workflowResult = runCli(project, [ + 'workflow', + 'create', + 'man', + 'Exercise a locked Windows session authority replacement.', + '--session', + sessionId, + '--client', + 'windows-smoke', + ]); + const taskRef = JSON.parse(workflowResult.stdout).taskRef; + const sessionTarget = path.join( + project, + '.mancode', + 'local', + 'sessions', + `${sessionId}.json`, + ); + await assertReplaceAfterOpenWindowsHandle(sessionTarget, () => + runCli(project, [ + 'context', + 'resume', + `${taskRef.namespace}:${taskRef.taskId}`, + '--session', + sessionId, + '--client', + 'windows-smoke', + ]), + ); + const session = await readJson(sessionTarget); + assert( + session.activeTaskRef?.taskId === taskRef.taskId, + 'V3 session authority was not atomically replaced after the Windows handle closed', + ); +} + +async function assertReplaceAfterOpenWindowsHandle(target, replace) { + const lock = spawn( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + '$path = [Environment]::GetEnvironmentVariable("MANCODE_RENAME_TARGET"); $stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read); Write-Output "locked"; Start-Sleep -Milliseconds 500; $stream.Dispose()', + ], + { + env: { ...process.env, MANCODE_RENAME_TARGET: target }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + const lockExit = waitForExit(lock); + try { + await waitForLock(lock); + replace(); + } finally { + await lockExit; + } +} + +function waitForLock(child) { + return new Promise((resolve, reject) => { + let stderr = ''; + const timer = setTimeout(() => { + reject(new Error(`timed out waiting for Windows file lock: ${stderr}`)); + }, 5_000); + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + child.stdout.on('data', (chunk) => { + if (String(chunk).includes('locked')) { + clearTimeout(timer); + resolve(); + } + }); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('exit', (code) => { + clearTimeout(timer); + reject(new Error(`Windows file lock exited early (${code}): ${stderr}`)); + }); + }); +} + +function waitForExit(child) { + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`Windows file lock failed with exit code ${code}`)); + }); + }); +} + async function readJson(filePath) { return JSON.parse(await readFile(filePath, 'utf8')); } diff --git a/src/cli.ts b/src/cli.ts index 6ee431f..2ae6a78 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,12 +1,56 @@ #!/usr/bin/env node import { program } from 'commander'; +import { + contextBeta, + contextClose, + contextCompact, + contextDiagnostics, + contextDoctor, + contextPublish, + contextReconcileTaskHead, + contextResume, + contextSessionNew, + contextSessionSpike, + contextShow, + contextWorktreeRegister, +} from './commands/context.js'; import { init } from './commands/init.js'; import { install } from './commands/install.js'; import { listPlatforms } from './commands/list-platforms.js'; import { manps } from './commands/manps.js'; +import { migrateContext, migrateContextResolve } from './commands/migrate.js'; +import { + operationAbort, + operationRepair, + operationShow, +} from './commands/operation.js'; import { refreshProject } from './commands/refresh-project.js'; import { refreshStyle } from './commands/refresh-style.js'; import { status } from './commands/status.js'; +import { + teamCheckpoint, + teamClaim, + teamClaimReclaim, + teamClaimRelease, + teamClaimRenew, + teamClaimRevalidate, + teamClaimTransfer, + teamConflicts, + teamDecisionPublish, + teamHandoffAccept, + teamHandoffCancel, + teamHandoffDraft, + teamHandoffOffer, + teamHandoffReject, + teamIdentityCreate, + teamIdentityShow, + teamJoin, + teamStatus, + teamSyncPull, + teamSyncPush, + teamTransportMigrate, + teamTransportRecover, +} from './commands/team.js'; import { uninstall } from './commands/uninstall.js'; import { version } from './commands/version.js'; import { workflow } from './commands/workflow.js'; @@ -29,6 +73,7 @@ program .option('--style ', 'Specify aesthetic style (MVP-2)') .option('--platform ', 'Adapters: comma-separated names or all') .option('--empty', 'Initialize a safe empty directory as a generic project') + .option('--v3', 'Use the journaled V3 greenfield initializer') .option('--lang ', 'Initialization language: zh-CN or en') .action(async (options) => { const code = await init(process.cwd(), { @@ -45,6 +90,10 @@ program ) .option('--force', 'Reinstall even if already installed') .option('--minimal', 'Minimal install (MVP-2)') + .option( + '--shadow', + 'Stage a V3 bootstrap candidate without changing live files', + ) .action(async (platform, options) => { const code = await install( process.cwd(), @@ -92,8 +141,28 @@ program '--parent-task ', 'Parent /man or /manteam workflow for manba', ) + .option('--parent ', 'V3 parent TaskRef for a manba child') + .option( + '--participant ', + 'Invite a joined team participant', + collectOption, + [], + ) + .option('--visibility ', 'V3: local or shared') + .option('--coordination ', 'V3: single or team') + .option('--session ', 'V3 session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'V3 client identity (default: mancode-cli)') + .option('--expected-revision ', 'V3 expected task revision for mutations') + .option('--child-revision ', 'V3 expected child task revision for merge') + .option('--summary ', 'Privacy-screened child result summary') + .option('--next-action ', 'Next parent action after a child merge') + .option('--sync', 'Use git-ref transport when available (P2; unavailable)') + .option( + '--confirm-shared', + 'Confirm that task metadata may enter shared V3 authority', + ) .option('--blocking-reason ', 'Explain why a workflow is blocked') - .option('--outcome ', 'Set manba outcome') + .option('--outcome ', 'Set manba outcome when completing a V3 task') .option('--plan-version ', 'Set the next man/manteam plan revision') .option( '--requirements-status ', @@ -131,7 +200,630 @@ program .option('--reason ', 'Reason for an explicit review skip') .option('--json', 'Output as JSON (for scripts)') .action(async (subcommand, args, options) => { - const code = await workflow(process.cwd(), subcommand, args ?? [], options); + const code = await workflow(process.cwd(), subcommand, args ?? [], { + ...options, + participants: + options.participant.length === 0 ? undefined : options.participant, + }); + process.exitCode = code; + }); + +const contextProgram = program + .command('context') + .description('Resolve V3 task context and manage explicit sessions'); + +contextProgram + .command('show') + .description('Resolve one V3 Context Pack') + .option('--task ', 'Explicit TaskRef') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--level ', 'bootstrap, task, or full') + .option( + '--purpose ', + 'orient, plan, implement, review, verify, or handoff', + ) + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await contextShow(process.cwd(), options); + }); + +const contextSessionProgram = contextProgram + .command('session') + .description('Manage V3 session identities'); + +contextSessionProgram + .command('new') + .description('Create an explicit bootstrap session') + .requiredOption('--client ', 'Client identity') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await contextSessionNew(process.cwd(), options); + }); + +contextSessionProgram + .command('spike') + .description('Record real-host session evidence without persisting host keys') + .requiredOption( + '--platform ', + 'claude-code, codex, cursor, copilot, or zcode', + ) + .requiredOption( + '--host-session-source ', + 'hook_stdin, environment, or api', + ) + .option( + '--subagent-inheritance ', + 'proven, not_proven, not_tested, or not_applicable', + ) + .option( + '--hook-approval ', + 'approved, unapproved, unknown, or not_applicable', + ) + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await contextSessionSpike(process.cwd(), options); + }); + +contextProgram + .command('resume ') + .description('Validate and bind the current session to a V3 TaskRef') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through git-ref transport (P2; unavailable)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (task, options) => { + process.exitCode = await contextResume(process.cwd(), task, options); + }); + +contextProgram + .command('close') + .description('Close one explicit session without affecting other sessions') + .requiredOption('--session ', 'Session ID') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await contextClose(process.cwd(), options); + }); + +contextProgram + .command('doctor') + .description('Inspect unfinished V3 operations or repair one explicitly') + .option( + '--repair ', + 'Repair this operation with its original session', + ) + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await contextDoctor(process.cwd(), options); + }); + +contextProgram + .command('diagnostics [action]') + .description('Show or configure local aggregate diagnostics') + .option('--json', 'Output as JSON (for scripts)') + .action(async (action, options) => { + process.exitCode = await contextDiagnostics(process.cwd(), action, options); + }); + +contextProgram + .command('compact') + .description('List and remove eligible V3 runtime retention candidates') + .option('--task ', 'Compact checkpoints for one completed task') + .option('--dry-run', 'Show the deletion list without changing files') + .option('--apply-shared', 'Permit deletion for shared completed tasks') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await contextCompact(process.cwd(), options); + }); + +contextProgram + .command('beta') + .description('Evaluate hard V3 Beta readiness gates') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await contextBeta(process.cwd(), options); + }); + +contextProgram + .command('publish ') + .description('Create a privacy-screened shared man successor') + .requiredOption('--expected-revision ', 'Current local task revision') + .requiredOption( + '--confirm-shared', + 'Confirm that the screened task authority may enter shared storage', + ) + .option('--dry-run', 'Validate the publish preflight without writing') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (task, options) => { + process.exitCode = await contextPublish(process.cwd(), task, options); + }); + +contextProgram + .command('reconcile-task-head ') + .description( + 'Adopt a Git-sourced shared aggregate through an explicit fence CAS', + ) + .requiredOption( + '--expected-fence-revision ', + 'Current shared task-head fence revision', + ) + .requiredOption( + '--from-git', + 'Confirm the checked-out aggregate came from Git', + ) + .option('--dry-run', 'Validate adoption without changing the task-head fence') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (task, options) => { + process.exitCode = await contextReconcileTaskHead( + process.cwd(), + task, + options, + ); + }); + +const contextWorktreeProgram = contextProgram + .command('worktree') + .description('Register and inspect the current V3 checkout binding'); + +contextWorktreeProgram + .command('register') + .description('Register this linked worktree before using V3 coordination') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await contextWorktreeRegister(process.cwd(), options); + }); + +const operationProgram = program + .command('operation') + .description('Inspect and recover durable V3 operations'); + +operationProgram + .command('show ') + .description('Show one operation journal and its recovery disposition') + .option('--json', 'Output as JSON (for scripts)') + .action(async (operationId, options) => { + process.exitCode = await operationShow(process.cwd(), operationId, options); + }); + +operationProgram + .command('repair ') + .description('Repair an operation using its original actor and session') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (operationId, options) => { + process.exitCode = await operationRepair( + process.cwd(), + operationId, + options, + ); + }); + +operationProgram + .command('abort ') + .description( + 'Abort only an operation proven to have no visible business write', + ) + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (operationId, options) => { + process.exitCode = await operationAbort( + process.cwd(), + operationId, + options, + ); + }); + +const teamProgram = program + .command('team') + .description('Manage V3 local identity and local-team membership'); + +teamProgram + .command('status') + .description('Show V3 team policy, transport, and local identity state') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await teamStatus(process.cwd(), options); + }); + +teamProgram + .command('conflicts') + .description( + 'Inspect local claim conflicts and handoffs without mutating coordination', + ) + .option('--task ', 'Narrow the report to one shared TaskRef') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await teamConflicts(process.cwd(), options); + }); + +const teamTransportProgram = teamProgram + .command('transport') + .description('Inspect and migrate the coordination authority'); + +teamTransportProgram + .command('migrate') + .description('Journal a single-authority local/git-ref transport switch') + .requiredOption('--to ', 'Target authority: local or git-ref') + .requiredOption( + '--expected-config-revision ', + 'Current project config revision', + ) + .option( + '--remote ', + 'Git remote for a git-ref target (default: origin)', + ) + .option('--dry-run', 'Validate and preview without writing authority state') + .option('--confirm', 'Explicitly confirm the authority migration') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await teamTransportMigrate(process.cwd(), options); + }); + +teamTransportProgram + .command('recover ') + .description('Repair forward or safely abort a transport migration') + .requiredOption('--to ', 'Original target authority: local or git-ref') + .option('--remote ', 'Original Git remote for a git-ref target') + .option('--abort', 'Abort only before the target authority is established') + .option('--session ', 'Original migration session ID') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (operationId, options) => { + process.exitCode = await teamTransportRecover( + process.cwd(), + operationId, + options, + ); + }); + +const teamSyncProgram = teamProgram + .command('sync') + .description('Explicitly synchronize the git-ref coordination authority'); + +teamSyncProgram + .command('pull') + .description('Fetch, validate, and cache refs/mancode/team') + .option('--task ', 'Narrow output to one shared TaskRef') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await teamSyncPull(process.cwd(), options); + }); + +teamSyncProgram + .command('push ') + .description('Publish one task bundle through a fresh ownership fence CAS') + .requiredOption( + '--expected-task-revision ', + 'Current shared task revision', + ) + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (task, options) => { + process.exitCode = await teamSyncPush(process.cwd(), { + ...options, + task, + }); + }); + +const teamIdentityProgram = teamProgram + .command('identity') + .description('Manage the machine-local actor identity'); + +teamIdentityProgram + .command('create') + .description('Create one local actor identity') + .requiredOption('--name ', 'Display name') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await teamIdentityCreate(process.cwd(), options); + }); + +teamIdentityProgram + .command('show') + .description('Show local identity and whether it is joined') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await teamIdentityShow(process.cwd(), options); + }); + +const teamDecisionProgram = teamProgram + .command('decision') + .description('Publish explicitly confirmed, privacy-safe shared decisions'); + +teamDecisionProgram + .command('publish') + .description('Publish one immutable confirmed decision') + .requiredOption('--title ', 'Short decision title') + .requiredOption('--statement ', 'Confirmed decision statement') + .option('--task ', 'Optional shared TaskRef that produced it') + .requiredOption('--confirm', 'Confirm this decision may enter shared memory') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await teamDecisionPublish(process.cwd(), options); + }); + +teamProgram + .command('join') + .description( + 'Publish the approved shared actor profile after explicit confirmation', + ) + .requiredOption('--name ', 'Must match the local actor identity') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Request explicit remote sync when transport supports it') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + process.exitCode = await teamJoin(process.cwd(), options); + }); + +teamProgram + .command('checkpoint ') + .description('Create a journaled immutable checkpoint for a shared V3 task') + .requiredOption( + '--expected-task-revision ', + 'Current shared task revision', + ) + .requiredOption('--kind ', 'Checkpoint kind') + .requiredOption('--summary ', 'Privacy-safe checkpoint summary') + .option('--next-action ', 'Next action for the receiving workflow') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--json', 'Output as JSON (for scripts)') + .action(async (task, options) => { + process.exitCode = await teamCheckpoint(process.cwd(), { + ...options, + task, + }); + }); + +teamProgram + .command('claim ') + .description('Acquire a scoped claim for a shared V3 task') + .requiredOption( + '--expected-task-revision ', + 'Current shared task revision', + ) + .option('--path ', 'Repository-relative path glob', collectOption, []) + .option('--module ', 'Implementation module', collectOption, []) + .option('--api ', 'Public API boundary', collectOption, []) + .option('--schema ', 'Shared schema boundary', collectOption, []) + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through the active git-ref authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (task, options) => { + process.exitCode = await teamClaim(process.cwd(), { + ...options, + task, + paths: options.path, + modules: options.module, + apis: options.api, + schemas: options.schema, + }); + }); + +teamProgram + .command('renew ') + .description('Renew one fresh claim lease') + .requiredOption('--expected-revision ', 'Current claim revision') + .option('--ttl ', 'Lease duration: ms, s, m, h, or d') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through the active git-ref authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (claimId, options) => { + process.exitCode = await teamClaimRenew(process.cwd(), { + ...options, + claimId, + }); + }); + +teamProgram + .command('release ') + .description('Release one claim') + .requiredOption('--expected-revision ', 'Current claim revision') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through the active git-ref authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (claimId, options) => { + process.exitCode = await teamClaimRelease(process.cwd(), { + ...options, + claimId, + }); + }); + +teamProgram + .command('transfer ') + .description('Transfer a claim through a new successor identity') + .requiredOption('--to ', 'Receiving joined participant actor ID') + .requiredOption('--expected-revision ', 'Current claim revision') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through the active git-ref authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (claimId, options) => { + process.exitCode = await teamClaimTransfer(process.cwd(), { + ...options, + claimId, + }); + }); + +teamProgram + .command('reclaim ') + .description('Explicitly mark an expired claim terminal') + .requiredOption('--expected-revision ', 'Current claim revision') + .requiredOption('--reason ', 'Privacy-safe expiry reclaim reason') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through the active git-ref authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (claimId, options) => { + process.exitCode = await teamClaimReclaim(process.cwd(), { + ...options, + claimId, + }); + }); + +teamProgram + .command('revalidate ') + .description('Refresh one claim after task or code snapshot drift') + .requiredOption('--expected-revision ', 'Current claim revision') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through the active git-ref authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (claimId, options) => { + process.exitCode = await teamClaimRevalidate(process.cwd(), { + ...options, + claimId, + }); + }); + +const teamHandoffProgram = teamProgram + .command('handoff') + .description('Create and transition journaled ownership handoffs'); + +teamHandoffProgram + .command('draft ') + .description('Create a checkpoint-backed named handoff draft') + .requiredOption( + '--expected-task-revision ', + 'Current shared task revision', + ) + .requiredOption('--to ', 'Receiving joined participant actor ID') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through the active git-ref authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (task, options) => { + process.exitCode = await teamHandoffDraft(process.cwd(), { + ...options, + task, + }); + }); + +teamHandoffProgram + .command('offer ') + .description('Offer a handoff draft to its receiving actor') + .requiredOption('--expected-revision ', 'Current handoff revision') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through the active git-ref authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (handoffId, options) => { + process.exitCode = await teamHandoffOffer(process.cwd(), { + ...options, + handoffId, + }); + }); + +teamHandoffProgram + .command('accept ') + .description('Accept an offered handoff and transfer ownership atomically') + .requiredOption('--expected-revision ', 'Current handoff revision') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through the active git-ref authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (handoffId, options) => { + process.exitCode = await teamHandoffAccept(process.cwd(), { + ...options, + handoffId, + }); + }); + +teamHandoffProgram + .command('reject ') + .description('Reject an offered handoff with a durable reason') + .requiredOption('--expected-revision ', 'Current handoff revision') + .requiredOption('--reason ', 'Reason for rejecting the handoff') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through the active git-ref authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (handoffId, options) => { + process.exitCode = await teamHandoffReject(process.cwd(), { + ...options, + handoffId, + }); + }); + +teamHandoffProgram + .command('cancel ') + .description('Cancel a draft or offered handoff') + .requiredOption('--expected-revision ', 'Current handoff revision') + .option('--reason ', 'Optional cancellation reason') + .option('--session ', 'Session ID (otherwise MANCODE_SESSION_ID)') + .option('--client ', 'Client identity (default: mancode-cli)') + .option('--sync', 'Publish through the active git-ref authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (handoffId, options) => { + process.exitCode = await teamHandoffCancel(process.cwd(), { + ...options, + handoffId, + }); + }); + +const migrateProgram = program + .command('migrate') + .description('Inspect and migrate legacy mancode context into V3 staging'); + +const migrateContextProgram = migrateProgram + .command('context') + .description('Manage the isolated legacy-to-V3 context migration stage') + .option('--dry-run', 'Inspect legacy authority without writing files') + .option('--stage', 'Create or refresh an isolated local migration stage') + .option('--status', 'Show local migration stages') + .option('--activate', 'Attempt the journaled V3 activation') + .option('--rollback ', 'Roll back an untouched V3 activation') + .option('--stage-id ', 'Migration stage ID (required if more than one)') + .option( + '--expected-stage-revision ', + 'Expected stage revision for activation', + ) + .option('--session ', 'Active session required for activation') + .option('--confirm', 'Explicitly confirm the V3 cutover') + .option('--confirm-shared', 'Confirm promotion of staged shared authority') + .option('--json', 'Output as JSON (for scripts)') + .action(async (options) => { + const code = await migrateContext(process.cwd(), options); + process.exitCode = code; + }); + +migrateContextProgram + .command('resolve ') + .description('Resolve missing owner or implementation scope in one stage') + .requiredOption( + '--expected-stage-revision ', + 'Expected local migration stage revision', + ) + .option('--stage-id ', 'Migration stage ID (required if more than one)') + .option('--owner ', 'Explicit V3 owner actor ID') + .option( + '--scope-file ', + 'JSON implementation scope {include,exclude,modules}', + ) + .option('--json', 'Output as JSON (for scripts)') + .action(async (legacyTaskId, options) => { + const code = await migrateContextResolve( + process.cwd(), + legacyTaskId, + options, + ); process.exitCode = code; }); @@ -170,4 +862,10 @@ program version(); }); +export { program as cliProgram }; + program.parse(); + +function collectOption(value: string, previous: string[]): string[] { + return [...previous, value]; +} diff --git a/src/commands/context.ts b/src/commands/context.ts new file mode 100644 index 0000000..f1199b2 --- /dev/null +++ b/src/commands/context.ts @@ -0,0 +1,848 @@ +import type { ContextLevel, ContextPurpose } from '../context/context-pack.js'; +import { + previewV3TaskPromotion, + promoteV3Task, +} from '../context/publish-promote.js'; +import { ContextResolver } from '../context/resolver.js'; +import { V3ContextStore } from '../context/store.js'; +import { + previewV3TaskHeadReconcile, + reconcileV3TaskHead, +} from '../context/task-head-reconcile.js'; +import { parseTaskRef } from '../context/task-ref.js'; +import { evaluateV3BetaGate } from '../runtime/beta-gate.js'; +import { + readLocalDiagnostics, + readLocalDiagnosticsConfig, + setLocalDiagnosticsEnabled, +} from '../runtime/diagnostics.js'; +import { + executeOperationRecovery, + inspectOperationRecovery, + listUnfinishedOperationRecoveries, +} from '../runtime/operation-recovery-executor.js'; +import { + listPlatformSessionSpikes, + writePlatformSessionSpike, +} from '../runtime/platform-spike-store.js'; +import { + type HookApprovalStatus, + type HostSessionSource, + SESSION_SPIKE_PLATFORMS, + type SessionSpikePlatform, + type SpikeEvidenceStatus, + evaluatePlatformSessionCapability, + platformSpikeFreezeStatus, + probePlatformSessionSpike, +} from '../runtime/platform-spike.js'; +import { + ensureProjectRuntimeContext, + readCheckoutCodeHead, +} from '../runtime/project-runtime.js'; +import { + listProjectionIntents, + reconcileProjectionIntents, + supersedeProjectionIntents, +} from '../runtime/projection-outbox.js'; +import { + applyContextCompaction, + planContextCompaction, +} from '../runtime/retention.js'; +import { + type SessionStateV1, + closeSession, + createBootstrapSession, + resumeSession, +} from '../runtime/session.js'; +import { readLocalActor } from '../team/actor.js'; +import { VERSION } from '../version.js'; +import { + EXIT_V3_BLOCKED, + EXIT_V3_INVALID_ARGUMENT, + EXIT_V3_OK, + commandClient, + printV3Error, + printV3Result, + readV3CommandProject, + resolveV3CommandSession, + resolveV3ReadSession, + v3ErrorCode, +} from './v3-support.js'; + +export interface ContextSessionNewOptions { + client?: string; + json?: boolean; +} + +export interface ContextSessionSpikeOptions { + platform?: string; + hostSessionSource?: string; + subagentInheritance?: string; + hookApproval?: string; + json?: boolean; +} + +export interface ContextResumeOptions { + client?: string; + session?: string; + json?: boolean; +} + +export interface ContextShowOptions extends ContextResumeOptions { + task?: string; + level?: string; + purpose?: string; +} + +export interface ContextCloseOptions { + session?: string; + json?: boolean; +} + +export interface ContextWorktreeRegisterOptions { + json?: boolean; +} + +export interface ContextDoctorOptions extends ContextResumeOptions { + repair?: string; +} + +export interface ContextDiagnosticsOptions { + json?: boolean; +} + +export interface ContextCompactOptions { + task?: string; + dryRun?: boolean; + applyShared?: boolean; + json?: boolean; +} + +export interface ContextBetaOptions { + json?: boolean; +} + +export interface ContextPublishOptions extends ContextResumeOptions { + expectedRevision?: string; + confirmShared?: boolean; + dryRun?: boolean; +} + +export interface ContextReconcileTaskHeadOptions extends ContextResumeOptions { + expectedFenceRevision?: string; + fromGit?: boolean; + dryRun?: boolean; +} + +/** Implements `mancode context session new --client `. */ +export async function contextSessionNew( + rootDir: string, + options: ContextSessionNewOptions, +): Promise { + try { + if (options.client === undefined) { + return printV3Error( + options.json, + 'MANCODE_CLIENT_REQUIRED', + 'context session new requires --client .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + const project = await readV3CommandProject(rootDir); + const actor = await readLocalActor(project.projectRoot); + if (actor === null) { + throw new Error('MANCODE_LOCAL_ACTOR_REQUIRED'); + } + const result = await createBootstrapSession(project.projectRoot, { + actorId: actor.actorId, + client: commandClient(options.client), + }); + return printV3Result(options.json, { schemaVersion: 1, ...result }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_CONTEXT_SESSION_CREATE_FAILED'), + error instanceof Error ? error.message : 'Unable to create a session.', + ); + } +} + +/** + * Records one real-host session spike without accepting or persisting host + * keys as command-line arguments. Operators supply the two window values via + * the process environment for this one invocation. + */ +export async function contextSessionSpike( + rootDir: string, + options: ContextSessionSpikeOptions, +): Promise { + try { + const platform = parseSpikePlatform(options.platform); + const hostSessionSource = parseHostSessionSource(options.hostSessionSource); + if (hostSessionSource === 'none') { + throw new Error('MANCODE_PLATFORM_SPIKE_HOST_SOURCE_REQUIRED'); + } + const firstWindowHostSessionKey = + process.env.MANCODE_SPIKE_HOST_SESSION_KEY ?? null; + const secondWindowHostSessionKey = + process.env.MANCODE_SPIKE_SECOND_WINDOW_HOST_SESSION_KEY ?? null; + if ( + firstWindowHostSessionKey === null || + secondWindowHostSessionKey === null + ) { + throw new Error('MANCODE_PLATFORM_SPIKE_WINDOW_EVIDENCE_REQUIRED'); + } + const project = await readV3CommandProject(rootDir); + const spike = await probePlatformSessionSpike({ + platform, + hostSessionSource, + firstWindowHostSessionKey, + secondWindowHostSessionKey, + subagentInheritance: parseSpikeEvidenceStatus( + options.subagentInheritance ?? 'not_tested', + 'subagent inheritance', + ), + hookApproval: parseHookApproval( + options.hookApproval ?? + (hostSessionSource === 'hook_stdin' ? 'unknown' : 'not_applicable'), + ), + }); + await writePlatformSessionSpike(project.projectRoot, spike); + const spikes = await listPlatformSessionSpikes(project.projectRoot); + return printV3Result(options.json, { + schemaVersion: 1, + spike, + capability: evaluatePlatformSessionCapability(spike), + freeze: platformSpikeFreezeStatus(spikes), + rawHostKeysPersisted: false, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_PLATFORM_SPIKE_FAILED'), + error instanceof Error + ? error.message + : 'Unable to record platform session evidence.', + ); + } +} + +/** Reports Beta readiness and exits non-zero until every hard local gate passes. */ +export async function contextBeta( + rootDir: string, + options: ContextBetaOptions, +): Promise { + try { + const result = await evaluateV3BetaGate(rootDir); + printV3Result(options.json, result); + return result.ready ? EXIT_V3_OK : EXIT_V3_BLOCKED; + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_BETA_GATE_FAILED'), + error instanceof Error + ? error.message + : 'Unable to evaluate Beta readiness.', + ); + } +} + +/** Implements `mancode context resume ...`. */ +export async function contextResume( + rootDir: string, + task: string | undefined, + options: ContextResumeOptions, +): Promise { + if (task === undefined) { + return printV3Error( + options.json, + 'MANCODE_TASK_REQUIRED', + 'context resume requires a TaskRef in namespace:ULID form.', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const taskRef = parseTaskRef(task); + const resolution = await resolveContext(project, session, { + taskRef, + level: 'bootstrap', + purpose: 'orient', + intent: 'mutate', + }); + if (resolution.metadata === null || resolution.aggregate === null) { + throw new Error('MANCODE_CONTEXT_WRITE_BLOCKED'); + } + const resumed = await resumeSession( + project.projectRoot, + session.sessionId, + { + taskRef: resolution.taskRef, + workflowMode: resolution.metadata.workflowMode, + taskRevision: resolution.aggregate.taskRevision, + }, + ); + return printV3Result(options.json, { + schemaVersion: 1, + session: resumed, + taskRef: resolution.taskRef, + taskRevision: resolution.aggregate.taskRevision, + pack: resolution.pack, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_CONTEXT_RESUME_FAILED'), + error instanceof Error ? error.message : 'Unable to resume context.', + ); + } +} + +/** Implements `mancode context show [--task ...] ...`. */ +export async function contextShow( + rootDir: string, + options: ContextShowOptions, +): Promise { + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3ReadSession(project, options); + const level = parseLevel(options.level); + const purpose = parsePurpose(options.purpose); + const resolution = await resolveContext(project, session, { + taskRef: + options.task === undefined ? undefined : parseTaskRef(options.task), + level, + purpose, + intent: 'read', + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: resolution.taskRef, + mutatingAllowed: resolution.mutatingAllowed, + repair: resolution.repair, + writeBlockers: resolution.writeBlockers, + pack: resolution.pack, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_CONTEXT_SHOW_FAILED'), + error instanceof Error ? error.message : 'Unable to resolve context.', + ); + } +} + +export async function contextClose( + rootDir: string, + options: ContextCloseOptions, +): Promise { + if (options.session === undefined) { + return printV3Error( + options.json, + 'MANCODE_SESSION_REQUIRED', + 'context close requires --session .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await closeSession(project.projectRoot, options.session); + return printV3Result(options.json, { schemaVersion: 1, session }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_CONTEXT_CLOSE_FAILED'), + error instanceof Error ? error.message : 'Unable to close the session.', + ); + } +} + +/** + * Explicitly registers the current checkout against an existing V3 workspace. + * A read path must not manufacture a checkout identity, but a linked worktree + * needs this one-time local registration before it can safely use common-dir + * coordination. + */ +export async function contextWorktreeRegister( + rootDir: string, + options: ContextWorktreeRegisterOptions, +): Promise { + try { + const store = new V3ContextStore(rootDir); + const project = await store.readProjectSnapshot(); + if (project.manifest.activationState !== 'v3_active') { + throw new Error('MANCODE_MIGRATION_REQUIRED'); + } + const runtime = await ensureProjectRuntimeContext(rootDir); + return printV3Result(options.json, { + schemaVersion: 1, + workspaceId: runtime.workspaceId, + checkoutId: runtime.checkoutId, + repositoryBindingId: runtime.repositoryBindingId, + gitCommonDir: runtime.gitCommonDir, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_WORKTREE_REGISTER_FAILED'), + error instanceof Error + ? error.message + : 'Unable to register this worktree for V3 coordination.', + ); + } +} + +/** Reports every unfinished primary operation and can repair one explicitly. */ +export async function contextDoctor( + rootDir: string, + options: ContextDoctorOptions, +): Promise { + try { + const project = await readV3CommandProject(rootDir); + if (options.repair === undefined) { + const [operations, projections] = await Promise.all([ + listUnfinishedOperationRecoveries(project.projectRoot), + listProjectionIntents(project.projectRoot), + ]); + return printV3Result(options.json, { + schemaVersion: 1, + operations, + projections, + }); + } + const session = await resolveV3CommandSession(project, options); + const projectionIntents = await listProjectionIntents(project.projectRoot, { + operationId: options.repair, + includeTerminal: true, + }); + let operation: Awaited> | null = + null; + let inspection: Awaited< + ReturnType + > | null = null; + try { + inspection = await inspectOperationRecovery( + project.projectRoot, + options.repair, + ); + } catch (error) { + if ( + projectionIntents.length === 0 || + !(error instanceof Error) || + error.message !== 'MANCODE_OPERATION_JOURNAL_NOT_FOUND' + ) { + throw error; + } + } + if ( + inspection?.journal.state === 'committed' || + inspection?.journal.state === 'aborted' + ) { + operation = { + state: 'already_terminal', + journal: inspection.journal, + reason: 'terminal', + }; + } else if (inspection !== null) { + operation = await executeOperationRecovery({ + projectRoot: project.projectRoot, + operationId: options.repair, + actorId: session.actorId, + sessionId: session.sessionId, + mode: 'repair', + }); + } + const projections = + projectionIntents.length === 0 + ? null + : operation?.journal.state === 'aborted' + ? await supersedeProjectionIntents( + project.projectRoot, + options.repair, + ) + : await reconcileProjectionIntents( + project.projectRoot, + options.repair, + ); + return printV3Result(options.json, { + schemaVersion: 1, + operation, + projections, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_CONTEXT_DOCTOR_FAILED'), + error instanceof Error + ? error.message + : 'Unable to inspect repair state.', + ); + } +} + +/** Shows or explicitly disables the local-only, aggregate diagnostics store. */ +export async function contextDiagnostics( + rootDir: string, + action: string | undefined, + options: ContextDiagnosticsOptions, +): Promise { + if ( + action !== undefined && + action !== 'show' && + action !== 'enable' && + action !== 'disable' + ) { + return printV3Error( + options.json, + 'MANCODE_DIAGNOSTICS_ACTION_INVALID', + 'Use: context diagnostics [show|enable|disable].', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + if (action === 'enable' || action === 'disable') { + const config = await setLocalDiagnosticsEnabled( + project.projectRoot, + action === 'enable', + ); + return printV3Result(options.json, { + schemaVersion: 1, + config, + diagnostics: + action === 'disable' + ? null + : await readLocalDiagnostics(project.projectRoot), + }); + } + return printV3Result(options.json, { + schemaVersion: 1, + config: await readLocalDiagnosticsConfig(project.projectRoot), + diagnostics: await readLocalDiagnostics(project.projectRoot), + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_DIAGNOSTICS_FAILED'), + error instanceof Error + ? error.message + : 'Unable to manage local diagnostics.', + ); + } +} + +/** Lists retention candidates before deleting them; shared deletion is opt-in. */ +export async function contextCompact( + rootDir: string, + options: ContextCompactOptions, +): Promise { + try { + const project = await readV3CommandProject(rootDir); + const taskRef = + options.task === undefined ? undefined : parseTaskRef(options.task); + const plan = await planContextCompaction({ + projectRoot: project.projectRoot, + ...(taskRef === undefined ? {} : { taskRef }), + }); + const includesShared = plan.candidates.some( + (candidate) => candidate.taskRef?.namespace === 'shared', + ); + const dryRun = + options.dryRun === true || + (includesShared && options.applyShared !== true); + const result = dryRun + ? { ...plan, deleted: [] } + : await applyContextCompaction(plan); + return printV3Result(options.json, result); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_CONTEXT_COMPACT_FAILED'), + error instanceof Error ? error.message : 'Unable to compact V3 context.', + ); + } +} + +/** Implements `mancode context publish ...`. */ +export async function contextPublish( + rootDir: string, + task: string | undefined, + options: ContextPublishOptions, +): Promise { + if (task === undefined) { + return printV3Error( + options.json, + 'MANCODE_TASK_REQUIRED', + 'context publish requires a local TaskRef in local:ULID form.', + EXIT_V3_INVALID_ARGUMENT, + ); + } + const expectedSourceRevision = parseExpectedRevision( + options.expectedRevision, + ); + if (expectedSourceRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'context publish requires --expected-revision .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + if (options.confirmShared !== true) { + return printV3Error( + options.json, + 'MANCODE_PRIVACY_CONFIRMATION_REQUIRED', + 'context publish requires --confirm-shared before authority enters shared storage.', + EXIT_V3_INVALID_ARGUMENT, + ); + } + if (options.dryRun === true) { + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const preview = await previewV3TaskPromotion({ + projectRoot: project.projectRoot, + sourceTaskRef: parseTaskRef(task), + sessionActorId: session.actorId, + expectedSourceRevision, + destinationWorkflowMode: 'man', + client: commandClient(options.client), + }); + return printV3Result(options.json, { + schemaVersion: 1, + dryRun: true, + ...preview, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_CONTEXT_PUBLISH_FAILED'), + error instanceof Error + ? error.message + : 'Unable to preview the local V3 task publish.', + ); + } + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const result = await promoteV3Task({ + projectRoot: project.projectRoot, + sourceTaskRef: parseTaskRef(task), + sessionId: session.sessionId, + expectedSourceRevision, + destinationWorkflowMode: 'man', + sharedPrivacyConfirmed: true, + client: commandClient(options.client), + }); + return printV3Result(options.json, { + schemaVersion: 1, + sourceMetadata: result.sourceMetadata, + taskRef: result.destinationMetadata.taskRef, + metadata: result.destinationMetadata, + aggregate: result.destinationAggregate, + taskHeadFence: result.destinationTaskHead, + quarantine: result.quarantine, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_CONTEXT_PUBLISH_FAILED'), + error instanceof Error + ? error.message + : 'Unable to publish the local V3 task.', + ); + } +} + +/** Implements explicit adoption of a Git-sourced shared task aggregate. */ +export async function contextReconcileTaskHead( + rootDir: string, + task: string | undefined, + options: ContextReconcileTaskHeadOptions, +): Promise { + if (task === undefined) { + return printV3Error( + options.json, + 'MANCODE_TASK_REQUIRED', + 'context reconcile-task-head requires a shared TaskRef in shared:ULID form.', + EXIT_V3_INVALID_ARGUMENT, + ); + } + const expectedFenceRevision = parseExpectedRevision( + options.expectedFenceRevision, + ); + if (expectedFenceRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'context reconcile-task-head requires --expected-fence-revision .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + if (options.fromGit !== true) { + return printV3Error( + options.json, + 'MANCODE_GIT_SOURCE_CONFIRMATION_REQUIRED', + 'context reconcile-task-head requires --from-git.', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + if (options.dryRun === true) { + const preview = await previewV3TaskHeadReconcile({ + projectRoot: project.projectRoot, + taskRef: parseTaskRef(task), + sessionActorId: session.actorId, + expectedFenceRevision, + fromGit: true, + }); + return printV3Result(options.json, { + schemaVersion: 1, + dryRun: true, + taskRef: preview.aggregate.taskRef, + aggregate: preview.aggregate, + currentTaskHeadFence: preview.currentTaskHeadFence, + proposedTaskHeadFence: preview.proposedTaskHeadFence, + }); + } + const result = await reconcileV3TaskHead({ + projectRoot: project.projectRoot, + taskRef: parseTaskRef(task), + sessionId: session.sessionId, + expectedFenceRevision, + fromGit: true, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.aggregate.taskRef, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_CONTEXT_RECONCILE_TASK_HEAD_FAILED'), + error instanceof Error + ? error.message + : 'Unable to reconcile the V3 task head.', + ); + } +} + +async function resolveContext( + project: Awaited>, + session: SessionStateV1 | null, + request: { + taskRef: ReturnType | undefined; + level: ContextLevel; + purpose: ContextPurpose; + intent: 'read' | 'mutate'; + }, +) { + const resolver = new ContextResolver({ + projectRoot: project.projectRoot, + entityHomeStoreContext: project.runtime.entityHomeStoreContext, + }); + return resolver.resolve({ + session, + taskRef: request.taskRef, + level: request.level, + purpose: request.purpose, + intent: request.intent, + compatibility: { + expectedSchemaEpoch: project.project.manifest.epoch, + readerVersion: VERSION, + writerVersion: VERSION, + adapterVersions: project.project.manifest.managedAdapters, + }, + codeHead: await readCheckoutCodeHead(project.projectRoot), + }); +} + +function parseLevel(value: string | undefined): ContextLevel { + const level = value ?? 'task'; + if (level !== 'bootstrap' && level !== 'task' && level !== 'full') { + throw new Error('MANCODE_CONTEXT_LEVEL_INVALID'); + } + return level; +} + +function parsePurpose(value: string | undefined): ContextPurpose { + const purpose = value ?? 'orient'; + if ( + purpose !== 'orient' && + purpose !== 'plan' && + purpose !== 'implement' && + purpose !== 'review' && + purpose !== 'verify' && + purpose !== 'handoff' + ) { + throw new Error('MANCODE_CONTEXT_PURPOSE_INVALID'); + } + return purpose; +} + +function parseSpikePlatform(value: string | undefined): SessionSpikePlatform { + if ( + value === undefined || + !SESSION_SPIKE_PLATFORMS.includes(value as SessionSpikePlatform) + ) { + throw new Error('MANCODE_PLATFORM_SPIKE_PLATFORM_REQUIRED'); + } + return value as SessionSpikePlatform; +} + +function parseHostSessionSource(value: string | undefined): HostSessionSource { + if ( + value !== 'hook_stdin' && + value !== 'environment' && + value !== 'api' && + value !== 'none' + ) { + throw new Error('MANCODE_PLATFORM_SPIKE_HOST_SOURCE_REQUIRED'); + } + return value; +} + +function parseSpikeEvidenceStatus( + value: string, + label: string, +): SpikeEvidenceStatus { + if ( + value !== 'proven' && + value !== 'not_proven' && + value !== 'not_tested' && + value !== 'not_applicable' + ) { + throw new Error( + `MANCODE_PLATFORM_SPIKE_${label.toUpperCase().replaceAll(' ', '_')}_INVALID`, + ); + } + return value; +} + +function parseHookApproval(value: string): HookApprovalStatus { + if ( + value !== 'approved' && + value !== 'unapproved' && + value !== 'unknown' && + value !== 'not_applicable' + ) { + throw new Error('MANCODE_PLATFORM_SPIKE_HOOK_APPROVAL_INVALID'); + } + return value; +} + +function parseExpectedRevision(value: string | undefined): number | null { + if (value === undefined || !/^[1-9][0-9]*$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +export { EXIT_V3_OK }; diff --git a/src/commands/init.ts b/src/commands/init.ts index b7d74db..e584725 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -12,6 +12,7 @@ import { getPlatformInstaller, getPlatformInstallers, } from '../installers/registry.js'; +import { installV3Adapter } from '../installers/v3-adapter.js'; import { detectTeamStatus } from '../system/detect-team.js'; import { detectSystemDeps } from '../system/detect.js'; import { @@ -30,6 +31,7 @@ import { import { scanAesthetics } from '../system/scan-aesthetics.js'; import { ALL_AGENTS } from '../templates/agents/index.js'; import { VERSION } from '../version.js'; +import { initializeV3Project } from './v3-init.js'; /** * 退出码契约 — 见 docs/08-cli-spec.md §2.5 @@ -89,6 +91,8 @@ export interface InitOptions { empty?: boolean; /** --lang : onboarding language (zh-CN or en). */ lang?: string; + /** Explicitly use the greenfield V3 initialization journal. */ + v3?: boolean; /** Internal CLI flag. Undefined preserves the programmatic API's legacy default. */ interactive?: boolean; /** Injectable prompt adapter for terminal and tests. */ @@ -133,7 +137,7 @@ export async function init( } // 1. 幂等检查 - if (wasInitialized) { + if (wasInitialized && !options.v3) { if (!options.force) { console.log( localize( @@ -174,6 +178,10 @@ export async function init( return EXIT_NOT_A_PROJECT_DIR; } + if (options.v3) { + return initializeV3(rootDir, options); + } + // 2.1 校验是项目目录(git 或任一常见项目 manifest)。空目录可明确作为通用项目初始化。 const isGitRepo = await pathExists(path.join(rootDir, '.git')); const hasManifest = await hasProjectManifest(rootDir); @@ -625,6 +633,52 @@ export async function init( } } +async function initializeV3( + rootDir: string, + options: InitOptions, +): Promise { + if (options.force) { + console.error( + '✗ --force is not supported for journaled V3 initialization.', + ); + return EXIT_INIT_FAILED; + } + const selectedPlatforms = + options.platform === undefined + ? [] + : parsePlatformSelection(options.platform); + if (selectedPlatforms === null) { + console.error(`✗ Unsupported platform selection: ${options.platform}`); + console.error(' Use one or more supported platform names, or all.'); + return EXIT_INIT_FAILED; + } + try { + const result = await initializeV3Project({ projectRoot: rootDir }); + for (const platform of selectedPlatforms) { + await installV3Adapter(rootDir, platform); + } + console.log('✓ Initialized mancode V3 greenfield project.'); + console.log(` workspace: ${result.runtime.workspaceId}`); + console.log(` operation: ${result.journal.operationId}`); + if (selectedPlatforms.length === 0) { + console.log( + ' No platform bootstrap selected. Run `mancode install `.', + ); + } else { + console.log(` V3 bootstrap: ${selectedPlatforms.join(', ')}`); + } + return EXIT_OK; + } catch (error) { + const message = + error instanceof Error ? error.message : 'V3 initialization failed'; + console.error(`✗ ${message}`); + if (message === 'MANCODE_LEGACY_AUTHORITY_PRESENT') { + console.error(' Run `mancode migrate context --dry-run` instead.'); + } + return EXIT_INIT_FAILED; + } +} + async function updateConfigOptions( mancodeDir: string, patch: { diff --git a/src/commands/install.ts b/src/commands/install.ts index fbbee83..4305d06 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -1,12 +1,14 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { V3ContextStore } from '../context/store.js'; import { checkPlatformStatus } from '../installers/platform-status.js'; import { formatPlatformName, getPlatformInstaller, getPlatformInstallers, } from '../installers/registry.js'; +import { installV3Adapter, stageV3Adapter } from '../installers/v3-adapter.js'; import { detectProjectProfile, primaryUiLibrary, @@ -31,6 +33,8 @@ export interface InstallOptions { force?: boolean; /** --minimal: 最小安装(MVP-2 预留) */ minimal?: boolean; + /** Render a V3 adapter candidate under staging without changing live files. */ + shadow?: boolean; } /** @@ -54,6 +58,18 @@ export async function install( options: InstallOptions = {}, ): Promise { const stateFile = path.join(rootDir, '.mancode', 'state.json'); + const v3SchemaFile = path.join(rootDir, '.mancode', 'schema.json'); + + // V3 authority is physically separate from legacy state. Never fall + // through to installMancodeCore when a V3 manifest is present. + if (await pathExists(v3SchemaFile)) { + return installV3(rootDir, platform, options); + } + if (options.shadow) { + console.error('✗ MANCODE_V3_ADAPTER_SHADOW_REQUIRES_V3'); + console.error(' Adapter shadow staging requires a V3 dual-read project.'); + return EXIT_INSTALL_FAILED; + } // 1. 检查是否已初始化 if (!(await pathExists(stateFile))) { @@ -65,11 +81,7 @@ export async function install( // 2. 验证平台名 const installer = getPlatformInstaller(platform); if (!installer) { - console.error(`✗ Unsupported platform: ${platform}`); - console.error(' Supported platforms:'); - for (const item of getPlatformInstallers()) { - console.error(` ${item.name.padEnd(20)} ${item.displayName}`); - } + printUnsupportedPlatform(platform); return EXIT_UNSUPPORTED_PLATFORM; } @@ -157,6 +169,58 @@ export async function install( return EXIT_OK; } +async function installV3( + rootDir: string, + platform: string, + options: InstallOptions, +): Promise { + const installer = getPlatformInstaller(platform); + if (installer === null) { + printUnsupportedPlatform(platform); + return EXIT_UNSUPPORTED_PLATFORM; + } + try { + const project = await new V3ContextStore(rootDir).readProjectSnapshot(); + if (options.shadow) { + if (project.manifest.activationState !== 'dual_read') { + throw new Error('MANCODE_V3_ADAPTER_SHADOW_REQUIRES_DUAL_READ'); + } + const staged = await stageV3Adapter(rootDir, installer.name); + console.log( + `✓ ${formatPlatformName(platform)} V3 bootstrap staged for shadow comparison.`, + ); + console.log(` ${staged.stagingTarget}`); + return EXIT_OK; + } + if (project.manifest.activationState !== 'v3_active') { + throw new Error('MANCODE_V3_ADAPTER_INSTALL_REQUIRES_ACTIVE'); + } + if (options.minimal) { + console.log( + 'ℹ️ V3 adapters are already bootstrap-only; --minimal has no additional effect.', + ); + } + const installed = await installV3Adapter(rootDir, installer.name); + console.log(`✓ ${formatPlatformName(platform)} V3 bootstrap installed.`); + console.log(` ${installed.target}`); + return EXIT_OK; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error( + `✗ ${formatPlatformName(platform)} V3 adapter install failed: ${message}`, + ); + return EXIT_INSTALL_FAILED; + } +} + +function printUnsupportedPlatform(platform: string): void { + console.error(`✗ Unsupported platform: ${platform}`); + console.error(' Supported platforms:'); + for (const item of getPlatformInstallers()) { + console.error(` ${item.name.padEnd(20)} ${item.displayName}`); + } +} + /** * 读取 .mancode/config.json。 */ diff --git a/src/commands/list-platforms.ts b/src/commands/list-platforms.ts index 820eec7..b7ad72c 100644 --- a/src/commands/list-platforms.ts +++ b/src/commands/list-platforms.ts @@ -5,12 +5,16 @@ import { type PlatformInstaller, getPlatformInstallers, } from '../installers/registry.js'; +import { inspectV3Adapter } from '../installers/v3-adapter.js'; export const EXIT_OK = 0; export async function listPlatforms( rootDir: string = process.cwd(), ): Promise { + if (await pathExists(path.join(rootDir, '.mancode', 'schema.json'))) { + return listV3Platforms(rootDir); + } const installed = new Set(await readInstalledPlatforms(rootDir)); const platforms = getPlatformInstallers(); @@ -24,6 +28,27 @@ export async function listPlatforms( return EXIT_OK; } +async function listV3Platforms(rootDir: string): Promise { + const platforms = getPlatformInstallers(); + const statuses = await Promise.all( + platforms.map((platform) => inspectV3Adapter(rootDir, platform.name)), + ); + console.log(''); + console.log('Available platforms (V3 bootstrap):'); + for (const [index, platform] of platforms.entries()) { + const status = statuses[index]; + if (status === undefined) { + throw new Error('MANCODE_V3_ADAPTER_STATUS_UNAVAILABLE'); + } + const marker = status.installed ? '✓' : '○'; + console.log( + ` ${marker} ${platform.name.padEnd(12)} ${platform.displayName.padEnd(16)} V3 bootstrap; explicit session identity`, + ); + } + console.log(''); + return EXIT_OK; +} + async function readInstalledPlatforms(rootDir: string): Promise { try { const raw = await fs.readFile( @@ -41,6 +66,15 @@ async function readInstalledPlatforms(rootDir: string): Promise { } } +async function pathExists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + function formatPlatformLine( platform: PlatformInstaller, installed: boolean, diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts new file mode 100644 index 0000000..8b456e5 --- /dev/null +++ b/src/commands/migrate.ts @@ -0,0 +1,241 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { assertUlid } from '../context/ids.js'; +import { + type MigrationScopeResolutionV1, + activateLegacyMigration, + dryRunLegacyMigration, + listMigrationStages, + resolveLegacyMigration, + rollbackLegacyMigration, + stageLegacyMigration, +} from '../context/migrate.js'; + +export const EXIT_OK = 0; +export const EXIT_INVALID_ARG = 2; +export const EXIT_MIGRATION_BLOCKED = 3; + +export interface MigrateContextOptions { + dryRun?: boolean; + stage?: boolean; + status?: boolean; + activate?: boolean; + rollback?: string; + stageId?: string; + expectedStageRevision?: string; + session?: string; + confirm?: boolean; + confirmShared?: boolean; + json?: boolean; +} + +export interface MigrateResolveOptions { + stageId?: string; + expectedStageRevision?: string; + owner?: string; + scopeFile?: string; + json?: boolean; +} + +/** Implements `mancode migrate context --dry-run|--stage|--status|--activate`. */ +export async function migrateContext( + rootDir: string, + options: MigrateContextOptions, +): Promise { + const selected = [ + options.dryRun === true, + options.stage === true, + options.status === true, + options.activate === true, + options.rollback !== undefined, + ].filter(Boolean).length; + if (selected !== 1) { + return printError( + options.json, + 'MANCODE_MIGRATION_ARGUMENT_INVALID', + 'Choose exactly one migration operation.', + EXIT_INVALID_ARG, + ); + } + try { + if (options.dryRun) { + return printResult(options.json, await dryRunLegacyMigration(rootDir)); + } + if (options.stage) { + return printResult( + options.json, + await stageLegacyMigration({ projectRoot: rootDir }), + ); + } + if (options.status) { + return printResult(options.json, { + schemaVersion: 1, + stages: await listMigrationStages(rootDir), + }); + } + if (options.session === undefined) { + throw new Error('MANCODE_SESSION_REQUIRED'); + } + assertUlid(options.session, 'migration activation session'); + if (options.rollback !== undefined) { + assertUlid(options.rollback, 'migration rollback operation'); + return printResult( + options.json, + await rollbackLegacyMigration({ + projectRoot: rootDir, + operationId: options.rollback, + sessionId: options.session, + explicitConfirmation: options.confirm === true, + }), + ); + } + const stageId = await selectStageId(rootDir, options.stageId); + return printResult( + options.json, + await activateLegacyMigration({ + projectRoot: rootDir, + stageId, + expectedStageRevision: parseRevision(options.expectedStageRevision), + sessionId: options.session, + explicitConfirmation: options.confirm === true, + sharedPrivacyConfirmed: options.confirmShared === true, + }), + ); + } catch (error) { + return printError( + options.json, + errorCode(error), + error instanceof Error ? error.message : 'Migration failed.', + EXIT_MIGRATION_BLOCKED, + ); + } +} + +/** Implements `mancode migrate context resolve ...`. */ +export async function migrateContextResolve( + rootDir: string, + legacyTaskId: string, + options: MigrateResolveOptions, +): Promise { + try { + const stageId = await selectStageId(rootDir, options.stageId); + const expectedStageRevision = parseRevision(options.expectedStageRevision); + const owner = + options.owner === undefined ? undefined : parseActorId(options.owner); + const implementationScope = + options.scopeFile === undefined + ? undefined + : await readScopeFile(rootDir, options.scopeFile); + const result = await resolveLegacyMigration({ + projectRoot: rootDir, + stageId, + legacyTaskId, + expectedStageRevision, + ownerActorId: owner, + implementationScope, + }); + return printResult(options.json, result); + } catch (error) { + return printError( + options.json, + errorCode(error), + error instanceof Error ? error.message : 'Migration resolution failed.', + EXIT_MIGRATION_BLOCKED, + ); + } +} + +async function selectStageId( + rootDir: string, + provided: string | undefined, +): Promise { + if (provided !== undefined) { + assertUlid(provided, 'migration stageId'); + return provided; + } + const mutable = (await listMigrationStages(rootDir)).filter( + (stage) => stage.state === 'staged', + ); + if (mutable.length === 0) + throw new Error('MANCODE_MIGRATION_STAGE_NOT_FOUND'); + if (mutable.length > 1) throw new Error('MANCODE_MIGRATION_STAGE_AMBIGUOUS'); + const stage = mutable[0]; + if (stage === undefined) throw new Error('MANCODE_MIGRATION_STAGE_NOT_FOUND'); + return stage.stageId; +} + +function parseRevision(value: string | undefined): number { + if ( + value === undefined || + !/^[1-9][0-9]*$/.test(value) || + !Number.isSafeInteger(Number(value)) + ) { + throw new Error('MANCODE_MIGRATION_STAGE_REVISION_INVALID'); + } + return Number(value); +} + +function parseActorId(value: string): string { + assertUlid(value, 'migration resolution owner'); + return value; +} + +async function readScopeFile( + rootDir: string, + file: string, +): Promise { + if (!file.trim() || file.includes('\0')) { + throw new Error('MANCODE_MIGRATION_SCOPE_FILE_INVALID'); + } + const resolved = path.resolve(rootDir, file); + const relative = path.relative(path.resolve(rootDir), resolved); + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error('MANCODE_MIGRATION_SCOPE_FILE_INVALID'); + } + try { + return JSON.parse( + await readFile(resolved, 'utf8'), + ) as MigrationScopeResolutionV1; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('MANCODE_MIGRATION_SCOPE_FILE_INVALID'); + } + throw error; + } +} + +function printResult(json: boolean | undefined, result: unknown): number { + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(JSON.stringify(result, null, 2)); + } + return EXIT_OK; +} + +function printError( + json: boolean | undefined, + code: string, + message: string, + exitCode: number, +): number { + const result = { schemaVersion: 1, error: { code, message } }; + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.error(`✗ ${code}`); + console.error(` ${message}`); + } + return exitCode; +} + +function errorCode(error: unknown): string { + if (error instanceof Error && error.message.startsWith('MANCODE_')) { + return error.message.split(':', 1)[0] ?? 'MANCODE_MIGRATION_FAILED'; + } + return 'MANCODE_MIGRATION_FAILED'; +} diff --git a/src/commands/operation.ts b/src/commands/operation.ts new file mode 100644 index 0000000..8429600 --- /dev/null +++ b/src/commands/operation.ts @@ -0,0 +1,108 @@ +import { assertUlid } from '../context/ids.js'; +import { + executeOperationRecovery, + inspectOperationRecovery, +} from '../runtime/operation-recovery-executor.js'; +import { + EXIT_V3_INVALID_ARGUMENT, + printV3Error, + printV3Result, + readV3CommandProject, + resolveV3CommandSession, + v3ErrorCode, +} from './v3-support.js'; + +export interface OperationShowOptions { + json?: boolean; +} + +export interface OperationMutationOptions extends OperationShowOptions { + session?: string; + client?: string; +} + +export async function operationShow( + rootDir: string, + operationId: string | undefined, + options: OperationShowOptions, +): Promise { + if (operationId === undefined) { + return printV3Error( + options.json, + 'MANCODE_OPERATION_ID_REQUIRED', + 'operation show requires an operation ULID.', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + assertUlid(operationId, 'operationId'); + const project = await readV3CommandProject(rootDir); + const result = await inspectOperationRecovery( + project.projectRoot, + operationId, + ); + return printV3Result(options.json, { schemaVersion: 1, ...result }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_OPERATION_SHOW_FAILED'), + error instanceof Error ? error.message : 'Unable to inspect operation.', + ); + } +} + +export async function operationRepair( + rootDir: string, + operationId: string | undefined, + options: OperationMutationOptions, +): Promise { + return runOperationMutation(rootDir, operationId, options, 'repair'); +} + +export async function operationAbort( + rootDir: string, + operationId: string | undefined, + options: OperationMutationOptions, +): Promise { + return runOperationMutation(rootDir, operationId, options, 'abort'); +} + +async function runOperationMutation( + rootDir: string, + operationId: string | undefined, + options: OperationMutationOptions, + mode: 'repair' | 'abort', +): Promise { + if (operationId === undefined) { + return printV3Error( + options.json, + 'MANCODE_OPERATION_ID_REQUIRED', + `operation ${mode} requires an operation ULID.`, + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + assertUlid(operationId, 'operationId'); + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const result = await executeOperationRecovery({ + projectRoot: project.projectRoot, + operationId, + actorId: session.actorId, + sessionId: session.sessionId, + mode, + }); + return printV3Result(options.json, { schemaVersion: 1, ...result }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode( + error, + mode === 'repair' + ? 'MANCODE_OPERATION_REPAIR_FAILED' + : 'MANCODE_OPERATION_ABORT_FAILED', + ), + error instanceof Error ? error.message : 'Unable to recover operation.', + ); + } +} diff --git a/src/commands/refresh-project.ts b/src/commands/refresh-project.ts index 997e542..67a1229 100644 --- a/src/commands/refresh-project.ts +++ b/src/commands/refresh-project.ts @@ -2,6 +2,11 @@ import { randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { + createProjectFacts, + writeProjectFacts as writeV3ProjectFacts, +} from '../context/project-facts.js'; +import { V3ContextStore } from '../context/store.js'; import { type PlatformName, getPlatformInstaller, @@ -24,6 +29,9 @@ export async function refreshProject( ): Promise { const mancodeDir = path.join(rootDir, '.mancode'); const statePath = path.join(mancodeDir, 'state.json'); + if (await pathExists(path.join(mancodeDir, 'schema.json'))) { + return refreshV3Project(rootDir); + } if (!(await pathExists(statePath))) { console.error('✗ mancode not initialized.'); console.error(' Run `mancode init` first.'); @@ -106,6 +114,32 @@ export async function refreshProject( } } +async function refreshV3Project(rootDir: string): Promise { + try { + const project = await new V3ContextStore(rootDir).readProjectSnapshot(); + if (project.manifest.activationState !== 'v3_active') { + throw new Error('MANCODE_V3_REFRESH_REQUIRES_ACTIVE'); + } + const profile = await detectProjectProfile(rootDir); + const facts = createProjectFacts(profile, { + revision: (project.projectFacts?.revision ?? 0) + 1, + }); + await writeV3ProjectFacts(rootDir, facts); + console.log('✓ V3 project facts refreshed.'); + console.log( + ` Kind: ${facts.profile.projectKind} | Stack: ${[...facts.profile.languages, ...facts.profile.frameworks].join(' + ') || 'unknown'}`, + ); + console.log( + ' V3 adapters remain unchanged because their bootstrap is static.', + ); + return EXIT_OK; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`✗ V3 project facts refresh failed: ${message}`); + return EXIT_REFRESH_FAILED; + } +} + async function hasProjectManifest(rootDir: string): Promise { for (const manifest of PROJECT_MANIFESTS) { if (await pathExists(path.join(rootDir, manifest))) return true; diff --git a/src/commands/refresh-style.ts b/src/commands/refresh-style.ts index b04cf8f..5eae53e 100644 --- a/src/commands/refresh-style.ts +++ b/src/commands/refresh-style.ts @@ -1,6 +1,11 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { + createProjectFacts, + writeProjectFacts as writeV3ProjectFacts, +} from '../context/project-facts.js'; +import { V3ContextStore } from '../context/store.js'; import { detectProjectProfile, primaryUiLibrary, @@ -12,6 +17,7 @@ import { scanAesthetics } from '../system/scan-aesthetics.js'; */ export const EXIT_OK = 0; export const EXIT_NOT_INITIALIZED = 1; +export const EXIT_V3_REFRESH_FAILED = 2; /** * `mancode refresh-style` 命令。 @@ -34,6 +40,9 @@ export async function refreshStyle( rootDir: string = process.cwd(), ): Promise { const stateFile = path.join(rootDir, '.mancode', 'state.json'); + if (await pathExists(path.join(rootDir, '.mancode', 'schema.json'))) { + return refreshV3Style(rootDir); + } // 1. 检查是否已初始化 if (!(await pathExists(stateFile))) { @@ -86,6 +95,19 @@ export async function refreshStyle( ); // 6. 输出摘要 + printAestheticsSummary(tokens); + + console.log(''); + console.log( + '已更新 .mancode/project-profile.json 和 .mancode/aesthetics/style-tokens.json', + ); + await printStaticPlatformRefreshHint(rootDir); + return EXIT_OK; +} + +function printAestheticsSummary( + tokens: Awaited>, +): void { console.log(''); if (tokens.matchLevel === 'none') { console.log('未检测到设计 token。'); @@ -126,13 +148,63 @@ export async function refreshStyle( console.log(` 匹配度: ${tokens.matchLevel}`); } +} - console.log(''); - console.log( - '已更新 .mancode/project-profile.json 和 .mancode/aesthetics/style-tokens.json', - ); - await printStaticPlatformRefreshHint(rootDir); - return EXIT_OK; +/** V3 keeps detected facts shared and rebuildable style scans checkout-local. */ +async function refreshV3Style(rootDir: string): Promise { + try { + const project = await new V3ContextStore(rootDir).readProjectSnapshot(); + if (project.manifest.activationState !== 'v3_active') { + throw new Error('MANCODE_V3_REFRESH_REQUIRES_ACTIVE'); + } + console.log('✓ 刷新 V3 项目 profile...'); + const profile = await detectProjectProfile(rootDir); + const uiLibraryHint = primaryUiLibrary(profile); + await writeV3ProjectFacts( + rootDir, + createProjectFacts(profile, { + revision: (project.projectFacts?.revision ?? 0) + 1, + }), + ); + console.log( + ` 类型: ${profile.projectKind} | UI: ${profile.uiAssets} | 浏览器: ${profile.browserAutomation}`, + ); + + const tokensPath = path.join( + rootDir, + '.mancode', + 'local', + 'cache', + 'style-tokens.json', + ); + if (profile.uiAssets !== 'detected') { + await fs.rm(tokensPath, { force: true }); + console.log( + 'ℹ️ No UI assets detected in project profile. Skipping style scan.', + ); + console.log(' Updated V3 project facts.'); + return EXIT_OK; + } + + console.log('✓ 扫描项目设计 token...'); + const tokens = await scanAesthetics(rootDir, uiLibraryHint); + await fs.mkdir(path.dirname(tokensPath), { recursive: true }); + await fs.writeFile( + tokensPath, + `${JSON.stringify(tokens, null, 2)}\n`, + 'utf-8', + ); + printAestheticsSummary(tokens); + console.log(''); + console.log( + '已更新 .mancode/shared/context/project.json 和 .mancode/local/cache/style-tokens.json', + ); + return EXIT_OK; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`✗ V3 style refresh failed: ${message}`); + return EXIT_V3_REFRESH_FAILED; + } } async function printStaticPlatformRefreshHint(rootDir: string): Promise { diff --git a/src/commands/status.ts b/src/commands/status.ts index bc0e4e3..810ad40 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -2,14 +2,28 @@ import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { evaluateCompatibilityGate } from '../context/compatibility.js'; +import { scanLegacyAuthority } from '../context/layout.js'; +import { V3ContextStore } from '../context/store.js'; import { type PlatformStatus, checkPlatformStatus as checkOnePlatformStatus, } from '../installers/platform-status.js'; import { + type PlatformName, formatPlatformName, getPlatformInstallers, } from '../installers/registry.js'; +import { + type V3PlatformAdapterStatus, + inspectV3Adapter, +} from '../installers/v3-adapter.js'; +import { listPlatformSessionSpikes } from '../runtime/platform-spike-store.js'; +import { + type SessionSpikePlatform, + platformSpikeFreezeStatus, +} from '../runtime/platform-spike.js'; +import { readProjectRuntimeContext } from '../runtime/project-runtime.js'; import { detectTeamStatus } from '../system/detect-team.js'; import { PROJECT_MANIFESTS } from '../system/project-profile.js'; import { @@ -20,6 +34,7 @@ import { maxWorkflowStep, readWorkflow, } from '../system/workflow.js'; +import { readLocalActor } from '../team/actor.js'; import { VERSION } from '../version.js'; const HOOK_ESTIMATE_TIMEOUT_MS = 2000; @@ -103,6 +118,53 @@ export interface StatusResult { projectRefreshRecommended: boolean; } +/** V3 status never derives state from legacy hooks or adapter copies. */ +export interface V3StatusResult { + schemaVersion: 1; + authority: 'v3'; + version: string; + project: string; + activation: { + state: string; + epoch: string; + activatedAt: string | null; + managedAdapters: Record; + }; + compatibility: { + readAllowed: boolean; + writeAllowed: boolean; + failures: string[]; + }; + runtime: { + binding: 'ready' | 'registration_required'; + workspaceId: string | null; + checkoutId: string | null; + repositoryBindingId: string | null; + gitCommonDir: string | null; + error: string | null; + }; + transport: { + mode: 'local' | 'git-ref'; + remote: string | null; + }; + policy: { + revision: number; + mode: string; + defaultVisibility: string; + }; + localIdentity: { + actorId: string | null; + displayName: string | null; + }; + sessionEvidence: { + ready: boolean; + missingPlatforms: SessionSpikePlatform[]; + explicitRequiredPlatforms: SessionSpikePlatform[]; + }; + adapters: Record; + legacyAuthorityPresent: boolean; +} + interface DetectedTeamStatus { isTeam: boolean; contributors: number; @@ -135,6 +197,11 @@ export async function status( options: StatusOptions = {}, ): Promise { const stateFile = path.join(rootDir, '.mancode', 'state.json'); + const v3SchemaFile = path.join(rootDir, '.mancode', 'schema.json'); + + if (await pathExists(v3SchemaFile)) { + return statusV3(rootDir, options); + } // 1. 检查是否已初始化 if (!(await pathExists(stateFile))) { @@ -208,6 +275,108 @@ export async function status( return EXIT_OK; } +async function statusV3( + rootDir: string, + options: StatusOptions, +): Promise { + try { + const store = new V3ContextStore(rootDir); + const [snapshot, project, legacy, actor, adapterEntries, sessionSpikes] = + await Promise.all([ + store.readProjectSnapshot(), + getProjectName(rootDir), + scanLegacyAuthority(rootDir), + readLocalActor(rootDir).catch(() => null), + Promise.all( + getPlatformInstallers().map(async (platform) => + Promise.all([ + platform.name, + inspectV3Adapter(rootDir, platform.name), + ]), + ), + ), + listPlatformSessionSpikes(rootDir), + ]); + const compatibility = evaluateCompatibilityGate({ + manifest: snapshot.manifest, + expectedSchemaEpoch: snapshot.manifest.epoch, + readerVersion: VERSION, + writerVersion: VERSION, + adapterVersions: snapshot.manifest.managedAdapters, + currentLegacyBaseline: legacy.baseline, + legacyAuthorityPresent: legacy.authorityPresent, + operation: 'read', + }); + const adapters = Object.fromEntries(adapterEntries) as Record< + PlatformName, + V3PlatformAdapterStatus + >; + let runtime: V3StatusResult['runtime']; + try { + const context = await readProjectRuntimeContext(rootDir); + runtime = { + binding: 'ready', + workspaceId: context.workspaceId, + checkoutId: context.checkoutId, + repositoryBindingId: context.repositoryBindingId, + gitCommonDir: context.gitCommonDir, + error: null, + }; + } catch (error) { + runtime = { + binding: 'registration_required', + workspaceId: snapshot.config.workspaceId, + checkoutId: null, + repositoryBindingId: null, + gitCommonDir: null, + error: error instanceof Error ? error.message : String(error), + }; + } + const result: V3StatusResult = { + schemaVersion: 1, + authority: 'v3', + version: VERSION, + project, + activation: { + state: snapshot.manifest.activationState, + epoch: snapshot.manifest.epoch, + activatedAt: snapshot.manifest.activatedAt, + managedAdapters: snapshot.manifest.managedAdapters, + }, + compatibility: { + readAllowed: compatibility.readAllowed, + writeAllowed: compatibility.writeAllowed, + failures: compatibility.failures, + }, + runtime, + transport: snapshot.config.transport, + policy: { + revision: snapshot.policy.revision, + mode: snapshot.policy.policy, + defaultVisibility: snapshot.policy.defaultVisibility, + }, + localIdentity: { + actorId: actor?.actorId ?? null, + displayName: actor?.displayName ?? null, + }, + sessionEvidence: platformSpikeFreezeStatus(sessionSpikes), + adapters, + legacyAuthorityPresent: legacy.authorityPresent, + }; + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + printV3Text(result); + } + return EXIT_OK; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('✗ .mancode/schema.json is corrupt or incomplete.'); + console.error(` ${message}`); + return EXIT_CORRUPT_STATE; + } +} + async function shouldRefreshProject( rootDir: string, state: StatusState, @@ -574,6 +743,53 @@ function printText(r: StatusResult): void { } } +function printV3Text(result: V3StatusResult): void { + console.log(''); + console.log(`mancode v${result.version} (V3 authority)`); + console.log(''); + console.log(`Project: ${result.project}`); + console.log( + `Activation: ${result.activation.state} (epoch ${result.activation.epoch})`, + ); + console.log( + `Runtime: ${result.runtime.binding}${result.runtime.error ? ` (${result.runtime.error})` : ''}`, + ); + console.log( + `Transport: ${result.transport.mode}${result.transport.remote ? ` (${result.transport.remote})` : ''}`, + ); + console.log( + `Identity: ${result.localIdentity.displayName ?? 'not configured'}`, + ); + console.log( + `Session evidence: ${result.sessionEvidence.ready ? 'ready' : `explicit required (${result.sessionEvidence.explicitRequiredPlatforms.join(', ')})`}`, + ); + if (result.compatibility.failures.length > 0) { + console.log(`Compatibility: ${result.compatibility.failures.join(', ')}`); + } + if (result.legacyAuthorityPresent) { + console.log( + 'Legacy: legacy authority detected; V3 writes may be blocked.', + ); + } + console.log(''); + console.log('V3 adapter status:'); + for (const platform of getPlatformInstallers()) { + const adapter = result.adapters[platform.name]; + const marker = adapter.installed ? '✓' : '○'; + const readiness = adapter.ready ? 'ready' : 'not installed'; + console.log( + ` ${marker} ${platform.displayName}: ${readiness} (${adapter.target}; session=${adapter.capabilities.sessionIdentity})`, + ); + } + if (result.runtime.binding !== 'ready') { + console.log(''); + console.log( + 'Run `mancode context worktree register` in this checkout before V3 mutations.', + ); + } + console.log(''); +} + function formatTeamStatus(team: StatusResult['team']): string { if (team.forced) return `forced (${team.contributors} contributors)`; if (team.isTeam) return `detected (${team.contributors} contributors)`; diff --git a/src/commands/team.ts b/src/commands/team.ts new file mode 100644 index 0000000..5c23fe8 --- /dev/null +++ b/src/commands/team.ts @@ -0,0 +1,1769 @@ +import { createV3Checkpoint } from '../context/checkpoint-create.js'; +import { + createConfirmedDecision, + publishConfirmedDecision, +} from '../context/confirmed-decision.js'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { assertSharedTextSafe } from '../context/privacy.js'; +import { V3ContextStore } from '../context/store.js'; +import { parseTaskRef } from '../context/task-ref.js'; +import { listClaims } from '../runtime/claim-store.js'; +import { resolveCoordinationEntityHomeStore } from '../runtime/entity-home-store.js'; +import { listHandoffs } from '../runtime/handoff-store.js'; +import { + completeProjectionIntent, + enqueueAuditEventProjection, +} from '../runtime/projection-outbox.js'; +import { + type SharedActorProfileV1, + createLocalActor, + readLocalActor, + readSharedActorProfile, +} from '../team/actor.js'; +import { createAuthorizationBasis } from '../team/authorization.js'; +import { parseCheckpointKind } from '../team/checkpoints.js'; +import { acquireV3Claim } from '../team/claim-acquisition.js'; +import { + reclaimV3Claim, + releaseV3Claim, + renewV3Claim, + revalidateV3Claim, + transferV3Claim, +} from '../team/claim-operation.js'; +import { assessClaimConflicts } from '../team/conflicts.js'; +import { type TeamEventV1, writeTeamEvent } from '../team/events.js'; +import { + assertGitRefBundleCodeReachable, + quarantineGitRefTaskBundle, +} from '../team/git-ref-bundle.js'; +import { + capabilitiesFromGitRefCache, + readGitRefTeamCache, + writeGitRefTeamCache, +} from '../team/git-ref-cache.js'; +import { createGitRefTeamManifestStore } from '../team/git-ref-client.js'; +import { + acceptGitRefHandoffWithRepair, + recoverGitRefHandoffRepairs, +} from '../team/git-ref-handoff-repair.js'; +import { materializeGitRefTaskBundle } from '../team/git-ref-materialization.js'; +import { + acquireGitRefClaim, + createGitRefHandoffDraft, + mutateGitRefClaim, + mutateGitRefHandoff, + syncGitRefTask, +} from '../team/git-ref-operation.js'; +import { + acceptV3Handoff, + cancelV3Handoff, + createV3HandoffDraft, + offerV3Handoff, + rejectV3Handoff, +} from '../team/handoff-operation.js'; +import { type TeamJoinSyncPublisher, joinTeam } from '../team/join.js'; +import type { ProjectConfigV1 } from '../team/policy.js'; +import { createTransportMigrationFileAdapters } from '../team/transport-migration-adapters.js'; +import { + executeTransportMigration, + previewTransportMigration, + recoverTransportMigration, +} from '../team/transport-migration.js'; +import { capabilitiesFromProjectConfig } from '../team/transport.js'; +import { + EXIT_V3_INVALID_ARGUMENT, + printV3Error, + printV3Result, + readV3CommandProject, + resolveV3CommandSession, + v3ErrorCode, +} from './v3-support.js'; + +export interface TeamIdentityCreateOptions { + name?: string; + json?: boolean; +} + +export interface TeamIdentityShowOptions { + json?: boolean; +} + +export interface TeamJoinOptions { + name?: string; + session?: string; + client?: string; + sync?: boolean; + json?: boolean; +} + +export interface TeamStatusOptions { + json?: boolean; +} + +export interface TeamConflictsOptions { + task?: string; + json?: boolean; +} + +export interface TeamSyncOptions { + task?: string; + expectedTaskRevision?: string; + session?: string; + client?: string; + json?: boolean; +} + +export interface TeamTransportMigrateOptions { + to?: string; + remote?: string; + expectedConfigRevision?: string; + confirm?: boolean; + dryRun?: boolean; + session?: string; + client?: string; + json?: boolean; +} + +export interface TeamTransportRecoverOptions { + to?: string; + remote?: string; + abort?: boolean; + session?: string; + client?: string; + json?: boolean; +} + +export interface TeamDecisionPublishOptions { + title?: string; + statement?: string; + task?: string; + confirm?: boolean; + session?: string; + client?: string; + json?: boolean; +} + +export interface TeamCheckpointOptions { + task?: string; + expectedTaskRevision?: string; + kind?: string; + summary?: string; + nextAction?: string; + session?: string; + client?: string; + json?: boolean; +} + +export interface TeamClaimOptions { + task?: string; + expectedTaskRevision?: string; + paths?: string[]; + modules?: string[]; + apis?: string[]; + schemas?: string[]; + session?: string; + client?: string; + sync?: boolean; + json?: boolean; +} + +export interface TeamClaimTransitionOptions { + claimId?: string; + expectedRevision?: string; + ttl?: string; + to?: string; + reason?: string; + session?: string; + client?: string; + sync?: boolean; + json?: boolean; +} + +export interface TeamHandoffDraftOptions { + task?: string; + expectedTaskRevision?: string; + to?: string; + session?: string; + client?: string; + sync?: boolean; + json?: boolean; +} + +export interface TeamHandoffTransitionOptions { + handoffId?: string; + expectedRevision?: string; + reason?: string; + session?: string; + client?: string; + sync?: boolean; + json?: boolean; +} + +/** Creates only the local actor identity; it never publishes shared data. */ +export async function teamIdentityCreate( + rootDir: string, + options: TeamIdentityCreateOptions, +): Promise { + if (options.name === undefined) { + return printV3Error( + options.json, + 'MANCODE_ACTOR_NAME_REQUIRED', + 'team identity create requires --name .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const actor = await createLocalActor(project.projectRoot, { + displayName: options.name, + }); + return printV3Result(options.json, { schemaVersion: 1, actor }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_IDENTITY_CREATE_FAILED'), + error instanceof Error ? error.message : 'Unable to create identity.', + ); + } +} + +export async function teamClaimRenew( + rootDir: string, + options: TeamClaimTransitionOptions, +): Promise { + const expectedClaimRevision = claimTransitionExpectedRevision( + options, + 'renew', + ); + if (expectedClaimRevision === null) return EXIT_V3_INVALID_ARGUMENT; + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + if (requireGitRefSync(project.project.config, options.sync)) { + const result = await mutateGitRefClaim({ + projectRoot: project.projectRoot, + claimId: parseClaimId(options.claimId as string), + sessionId: session.sessionId, + expectedClaimRevision, + mutation: { + kind: 'renew', + ttlMs: parseClaimTtlDuration(options.ttl), + }, + }); + return printV3Result(options.json, { + schemaVersion: 1, + claim: requireClaimResult(result.claims, options.claimId as string), + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + }); + } + const result = await renewV3Claim({ + projectRoot: project.projectRoot, + claimId: parseClaimId(options.claimId as string), + sessionId: session.sessionId, + expectedClaimRevision, + ttlMs: parseClaimTtlDuration(options.ttl), + }); + return printV3Result(options.json, { + schemaVersion: 1, + claim: result.claim, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_CLAIM_RENEW_FAILED'), + error instanceof Error ? error.message : 'Unable to renew claim.', + ); + } +} + +export async function teamClaimRelease( + rootDir: string, + options: TeamClaimTransitionOptions, +): Promise { + const expectedClaimRevision = claimTransitionExpectedRevision( + options, + 'release', + ); + if (expectedClaimRevision === null) return EXIT_V3_INVALID_ARGUMENT; + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + if (requireGitRefSync(project.project.config, options.sync)) { + const result = await mutateGitRefClaim({ + projectRoot: project.projectRoot, + claimId: parseClaimId(options.claimId as string), + sessionId: session.sessionId, + expectedClaimRevision, + mutation: { kind: 'release' }, + }); + return printV3Result(options.json, { + schemaVersion: 1, + claim: requireClaimResult(result.claims, options.claimId as string), + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + }); + } + const result = await releaseV3Claim({ + projectRoot: project.projectRoot, + claimId: parseClaimId(options.claimId as string), + sessionId: session.sessionId, + expectedClaimRevision, + }); + return printV3Result(options.json, { + schemaVersion: 1, + claim: result.claim, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_CLAIM_RELEASE_FAILED'), + error instanceof Error ? error.message : 'Unable to release claim.', + ); + } +} + +export async function teamClaimTransfer( + rootDir: string, + options: TeamClaimTransitionOptions, +): Promise { + const expectedClaimRevision = claimTransitionExpectedRevision( + options, + 'transfer', + ); + if (expectedClaimRevision === null) return EXIT_V3_INVALID_ARGUMENT; + if (options.to === undefined) { + return printV3Error( + options.json, + 'MANCODE_CLAIM_TRANSFER_TARGET_REQUIRED', + 'team transfer requires --to .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + if (requireGitRefSync(project.project.config, options.sync)) { + const claimId = parseClaimId(options.claimId as string); + const result = await mutateGitRefClaim({ + projectRoot: project.projectRoot, + claimId, + sessionId: session.sessionId, + expectedClaimRevision, + mutation: { + kind: 'transfer', + toActorId: parseActorId(options.to), + }, + }); + const predecessorClaim = requireClaimResult(result.claims, claimId); + const successorClaim = result.claims.find( + (claim) => claim.predecessorClaimId === claimId, + ); + if (successorClaim === undefined) { + throw new Error('MANCODE_REMOTE_RECEIPT_MISMATCH'); + } + return printV3Result(options.json, { + schemaVersion: 1, + predecessorClaim, + successorClaim, + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + }); + } + const result = await transferV3Claim({ + projectRoot: project.projectRoot, + claimId: parseClaimId(options.claimId as string), + sessionId: session.sessionId, + expectedClaimRevision, + toActorId: parseActorId(options.to), + }); + return printV3Result(options.json, { + schemaVersion: 1, + predecessorClaim: result.predecessorClaim, + successorClaim: result.successorClaim, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_CLAIM_TRANSFER_FAILED'), + error instanceof Error ? error.message : 'Unable to transfer claim.', + ); + } +} + +export async function teamClaimReclaim( + rootDir: string, + options: TeamClaimTransitionOptions, +): Promise { + const expectedClaimRevision = claimTransitionExpectedRevision( + options, + 'reclaim', + ); + if (expectedClaimRevision === null) return EXIT_V3_INVALID_ARGUMENT; + if (options.reason === undefined || !options.reason.trim()) { + return printV3Error( + options.json, + 'MANCODE_RECLAIM_REASON_REQUIRED', + 'team reclaim requires --reason .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + if (requireGitRefSync(project.project.config, options.sync)) { + const result = await mutateGitRefClaim({ + projectRoot: project.projectRoot, + claimId: parseClaimId(options.claimId as string), + sessionId: session.sessionId, + expectedClaimRevision, + mutation: { kind: 'reclaim', reason: options.reason }, + }); + return printV3Result(options.json, { + schemaVersion: 1, + claim: requireClaimResult(result.claims, options.claimId as string), + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + }); + } + const result = await reclaimV3Claim({ + projectRoot: project.projectRoot, + claimId: parseClaimId(options.claimId as string), + sessionId: session.sessionId, + expectedClaimRevision, + reason: options.reason, + }); + return printV3Result(options.json, { + schemaVersion: 1, + claim: result.claim, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_CLAIM_RECLAIM_FAILED'), + error instanceof Error ? error.message : 'Unable to reclaim claim.', + ); + } +} + +export async function teamClaimRevalidate( + rootDir: string, + options: TeamClaimTransitionOptions, +): Promise { + const expectedClaimRevision = claimTransitionExpectedRevision( + options, + 'revalidate', + ); + if (expectedClaimRevision === null) return EXIT_V3_INVALID_ARGUMENT; + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + if (requireGitRefSync(project.project.config, options.sync)) { + const result = await mutateGitRefClaim({ + projectRoot: project.projectRoot, + claimId: parseClaimId(options.claimId as string), + sessionId: session.sessionId, + expectedClaimRevision, + mutation: { kind: 'revalidate' }, + }); + return printV3Result(options.json, { + schemaVersion: 1, + claim: requireClaimResult(result.claims, options.claimId as string), + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + }); + } + const result = await revalidateV3Claim({ + projectRoot: project.projectRoot, + claimId: parseClaimId(options.claimId as string), + sessionId: session.sessionId, + expectedClaimRevision, + }); + return printV3Result(options.json, { + schemaVersion: 1, + metadata: result.metadata, + claim: result.claim, + checkpoint: result.checkpoint, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_CLAIM_REVALIDATE_FAILED'), + error instanceof Error ? error.message : 'Unable to revalidate claim.', + ); + } +} + +export async function teamIdentityShow( + rootDir: string, + options: TeamIdentityShowOptions, +): Promise { + try { + const project = await readV3CommandProject(rootDir); + const actor = await readLocalActor(project.projectRoot); + if (actor === null) throw new Error('MANCODE_LOCAL_ACTOR_REQUIRED'); + const sharedProfile = await readSharedActorProfile( + project.projectRoot, + actor.actorId, + ); + return printV3Result(options.json, { + schemaVersion: 1, + actor, + sharedProfile, + joined: sharedProfile !== null, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_IDENTITY_SHOW_FAILED'), + error instanceof Error ? error.message : 'Unable to read identity.', + ); + } +} + +/** Publishes one explicit, privacy-safe shared decision as an immutable entity. */ +export async function teamDecisionPublish( + rootDir: string, + options: TeamDecisionPublishOptions, +): Promise { + if (options.title === undefined || options.statement === undefined) { + return printV3Error( + options.json, + 'MANCODE_CONFIRMED_DECISION_ARGUMENT_INVALID', + 'team decision publish requires --title and --statement .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + if (options.confirm !== true) { + return printV3Error( + options.json, + 'MANCODE_EXPLICIT_CONFIRMATION_REQUIRED', + 'Publishing shared decisions requires --confirm.', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + assertSharedTextSafe(options.title, 'confirmed decision title'); + assertSharedTextSafe(options.statement, 'confirmed decision statement'); + const project = await readV3CommandProject(rootDir); + const actor = await readLocalActor(project.projectRoot); + if (actor === null) throw new Error('MANCODE_LOCAL_ACTOR_REQUIRED'); + const session = await resolveV3CommandSession(project, options); + if (session.actorId !== actor.actorId) { + throw new Error('MANCODE_SESSION_ACTOR_MISMATCH'); + } + const joined = + (await readSharedActorProfile(project.projectRoot, actor.actorId)) !== + null; + const capabilities = capabilitiesFromProjectConfig(project.project.config); + const authorization = createAuthorizationBasis({ + action: 'confirmed_decision_publish', + actorId: actor.actorId, + session: { + sessionId: session.sessionId, + actorId: session.actorId, + status: session.status, + }, + joined, + sharedWriteGuard: capabilities.writeGuard, + task: null, + claim: null, + handoff: null, + evidence: null, + profileActorId: null, + conditions: { + confirmedDecisionSharingEnabled: + project.project.policy.shareConfirmedDecisions, + privacyConfirmed: true, + explicitConfirmation: true, + }, + }); + const operationId = createUlid(); + const decisionTarget = createConfirmedDecision({ + decisionId: createUlid(), + title: options.title, + statement: options.statement, + taskRef: options.task === undefined ? null : parseTaskRef(options.task), + actorId: actor.actorId, + operationId, + authorization, + }); + const eventTarget: TeamEventV1 = { + schemaVersion: 1, + eventId: createUlid(), + eventType: 'confirmed_decision_published', + operationId, + entityRef: { kind: 'decision', id: decisionTarget.decisionId }, + taskRef: null, + actorId: actor.actorId, + taskRevision: null, + createdAt: decisionTarget.confirmedAt, + }; + const projection = await enqueueAuditEventProjection( + project.projectRoot, + eventTarget, + ); + const decision = await publishConfirmedDecision( + project.projectRoot, + decisionTarget, + ); + const event = await writeTeamEvent(project.projectRoot, eventTarget); + try { + await completeProjectionIntent( + project.projectRoot, + projection.operationId, + projection.projectionId, + ); + } catch { + // The event is durable; doctor can close a stale pending intent. + } + return printV3Result(options.json, { + schemaVersion: 1, + decision, + event, + trustBoundary: authorization.trustBoundary, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_CONFIRMED_DECISION_PUBLISH_FAILED'), + error instanceof Error + ? error.message + : 'Unable to publish the confirmed decision.', + ); + } +} + +/** Publishes the minimal approved actor profile after resolving an active session. */ +export async function teamJoin( + rootDir: string, + options: TeamJoinOptions, +): Promise { + if (options.name === undefined) { + return printV3Error( + options.json, + 'MANCODE_ACTOR_NAME_REQUIRED', + 'team join requires --name .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const actor = await readLocalActor(project.projectRoot); + if (actor === null) throw new Error('MANCODE_LOCAL_ACTOR_REQUIRED'); + if (actor.displayName !== options.name.trim()) { + throw new Error('MANCODE_LOCAL_ACTOR_NAME_MISMATCH'); + } + const session = await resolveV3CommandSession(project, options); + if (session.actorId !== actor.actorId) { + throw new Error('MANCODE_SESSION_ACTOR_MISMATCH'); + } + const capabilities = capabilitiesFromProjectConfig(project.project.config); + createAuthorizationBasis({ + action: 'actor_profile_publish', + actorId: actor.actorId, + session: { + sessionId: session.sessionId, + actorId: session.actorId, + status: session.status, + }, + joined: false, + sharedWriteGuard: capabilities.writeGuard, + task: null, + claim: null, + handoff: null, + evidence: null, + profileActorId: actor.actorId, + conditions: { explicitConfirmation: true }, + }); + const result = await joinTeam({ + projectRoot: project.projectRoot, + actor, + projectConfig: project.project.config, + teamPolicy: project.project.policy, + operationId: createUlid(), + eventId: createUlid(), + confirmed: true, + sync: options.sync === true, + syncPublisher: gitRefProfilePublisher( + project.projectRoot, + project.project.config, + project.project.manifest, + options.sync === true, + ), + }); + return printV3Result(options.json, { + schemaVersion: 1, + ...result, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_JOIN_FAILED'), + error instanceof Error ? error.message : 'Unable to join team.', + ); + } +} + +function gitRefProfilePublisher( + projectRoot: string, + config: ProjectConfigV1, + manifest: Awaited< + ReturnType + >['project']['manifest'], + syncRequested: boolean, +): TeamJoinSyncPublisher | undefined { + const remote = config.transport.remote; + if ( + !syncRequested || + config.transport.mode !== 'git-ref' || + remote === null + ) { + return undefined; + } + const transport = createGitRefTeamManifestStore( + projectRoot, + config, + manifest, + ); + return { + async publishActorProfile(input: { + operationId: Ulid; + profile: SharedActorProfileV1; + }) { + const snapshot = await transport.pull(); + return transport.publishActorProfile({ + ...input, + expectedRemoteRevision: snapshot.manifest?.revision ?? 0, + }); + }, + }; +} + +export async function teamStatus( + rootDir: string, + options: TeamStatusOptions, +): Promise { + try { + const project = await readV3CommandProject(rootDir); + const actor = await readLocalActor(project.projectRoot); + const profile = + actor === null + ? null + : await readSharedActorProfile(project.projectRoot, actor.actorId); + const cache = await readGitRefTeamCache( + project.projectRoot, + project.project.config, + ); + const capabilities = + project.project.config.transport.mode === 'git-ref' + ? capabilitiesFromGitRefCache(project.project.config, cache) + : capabilitiesFromProjectConfig(project.project.config); + return printV3Result(options.json, { + schemaVersion: 1, + workspaceId: project.project.config.workspaceId, + policy: project.project.policy, + transport: project.project.config.transport, + capabilities, + remoteSnapshot: + cache === null + ? null + : { + revision: cache.manifest?.revision ?? 0, + fetchedAt: cache.fetchedAt, + receipt: cache.receipt, + }, + actor, + joined: profile !== null, + trustBoundary: 'repo-collaborators', + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_STATUS_FAILED'), + error instanceof Error ? error.message : 'Unable to read team status.', + ); + } +} + +/** Switches coordination authority only through the journaled migration engine. */ +export async function teamTransportMigrate( + rootDir: string, + options: TeamTransportMigrateOptions, +): Promise { + const expectedConfigRevision = parsePositiveInteger( + options.expectedConfigRevision, + ); + if (options.to === undefined || expectedConfigRevision === null) { + return printV3Error( + options.json, + 'MANCODE_TRANSPORT_MIGRATION_ARGUMENT_INVALID', + 'Use: team transport migrate --to --expected-config-revision --confirm.', + EXIT_V3_INVALID_ARGUMENT, + ); + } + const operationId = createUlid(); + try { + const targetMode = parseTransportMode(options.to); + const targetRemote = migrationTargetRemote(targetMode, options.remote); + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const joined = + (await readSharedActorProfile(project.projectRoot, session.actorId)) !== + null; + const adapters = await createTransportMigrationFileAdapters({ + projectRoot: project.projectRoot, + actorId: session.actorId, + targetMode, + targetRemote, + operationId, + }); + const input = { + ...adapters, + operationId, + checkoutId: adapters.checkoutId, + actorId: session.actorId, + sessionId: session.sessionId, + expectedConfigRevision, + joined, + explicitConfirmation: options.confirm === true, + }; + if (options.dryRun === true) { + const preview = await previewTransportMigration(input); + return printV3Result(options.json, { + schemaVersion: 1, + dryRun: true, + operationId, + source: preview.manifest.source, + target: preview.manifest.target, + manifestDigest: preview.manifestDigest, + taskCount: preview.manifest.tasks.length, + activeClaimCount: preview.manifest.sourceClaims.filter( + (claim) => claim.state === 'active', + ).length, + }); + } + const result = await executeTransportMigration(input); + return printV3Result(options.json, { + schemaVersion: 1, + dryRun: false, + operationId, + manifestDigest: result.manifestDigest, + source: result.manifest.source, + target: result.manifest.target, + config: result.activatedConfig, + authority: result.established, + operation: result.journal, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unable to migrate transport.'; + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TRANSPORT_MIGRATION_FAILED'), + `${message} (operationId: ${operationId})`, + ); + } +} + +/** Recovers a durable transport migration using its original actor/session. */ +export async function teamTransportRecover( + rootDir: string, + operationIdValue: string | undefined, + options: TeamTransportRecoverOptions, +): Promise { + if (operationIdValue === undefined || options.to === undefined) { + return printV3Error( + options.json, + 'MANCODE_TRANSPORT_MIGRATION_RECOVERY_ARGUMENT_INVALID', + 'Use: team transport recover --to .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + assertUlid(operationIdValue, 'transport migration operationId'); + const targetMode = parseTransportMode(options.to); + const targetRemote = migrationTargetRemote(targetMode, options.remote); + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const adapters = await createTransportMigrationFileAdapters({ + projectRoot: project.projectRoot, + actorId: session.actorId, + targetMode, + targetRemote, + operationId: operationIdValue, + }); + const result = await recoverTransportMigration({ + ...adapters, + operationId: operationIdValue, + actorId: session.actorId, + sessionId: session.sessionId, + mode: options.abort === true ? 'abort' : 'forward', + }); + return printV3Result(options.json, { + schemaVersion: 1, + operationId: operationIdValue, + result, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TRANSPORT_MIGRATION_RECOVERY_FAILED'), + error instanceof Error + ? error.message + : 'Unable to recover transport migration.', + ); + } +} + +/** Explicitly refreshes the validated remote snapshot; no other read path fetches. */ +export async function teamSyncPull( + rootDir: string, + options: TeamSyncOptions, +): Promise { + try { + const project = await readV3CommandProject(rootDir); + const requestedTask = + options.task === undefined ? null : parseTaskRef(options.task); + if (requestedTask?.namespace === 'local') { + throw new Error('MANCODE_CLAIM_REQUIRES_SHARED_TASK'); + } + const previousCache = await readGitRefTeamCache( + project.projectRoot, + project.project.config, + ); + const transport = createGitRefTeamManifestStore( + project.projectRoot, + project.project.config, + project.project.manifest, + ); + const snapshot = await transport.pull(); + const manifest = snapshot.manifest; + const selectedBundles = filterRemoteTask( + manifest?.taskBundles ?? [], + requestedTask, + ); + const materializedBundles = []; + for (const bundle of selectedBundles) { + const ownershipFence = manifest?.ownershipFences.find( + (candidate) => candidate.taskRef.taskId === bundle.taskRef.taskId, + ); + if (ownershipFence === undefined || manifest === null) { + throw new Error('MANCODE_REMOTE_OWNERSHIP_FENCE_MISSING'); + } + const quarantinePath = await quarantineGitRefTaskBundle( + project.projectRoot, + manifest.revision, + bundle, + ); + let codeReachable = true; + try { + await assertGitRefBundleCodeReachable(project.projectRoot, bundle); + } catch (error) { + if ( + !(error instanceof Error) || + error.message !== 'MANCODE_TASK_BUNDLE_CODE_UNREACHABLE' + ) { + throw error; + } + codeReachable = false; + } + if (!codeReachable) { + materializedBundles.push({ + taskRef: bundle.taskRef, + quarantinePath, + codeReachable: false, + status: 'quarantined', + taskRevision: bundle.taskRevision, + aggregateDigest: bundle.aggregateDigest, + }); + continue; + } + const previousBundle = previousCache?.manifest?.taskBundles.find( + (candidate) => candidate.taskRef.taskId === bundle.taskRef.taskId, + ); + const materialized = await materializeGitRefTaskBundle({ + projectRoot: project.projectRoot, + remoteRevision: manifest.revision, + ownershipFence, + bundle, + predecessorBundle: previousBundle ?? null, + }); + materializedBundles.push({ + taskRef: bundle.taskRef, + quarantinePath, + codeReachable: true, + ...materialized, + }); + } + const cache = await writeGitRefTeamCache( + project.projectRoot, + project.project.config, + snapshot, + ); + const cachedManifest = cache.manifest; + return printV3Result(options.json, { + schemaVersion: 1, + remoteRevision: cachedManifest?.revision ?? 0, + receipt: cache.receipt, + fetchedAt: cache.fetchedAt, + authorityState: cachedManifest?.authorityState ?? 'active', + ownershipFences: filterRemoteTask( + cachedManifest?.ownershipFences ?? [], + requestedTask, + ), + claims: filterRemoteTask(cachedManifest?.claims ?? [], requestedTask), + handoffs: filterRemoteTask(cachedManifest?.handoffs ?? [], requestedTask), + taskBundles: filterRemoteTask( + cachedManifest?.taskBundles ?? [], + requestedTask, + ).map((bundle) => ({ + taskRef: bundle.taskRef, + taskRevision: bundle.aggregate.taskRevision, + aggregateDigest: bundle.aggregateDigest, + codeRef: bundle.codeRef, + })), + materializedBundles, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_SYNC_PULL_FAILED'), + error instanceof Error ? error.message : 'Unable to pull team state.', + ); + } +} + +/** Publishes one current task bundle through a fresh remote fence CAS. */ +export async function teamSyncPush( + rootDir: string, + options: TeamSyncOptions, +): Promise { + if ( + options.task === undefined || + options.expectedTaskRevision === undefined + ) { + return printV3Error( + options.json, + 'MANCODE_TEAM_SYNC_PUSH_ARGUMENT_INVALID', + 'Use: team sync push --expected-task-revision .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + const expectedTaskRevision = parsePositiveInteger( + options.expectedTaskRevision, + ); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Sync push requires --expected-task-revision .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const taskRef = parseTaskRef(options.task); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_REMOTE_COORDINATION_REQUIRES_SHARED_TASK'); + } + const session = await resolveV3CommandSession(project, options); + const result = await syncGitRefTask({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef, + taskRevision: result.bundle.taskRevision, + aggregateDigest: result.bundle.aggregateDigest, + changed: result.changed, + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_SYNC_PUSH_FAILED'), + error instanceof Error ? error.message : 'Unable to push team state.', + ); + } +} + +function filterRemoteTask( + values: readonly T[], + taskRef: ReturnType | null, +): T[] { + return taskRef === null + ? [...values] + : values.filter((value) => value.taskRef.taskId === taskRef.taskId); +} + +/** Reports the current local coordination view without claiming remote freshness. */ +export async function teamConflicts( + rootDir: string, + options: TeamConflictsOptions, +): Promise { + try { + const project = await readV3CommandProject(rootDir); + const taskRef = + options.task === undefined ? undefined : parseTaskRef(options.task); + if (taskRef?.namespace === 'local') { + throw new Error('MANCODE_CLAIM_REQUIRES_SHARED_TASK'); + } + const cache = await readGitRefTeamCache( + project.projectRoot, + project.project.config, + ); + const gitRef = project.project.config.transport.mode === 'git-ref'; + const capabilities = gitRef + ? capabilitiesFromGitRefCache(project.project.config, cache) + : capabilitiesFromProjectConfig(project.project.config); + const [claims, handoffs] = gitRef + ? ([ + (cache?.manifest?.claims ?? []).filter( + (claim) => + taskRef === undefined || claim.taskRef.taskId === taskRef.taskId, + ), + (cache?.manifest?.handoffs ?? []).filter( + (handoff) => + taskRef === undefined || + handoff.taskRef.taskId === taskRef.taskId, + ), + ] as const) + : await readLocalCoordination(project, taskRef); + const transportFreshness = + capabilities.transport === 'local' + ? 'fresh' + : capabilities.transportFreshness; + return printV3Result(options.json, { + schemaVersion: 1, + ...(taskRef === undefined ? {} : { taskRef }), + capabilities, + claims: claims.map((claim) => ({ + claim, + conflict: assessClaimConflicts( + claim.scope, + claims.filter((candidate) => candidate.claimId !== claim.claimId), + { + transportFreshness, + claimAcquisition: capabilities.claimAcquisition, + }, + ), + })), + handoffs, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_CONFLICTS_FAILED'), + error instanceof Error ? error.message : 'Unable to inspect conflicts.', + ); + } +} + +async function readLocalCoordination( + project: Awaited>, + taskRef: ReturnType | undefined, +) { + const homeStore = resolveCoordinationEntityHomeStore( + project.runtime.entityHomeStoreContext, + ); + return Promise.all([ + listClaims(homeStore, taskRef), + listHandoffs(homeStore, taskRef), + ]); +} + +/** Creates a shared immutable checkpoint through the canonical task journal. */ +export async function teamCheckpoint( + rootDir: string, + options: TeamCheckpointOptions, +): Promise { + if ( + options.task === undefined || + options.expectedTaskRevision === undefined || + options.kind === undefined || + options.summary === undefined + ) { + return printV3Error( + options.json, + 'MANCODE_CHECKPOINT_ARGUMENT_INVALID', + 'Use: team checkpoint --expected-task-revision --kind --summary .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + const expectedTaskRevision = parsePositiveInteger( + options.expectedTaskRevision, + ); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Checkpoint creation requires --expected-task-revision .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const taskRef = parseTaskRef(options.task); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_TEAM_CHECKPOINT_REQUIRES_SHARED_TASK'); + } + const session = await resolveV3CommandSession(project, options); + const result = await createV3Checkpoint({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision, + kind: parseCheckpointKind(options.kind), + summary: options.summary, + nextAction: options.nextAction, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef, + checkpoint: result.checkpoint, + metadata: result.metadata, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_CHECKPOINT_FAILED'), + error instanceof Error ? error.message : 'Unable to create checkpoint.', + ); + } +} + +/** Acquires a scoped claim through the common-dir task and claim locks. */ +export async function teamClaim( + rootDir: string, + options: TeamClaimOptions, +): Promise { + if ( + options.task === undefined || + options.expectedTaskRevision === undefined + ) { + return printV3Error( + options.json, + 'MANCODE_CLAIM_ARGUMENT_INVALID', + 'Use: team claim --expected-task-revision --path or --module .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + const expectedTaskRevision = parsePositiveInteger( + options.expectedTaskRevision, + ); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Claim acquisition requires --expected-task-revision .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const taskRef = parseTaskRef(options.task); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_CLAIM_REQUIRES_SHARED_TASK'); + } + const session = await resolveV3CommandSession(project, options); + const scope = { + paths: options.paths ?? [], + modules: options.modules ?? [], + apis: options.apis ?? [], + schemas: options.schemas ?? [], + }; + if (requireGitRefSync(project.project.config, options.sync)) { + const result = await acquireGitRefClaim({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision, + scope, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef, + claim: result.claim, + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + }); + } + const result = await acquireV3Claim({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision, + scope, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef, + claim: result.claim, + conflict: result.conflict, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_CLAIM_FAILED'), + error instanceof Error ? error.message : 'Unable to acquire claim.', + ); + } +} + +/** Creates a checkpoint-backed, named handoff draft for a shared team task. */ +export async function teamHandoffDraft( + rootDir: string, + options: TeamHandoffDraftOptions, +): Promise { + if ( + options.task === undefined || + options.expectedTaskRevision === undefined || + options.to === undefined + ) { + return printV3Error( + options.json, + 'MANCODE_HANDOFF_DRAFT_ARGUMENT_INVALID', + 'Use: team handoff draft --expected-task-revision --to .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + const expectedTaskRevision = parsePositiveInteger( + options.expectedTaskRevision, + ); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Handoff draft requires --expected-task-revision .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const taskRef = parseTaskRef(options.task); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_HANDOFF_REQUIRES_SHARED_TASK'); + } + const session = await resolveV3CommandSession(project, options); + if (requireGitRefSync(project.project.config, options.sync)) { + let task = await new V3ContextStore(project.projectRoot).readTaskSnapshot( + taskRef, + ); + let checkpoint = task.latestCheckpoint; + if ( + checkpoint === null || + checkpoint.kind !== 'handoff_offered' || + task.metadata.revision !== expectedTaskRevision + ) { + const created = await createV3Checkpoint({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision, + kind: 'handoff_offered', + summary: + 'Created an immutable checkpoint before offering task ownership.', + nextAction: 'Review the checkpoint and continue the assigned task.', + }); + checkpoint = created.checkpoint; + task = await new V3ContextStore(project.projectRoot).readTaskSnapshot( + taskRef, + ); + } + const publication = await syncGitRefTask({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision: task.metadata.revision, + }); + const result = await createGitRefHandoffDraft({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision: task.metadata.revision, + toActorId: parseActorId(options.to), + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef, + checkpoint, + handoff: result.handoff, + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + bundleReceipt: publication.receipt, + }); + } + const result = await createV3HandoffDraft({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision, + toActorId: parseActorId(options.to), + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef, + checkpoint: result.checkpoint, + checkpointOperation: result.checkpointOperation, + handoff: result.handoff, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_HANDOFF_DRAFT_FAILED'), + error instanceof Error ? error.message : 'Unable to draft handoff.', + ); + } +} + +export async function teamHandoffOffer( + rootDir: string, + options: TeamHandoffTransitionOptions, +): Promise { + return runHandoffTransition(rootDir, options, 'offer'); +} + +export async function teamHandoffAccept( + rootDir: string, + options: TeamHandoffTransitionOptions, +): Promise { + if (!hasHandoffArguments(options)) { + return handoffArgumentError(options, 'accept'); + } + const expectedHandoffRevision = parsePositiveInteger( + options.expectedRevision as string, + ); + if (expectedHandoffRevision === null) { + return handoffExpectedRevisionError(options); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + if (requireGitRefSync(project.project.config, options.sync)) { + await recoverGitRefHandoffRepairs(project.projectRoot); + const result = await acceptGitRefHandoffWithRepair({ + projectRoot: project.projectRoot, + handoffId: parseHandoffId(options.handoffId as string), + sessionId: session.sessionId, + expectedHandoffRevision, + }); + const task = await new V3ContextStore( + project.projectRoot, + ).readTaskSnapshot(result.handoff.taskRef); + const predecessorIds = new Set(result.forwardRepair.predecessorClaimIds); + const successorIds = new Set(result.forwardRepair.successorClaimIds); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.handoff.taskRef, + metadata: task.metadata, + handoff: result.handoff, + predecessorClaims: result.claims.filter((claim) => + predecessorIds.has(claim.claimId), + ), + successorClaims: result.claims.filter((claim) => + successorIds.has(claim.claimId), + ), + aggregate: task.aggregate, + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + forwardRepair: result.forwardRepair, + }); + } + const result = await acceptV3Handoff({ + projectRoot: project.projectRoot, + handoffId: parseHandoffId(options.handoffId as string), + sessionId: session.sessionId, + expectedHandoffRevision, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.metadata.taskRef, + metadata: result.metadata, + handoff: result.handoff, + predecessorClaims: result.predecessorClaims, + successorClaims: result.successorClaims, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_TEAM_HANDOFF_ACCEPT_FAILED'), + error instanceof Error ? error.message : 'Unable to accept handoff.', + ); + } +} + +export async function teamHandoffReject( + rootDir: string, + options: TeamHandoffTransitionOptions, +): Promise { + if (options.reason === undefined || !options.reason.trim()) { + return printV3Error( + options.json, + 'MANCODE_HANDOFF_REJECTION_REASON_REQUIRED', + 'team handoff reject requires --reason .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + return runHandoffTransition(rootDir, options, 'reject'); +} + +export async function teamHandoffCancel( + rootDir: string, + options: TeamHandoffTransitionOptions, +): Promise { + return runHandoffTransition(rootDir, options, 'cancel'); +} + +async function runHandoffTransition( + rootDir: string, + options: TeamHandoffTransitionOptions, + intent: 'offer' | 'reject' | 'cancel', +): Promise { + if (!hasHandoffArguments(options)) { + return handoffArgumentError(options, intent); + } + const expectedHandoffRevision = parsePositiveInteger( + options.expectedRevision as string, + ); + if (expectedHandoffRevision === null) { + return handoffExpectedRevisionError(options); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + if (requireGitRefSync(project.project.config, options.sync)) { + const result = await mutateGitRefHandoff({ + projectRoot: project.projectRoot, + handoffId: parseHandoffId(options.handoffId as string), + sessionId: session.sessionId, + expectedHandoffRevision, + mutation: + intent === 'reject' + ? { kind: 'reject', reason: options.reason as string } + : { kind: intent, reason: options.reason }, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.handoff.taskRef, + handoff: result.handoff, + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + }); + } + const input = { + projectRoot: project.projectRoot, + handoffId: parseHandoffId(options.handoffId as string), + sessionId: session.sessionId, + expectedHandoffRevision, + reason: options.reason, + }; + const result = + intent === 'offer' + ? await offerV3Handoff(input) + : intent === 'reject' + ? await rejectV3Handoff(input) + : await cancelV3Handoff(input); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.handoff.taskRef, + handoff: result.handoff, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, `MANCODE_TEAM_HANDOFF_${intent.toUpperCase()}_FAILED`), + error instanceof Error ? error.message : `Unable to ${intent} handoff.`, + ); + } +} + +function hasHandoffArguments( + options: TeamHandoffTransitionOptions, +): options is TeamHandoffTransitionOptions & { + handoffId: string; + expectedRevision: string; +} { + return ( + options.handoffId !== undefined && options.expectedRevision !== undefined + ); +} + +function handoffArgumentError( + options: TeamHandoffTransitionOptions, + action: 'offer' | 'accept' | 'reject' | 'cancel', +): number { + return printV3Error( + options.json, + 'MANCODE_HANDOFF_ARGUMENT_INVALID', + `Use: team handoff ${action} --expected-revision .`, + EXIT_V3_INVALID_ARGUMENT, + ); +} + +function handoffExpectedRevisionError( + options: TeamHandoffTransitionOptions, +): number { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Handoff transition requires --expected-revision .', + EXIT_V3_INVALID_ARGUMENT, + ); +} + +function requireGitRefSync( + config: ProjectConfigV1, + sync: boolean | undefined, +): boolean { + if (config.transport.mode === 'git-ref') { + if (sync !== true) throw new Error('MANCODE_EXPLICIT_SYNC_REQUIRED'); + return true; + } + if (sync === true) throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); + return false; +} + +function requireClaimResult( + claims: readonly T[], + claimId: string, +): T { + const claim = claims.find((candidate) => candidate.claimId === claimId); + if (claim === undefined) throw new Error('MANCODE_REMOTE_RECEIPT_MISMATCH'); + return claim; +} + +function claimTransitionExpectedRevision( + options: TeamClaimTransitionOptions, + action: 'renew' | 'release' | 'transfer' | 'reclaim' | 'revalidate', +): number | null { + if (options.claimId === undefined || options.expectedRevision === undefined) { + printV3Error( + options.json, + 'MANCODE_CLAIM_ARGUMENT_INVALID', + `Use: team ${action} --expected-revision .`, + EXIT_V3_INVALID_ARGUMENT, + ); + return null; + } + const expectedRevision = parsePositiveInteger(options.expectedRevision); + if (expectedRevision === null) { + printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Claim mutation requires --expected-revision .', + EXIT_V3_INVALID_ARGUMENT, + ); + } + return expectedRevision; +} + +function parseClaimId(value: string): Ulid { + return parseUlidArgument(value, 'MANCODE_CLAIM_ID_INVALID'); +} + +function parseClaimTtlDuration(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const match = /^(?[1-9][0-9]*)(?ms|s|m|h|d)$/.exec(value); + if (match?.groups === undefined) { + throw new Error('MANCODE_CLAIM_TTL_INVALID'); + } + const rawAmount = match.groups.amount; + const unit = match.groups.unit; + if (rawAmount === undefined || unit === undefined) { + throw new Error('MANCODE_CLAIM_TTL_INVALID'); + } + const amount = Number(rawAmount); + const multipliers: Record = { + ms: 1, + s: 1_000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, + }; + const multiplier = multipliers[unit]; + if ( + multiplier === undefined || + !Number.isSafeInteger(amount) || + amount > Math.floor(Number.MAX_SAFE_INTEGER / multiplier) + ) { + throw new Error('MANCODE_CLAIM_TTL_INVALID'); + } + return amount * multiplier; +} + +function parseActorId(value: string): Ulid { + return parseUlidArgument(value, 'MANCODE_ACTOR_ID_INVALID'); +} + +function parseHandoffId(value: string): Ulid { + return parseUlidArgument(value, 'MANCODE_HANDOFF_ID_INVALID'); +} + +function parseUlidArgument(value: string, errorCode: string): Ulid { + try { + assertUlid(value, 'value'); + return value; + } catch { + throw new Error(errorCode); + } +} + +function parseTransportMode( + value: string, +): ProjectConfigV1['transport']['mode'] { + if (value !== 'local' && value !== 'git-ref') { + throw new Error('MANCODE_TRANSPORT_MODE_INVALID'); + } + return value; +} + +function migrationTargetRemote( + mode: ProjectConfigV1['transport']['mode'], + remote: string | undefined, +): string | null { + if (mode === 'local') { + if (remote !== undefined) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_REMOTE_INVALID'); + } + return null; + } + return remote ?? 'origin'; +} + +function parsePositiveInteger(value: string | undefined): number | null { + if (value === undefined) return null; + if (!/^[1-9][0-9]*$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 33a3b42..2e3cb1b 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -19,6 +19,7 @@ import { getPlatformInstaller, getPlatformInstallers, } from '../installers/registry.js'; +import { removeV3Adapter } from '../installers/v3-adapter.js'; import { ZCODE_MANCODE_END_MARKER, ZCODE_MANCODE_START_MARKER, @@ -27,6 +28,7 @@ import { export const EXIT_OK = 0; export const EXIT_NOT_INITIALIZED = 1; export const EXIT_UNSUPPORTED_PLATFORM = 2; +export const EXIT_V3_AUTHORITY_PROTECTED = 3; export interface UninstallOptions { /** --force: skip confirmation */ @@ -50,6 +52,10 @@ export async function uninstall( options: UninstallOptions = {}, ): Promise { const stateFile = path.join(rootDir, '.mancode', 'state.json'); + const v3SchemaFile = path.join(rootDir, '.mancode', 'schema.json'); + if (await pathExists(v3SchemaFile)) { + return uninstallV3(rootDir, platform, options); + } if (!(await pathExists(stateFile))) { console.error('✗ mancode not initialized.'); console.error(' Run `mancode init` first.'); @@ -90,6 +96,45 @@ export async function uninstall( return EXIT_OK; } +async function uninstallV3( + rootDir: string, + platform: string | undefined, + options: UninstallOptions, +): Promise { + if (!platform || options.all) { + console.error('✗ V3 authority is protected from bulk uninstall.'); + console.error( + ' Remove a single bootstrap with `mancode uninstall `, or complete an explicit V3 archive/migration workflow first.', + ); + return EXIT_V3_AUTHORITY_PROTECTED; + } + const installer = getPlatformInstaller(platform); + if (!installer) { + console.error(`✗ Unsupported platform: ${platform}`); + console.error( + ` Supported platforms: ${getPlatformInstallers() + .map((item) => item.name) + .join(', ')}`, + ); + return EXIT_UNSUPPORTED_PLATFORM; + } + if (!options.force) { + console.log( + `ℹ️ This will remove only the ${formatPlatformName(platform)} V3 bootstrap.`, + ); + console.log(' V3 task, session, and shared authority are preserved.'); + } + try { + await removeV3Adapter(rootDir, installer.name); + console.log(`✓ Removed ${formatPlatformName(platform)} V3 bootstrap.`); + return EXIT_OK; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`✗ V3 bootstrap removal failed: ${message}`); + return EXIT_V3_AUTHORITY_PROTECTED; + } +} + async function uninstallPlatform( rootDir: string, platform: string, diff --git a/src/commands/v3-init.ts b/src/commands/v3-init.ts new file mode 100644 index 0000000..4fee84d --- /dev/null +++ b/src/commands/v3-init.ts @@ -0,0 +1,108 @@ +import { + type GreenfieldInitializationJournalV1, + initializeGreenfield, +} from '../context/greenfield-init.js'; +import { type Ulid, createUlid } from '../context/ids.js'; +import type { ManagedAdapter } from '../context/manifest.js'; +import { createProjectFacts } from '../context/project-facts.js'; +import { V3_ADAPTER_VERSION } from '../installers/v3-adapter.js'; +import { + type ProjectRuntimeContext, + ensureProjectRuntimeContext, +} from '../runtime/project-runtime.js'; +import { detectProjectProfile } from '../system/project-profile.js'; +import type { ProjectConfigV1, TeamPolicyV1 } from '../team/policy.js'; +import { VERSION } from '../version.js'; + +export interface InitializeV3ProjectInput { + projectRoot: string; + operationId?: Ulid; + workspaceId?: Ulid; + schemaEpoch?: Ulid; + managedAdapters?: Record; + now?: Date; +} + +export interface InitializeV3ProjectResult { + journal: GreenfieldInitializationJournalV1; + runtime: ProjectRuntimeContext; +} + +/** + * The greenfield command path deliberately has no legacy fallback. If legacy + * authority exists, `initializeGreenfield` refuses it and the caller must use + * migration staging instead. + */ +export async function initializeV3Project( + input: InitializeV3ProjectInput, +): Promise { + const now = input.now ?? new Date(); + const workspaceId = input.workspaceId ?? createUlid(); + const operationId = input.operationId ?? createUlid(); + const schemaEpoch = input.schemaEpoch ?? createUlid(); + const timestamp = now.toISOString(); + const projectConfig: ProjectConfigV1 = { + schemaVersion: 1, + revision: 1, + workspaceId, + transport: { mode: 'local', remote: null, epoch: 1 }, + lastOperationId: null, + updatedAt: timestamp, + }; + const teamPolicy: TeamPolicyV1 = { + schemaVersion: 1, + revision: 1, + workspaceId, + policy: 'auto', + recentDays: 30, + defaultVisibility: 'local', + shareConfirmedDecisions: false, + retention: { + localRawArtifactDays: 7, + localCacheDays: 7, + completedSessionDays: 30, + }, + lastOperationId: null, + updatedAt: timestamp, + }; + let runtime: ProjectRuntimeContext | null = null; + const projectFacts = createProjectFacts( + await detectProjectProfile(input.projectRoot), + { now, operationId }, + ); + const journal = await initializeGreenfield( + { + projectRoot: input.projectRoot, + operationId, + workspaceId, + schemaEpoch, + minReaderVersion: VERSION, + minWriterVersion: VERSION, + managedAdapters: input.managedAdapters ?? defaultManagedAdapters(), + projectConfig, + teamPolicy, + projectFacts, + now, + }, + { + registerWorkspaceBinding: async () => { + runtime = await ensureProjectRuntimeContext(input.projectRoot, now); + }, + now, + }, + ); + if (runtime === null) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + return { journal, runtime }; +} + +function defaultManagedAdapters(): Record { + return { + 'claude-code': V3_ADAPTER_VERSION, + codex: V3_ADAPTER_VERSION, + cursor: V3_ADAPTER_VERSION, + copilot: V3_ADAPTER_VERSION, + zcode: V3_ADAPTER_VERSION, + }; +} diff --git a/src/commands/v3-support.ts b/src/commands/v3-support.ts new file mode 100644 index 0000000..5ceb6f3 --- /dev/null +++ b/src/commands/v3-support.ts @@ -0,0 +1,177 @@ +import path from 'node:path'; +import { + type StoredProjectSnapshot, + V3ContextStore, +} from '../context/store.js'; +import { readPlatformSessionSpike } from '../runtime/platform-spike-store.js'; +import { + type SessionSpikePlatform, + evaluatePlatformSessionCapability, +} from '../runtime/platform-spike.js'; +import { + type ProjectRuntimeContext, + readProjectRuntimeContext, +} from '../runtime/project-runtime.js'; +import { createSessionIdentityProvider } from '../runtime/session-identity.js'; +import { + type SessionStateV1, + resolveSessionCandidate, +} from '../runtime/session.js'; +import { readLocalActor } from '../team/actor.js'; + +export const EXIT_V3_OK = 0; +export const EXIT_V3_INVALID_ARGUMENT = 2; +export const EXIT_V3_BLOCKED = 3; + +export interface V3CommandProject { + projectRoot: string; + runtime: ProjectRuntimeContext; + store: V3ContextStore; + project: StoredProjectSnapshot; +} + +export async function readV3CommandProject( + projectRoot: string, +): Promise { + const root = path.resolve(projectRoot); + const runtime = await readProjectRuntimeContext(root); + const store = new V3ContextStore(root); + return { + projectRoot: root, + runtime, + store, + project: await store.readProjectSnapshot(), + }; +} + +/** + * There is intentionally no process-global client pointer. A caller either + * supplies a session, inherits MANCODE_SESSION_ID, or receives a precise + * bootstrap error. + */ +export async function resolveV3CommandSession( + project: V3CommandProject, + input: { session?: string; client?: string }, +): Promise { + const client = commandClient(input.client); + const candidate = await resolveV3SessionCandidate(project, input, client); + const actor = + candidate?.source === 'host' + ? await readLocalActor(project.projectRoot) + : null; + const session = await resolveSessionCandidate( + project.projectRoot, + candidate, + actor?.actorId, + ); + if (session === null) throw new Error('MANCODE_SESSION_REQUIRED'); + return session; +} + +/** + * A Context Pack may be inspected without a session, but only an explicit + * TaskRef can locate the task in that case. Mutating commands continue to use + * resolveV3CommandSession and therefore require a reliable identity. + */ +export async function resolveV3ReadSession( + project: V3CommandProject, + input: { session?: string; client?: string }, +): Promise { + const client = commandClient(input.client); + const candidate = await resolveV3SessionCandidate(project, input, client); + const actor = + candidate?.source === 'host' + ? await readLocalActor(project.projectRoot) + : null; + return resolveSessionCandidate( + project.projectRoot, + candidate, + actor?.actorId, + ); +} + +async function resolveV3SessionCandidate( + project: V3CommandProject, + input: { session?: string; client?: string }, + client: string, +) { + const platform = platformForClient(client); + const spike = + platform === null + ? null + : await readPlatformSessionSpike(project.projectRoot, platform); + const hostIdentityCapability = + spike === null + ? 'explicit_required' + : evaluatePlatformSessionCapability(spike).hostIdentity; + const hostSessionKey = process.env.MANCODE_HOST_SESSION_KEY; + const provider = createSessionIdentityProvider(project.runtime.workspaceId, { + hostIdentityCapability, + }); + return provider.resolveCandidate({ + explicitSessionId: input.session, + environment: process.env, + ...(hostIdentityCapability === 'host_verified' && hostSessionKey + ? { + trustedHostInput: { + externalSessionKey: hostSessionKey, + propagatesToCommands: true, + }, + } + : {}), + client, + }); +} + +function platformForClient(client: string): SessionSpikePlatform | null { + return client === 'claude-code' || + client === 'codex' || + client === 'cursor' || + client === 'copilot' || + client === 'zcode' + ? client + : null; +} + +export function commandClient(value: string | undefined): string { + const client = value ?? 'mancode-cli'; + if (!client.trim() || client.includes('\0')) { + throw new Error('MANCODE_CLIENT_INVALID'); + } + return client.trim(); +} + +export function printV3Result( + json: boolean | undefined, + result: unknown, +): number { + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(JSON.stringify(result, null, 2)); + } + return EXIT_V3_OK; +} + +export function printV3Error( + json: boolean | undefined, + code: string, + message: string, + exitCode: number = EXIT_V3_BLOCKED, +): number { + const result = { schemaVersion: 1, error: { code, message } }; + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.error(`✗ ${code}`); + console.error(` ${message}`); + } + return exitCode; +} + +export function v3ErrorCode(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.startsWith('MANCODE_')) { + return error.message.split(':', 1)[0] ?? fallback; + } + return fallback; +} diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index 28c26e4..bd11ce6 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -1,5 +1,24 @@ import { access, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { mergeV3ChildResult } from '../context/child-result-merge.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { parseSchemaManifest } from '../context/manifest.js'; +import { reviseV3Plan } from '../context/plan-revision.js'; +import { + previewV3TaskPromotion, + promoteV3Task, +} from '../context/publish-promote.js'; +import { finalizeV3Requirements } from '../context/requirements-finalize.js'; +import { applyV3ReviewLedger } from '../context/review-remediation.js'; +import { changeV3WorkflowScope } from '../context/scope-change.js'; +import { + completeV3SoloHandoff, + startV3SoloHandoff, +} from '../context/solo-handoff.js'; +import { completeV3Task } from '../context/task-complete.js'; +import { parseTaskRef } from '../context/task-ref.js'; +import { recordV3Verification } from '../context/verification-record.js'; +import { createV3Workflow } from '../context/workflow-create.js'; import { parseRequirementsLedger, readRequirementsLedger, @@ -46,6 +65,14 @@ import { readWorkflow, updateWorkflow, } from '../system/workflow.js'; +import { + commandClient, + printV3Error, + printV3Result, + readV3CommandProject, + resolveV3CommandSession, + v3ErrorCode, +} from './v3-support.js'; export const EXIT_OK = 0; export const EXIT_NOT_INITIALIZED = 1; @@ -59,6 +86,18 @@ export interface WorkflowOptions { status?: string; skipped?: string; parentTask?: string; + parent?: string; + participants?: string[]; + visibility?: string; + coordination?: string; + session?: string; + client?: string; + sync?: boolean; + confirmShared?: boolean; + expectedRevision?: string; + childRevision?: string; + summary?: string; + nextAction?: string; blockingReason?: string; outcome?: string; planVersion?: string; @@ -101,7 +140,18 @@ export async function workflow( args: string[] = [], options: WorkflowOptions = {}, ): Promise { + const v3Activation = await readV3ActivationState(rootDir); + if (v3Activation === 'v3_active') { + return workflowV3(rootDir, subcommand, args, options); + } if (!(await pathExists(path.join(rootDir, '.mancode', 'state.json')))) { + if (v3Activation !== null) { + return printV3Error( + options.json, + 'MANCODE_V3_WRITE_REQUIRES_ACTIVATION', + 'This project has staged V3 context but remains in dual-read migration. Use legacy workflows until activation.', + ); + } if (options.json) { console.log(JSON.stringify({ error: 'not initialized' }, null, 2)); } else { @@ -151,6 +201,809 @@ export async function workflow( } } +async function workflowV3( + rootDir: string, + subcommand: string, + args: string[], + options: WorkflowOptions, +): Promise { + if (subcommand === 'create') { + return workflowCreateV3(rootDir, args, options); + } + if (subcommand === 'requirements') { + return workflowRequirementsV3(rootDir, args, options); + } + if (subcommand === 'plan') { + return workflowPlanV3(rootDir, args, options); + } + if (subcommand === 'review') { + return workflowReviewV3(rootDir, args, options); + } + if (subcommand === 'verify') { + return workflowVerifyV3(rootDir, args, options); + } + if (subcommand === 'complete') { + return workflowCompleteV3(rootDir, args, options); + } + if (subcommand === 'scope') { + return workflowScopeChangeV3(rootDir, args, options); + } + if (subcommand === 'child') { + return workflowChildResultMergeV3(rootDir, args, options); + } + if (subcommand === 'promote') { + return workflowPromoteV3(rootDir, args, options); + } + if (subcommand === 'handoff') { + return workflowSoloHandoffV3(rootDir, args, options); + } + return printV3Error( + options.json, + 'MANCODE_V3_OPERATION_NOT_IMPLEMENTED', + `workflow ${subcommand} is not yet implemented for V3 authority.`, + ); +} + +async function workflowChildResultMergeV3( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const action = args[0]; + const parentTask = args[1]; + const childTask = args[2]; + if ( + action !== 'merge' || + !parentTask || + !childTask || + args.length !== 3 || + options.summary === undefined || + options.nextAction === undefined + ) { + return printV3Error( + options.json, + 'MANCODE_CHILD_RESULT_MERGE_ARGUMENT_INVALID', + 'Use: workflow child merge --expected-revision --child-revision --summary --next-action .', + EXIT_INVALID_ARG, + ); + } + const expectedParentRevision = parseExpectedTaskRevision(options); + const expectedChildRevision = + options.childRevision === undefined + ? null + : parseExactPositiveInteger(options.childRevision); + if (expectedParentRevision === null || expectedChildRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Child result merge requires positive --expected-revision and --child-revision values.', + EXIT_INVALID_ARG, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const result = await mergeV3ChildResult({ + projectRoot: project.projectRoot, + parentTaskRef: parseTaskRef(parentTask), + childTaskRef: parseTaskRef(childTask), + sessionId: session.sessionId, + expectedParentRevision, + expectedChildRevision, + summary: options.summary, + nextAction: options.nextAction, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.metadata.taskRef, + metadata: result.metadata, + checkpoint: result.checkpoint, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_CHILD_RESULT_MERGE_FAILED'), + error instanceof Error + ? error.message + : 'Unable to merge the V3 child diagnostic result.', + ); + } +} + +async function workflowSoloHandoffV3( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const task = args[0]; + if ( + !task || + args.length !== 1 || + (options.complete !== true && options.to !== 'solo') + ) { + return printV3Error( + options.json, + 'MANCODE_SOLO_HANDOFF_ARGUMENT_INVALID', + 'Use: workflow handoff --to solo --expected-revision , or workflow handoff --complete --expected-revision .', + EXIT_INVALID_ARG, + ); + } + if (options.complete === true && options.to !== undefined) { + return printV3Error( + options.json, + 'MANCODE_SOLO_HANDOFF_ARGUMENT_INVALID', + '--complete and --to cannot be combined.', + EXIT_INVALID_ARG, + ); + } + const expectedTaskRevision = parseExpectedTaskRevision(options); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Solo handoff requires --expected-revision .', + EXIT_INVALID_ARG, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const result = + options.complete === true + ? await completeV3SoloHandoff({ + projectRoot: project.projectRoot, + taskRef: parseTaskRef(task), + sessionId: session.sessionId, + expectedTaskRevision, + }) + : await startV3SoloHandoff({ + projectRoot: project.projectRoot, + taskRef: parseTaskRef(task), + sessionId: session.sessionId, + expectedTaskRevision, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.metadata.taskRef, + metadata: result.metadata, + aggregate: result.aggregate, + operation: result.operation, + sessionPointerUpdated: result.sessionPointerUpdated, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_SOLO_HANDOFF_FAILED'), + error instanceof Error + ? error.message + : 'Unable to run the V3 solo handoff.', + ); + } +} + +async function workflowPromoteV3( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const task = args[0]; + if (!task || args.length !== 1 || options.to !== 'manteam') { + return printV3Error( + options.json, + 'MANCODE_PROMOTION_ARGUMENT_INVALID', + 'Use: workflow promote --to manteam --expected-revision --confirm-shared.', + EXIT_INVALID_ARG, + ); + } + const expectedSourceRevision = parseExpectedTaskRevision(options); + if (expectedSourceRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Workflow promote requires --expected-revision .', + EXIT_INVALID_ARG, + ); + } + if (options.confirmShared !== true) { + return printV3Error( + options.json, + 'MANCODE_PRIVACY_CONFIRMATION_REQUIRED', + 'Workflow promote requires --confirm-shared before authority enters shared storage.', + EXIT_INVALID_ARG, + ); + } + if (options.sync === true) { + return printV3Error( + options.json, + 'MANCODE_GIT_REF_TRANSPORT_NOT_IMPLEMENTED', + 'Git-ref transport is not implemented for workflow promote.', + EXIT_INVALID_ARG, + ); + } + if (options.dryRun === true) { + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const preview = await previewV3TaskPromotion({ + projectRoot: project.projectRoot, + sourceTaskRef: parseTaskRef(task), + sessionActorId: session.actorId, + expectedSourceRevision, + destinationWorkflowMode: 'manteam', + client: commandClient(options.client), + }); + return printV3Result(options.json, { + schemaVersion: 1, + dryRun: true, + ...preview, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_WORKFLOW_PROMOTE_FAILED'), + error instanceof Error + ? error.message + : 'Unable to preview the V3 workflow promotion.', + ); + } + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const result = await promoteV3Task({ + projectRoot: project.projectRoot, + sourceTaskRef: parseTaskRef(task), + sessionId: session.sessionId, + expectedSourceRevision, + destinationWorkflowMode: 'manteam', + sharedPrivacyConfirmed: true, + client: commandClient(options.client), + }); + return printV3Result(options.json, { + schemaVersion: 1, + sourceMetadata: result.sourceMetadata, + taskRef: result.destinationMetadata.taskRef, + metadata: result.destinationMetadata, + aggregate: result.destinationAggregate, + taskHeadFence: result.destinationTaskHead, + quarantine: result.quarantine, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_WORKFLOW_PROMOTE_FAILED'), + error instanceof Error + ? error.message + : 'Unable to promote the local V3 workflow.', + ); + } +} + +async function workflowScopeChangeV3( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const action = args[0]; + const task = args[1]; + if (action !== 'change' || !task || args.length !== 2 || !options.file) { + return printV3Error( + options.json, + 'MANCODE_SCOPE_CHANGE_ARGUMENT_INVALID', + 'Use: workflow scope change --expected-revision --file .', + EXIT_INVALID_ARG, + ); + } + const expectedTaskRevision = parseExpectedTaskRevision(options); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Workflow scope change requires --expected-revision .', + EXIT_INVALID_ARG, + ); + } + try { + if (options.sync === true) { + throw new Error('MANCODE_GIT_REF_TRANSPORT_NOT_IMPLEMENTED'); + } + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const result = await changeV3WorkflowScope({ + projectRoot: project.projectRoot, + taskRef: parseTaskRef(task), + sessionId: session.sessionId, + expectedTaskRevision, + scope: await readWorkflowJsonInputFile(project.projectRoot, options.file), + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.metadata.taskRef, + metadata: result.metadata, + checkpoint: result.checkpoint, + terminatedClaims: result.terminatedClaims, + successorClaims: result.successorClaims, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_SCOPE_CHANGE_FAILED'), + error instanceof Error + ? error.message + : 'Unable to change the V3 workflow scope.', + ); + } +} + +async function workflowCompleteV3( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const task = args[0]; + if (!task || args.length !== 1) { + return printV3Error( + options.json, + 'MANCODE_COMPLETE_ARGUMENT_INVALID', + 'Use: workflow complete --expected-revision [--outcome ].', + EXIT_INVALID_ARG, + ); + } + const expectedTaskRevision = parseExpectedTaskRevision(options); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Task completion requires --expected-revision .', + EXIT_INVALID_ARG, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const result = await completeV3Task({ + projectRoot: project.projectRoot, + taskRef: parseTaskRef(task), + sessionId: session.sessionId, + expectedTaskRevision, + outcome: parseV3Outcome(options.outcome), + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.metadata.taskRef, + metadata: result.metadata, + releasedClaims: result.releasedClaims, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_TASK_COMPLETE_FAILED'), + error instanceof Error + ? error.message + : 'Unable to complete the V3 task.', + ); + } +} + +async function workflowVerifyV3( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const task = args[0]; + const action = args[1]; + if (!task || action !== 'apply' || !options.file) { + return printV3Error( + options.json, + 'MANCODE_VERIFICATION_ARGUMENT_INVALID', + 'Use: workflow verify apply --expected-revision --file .', + EXIT_INVALID_ARG, + ); + } + const expectedTaskRevision = parseExpectedTaskRevision(options); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Verification mutation requires --expected-revision .', + EXIT_INVALID_ARG, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const result = await recordV3Verification({ + projectRoot: project.projectRoot, + taskRef: parseTaskRef(task), + sessionId: session.sessionId, + expectedTaskRevision, + verification: await readWorkflowJsonInputFile( + project.projectRoot, + options.file, + ), + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.metadata.taskRef, + metadata: result.metadata, + verification: result.verification, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_VERIFICATION_RECORD_FAILED'), + error instanceof Error + ? error.message + : 'Unable to record V3 verification.', + ); + } +} + +async function workflowReviewV3( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const task = args[0]; + const action = args[1]; + if (!task || action !== 'apply' || !options.file) { + return printV3Error( + options.json, + 'MANCODE_REVIEW_ARGUMENT_INVALID', + 'Use: workflow review apply --expected-revision --file .', + EXIT_INVALID_ARG, + ); + } + const expectedTaskRevision = parseExpectedTaskRevision(options); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Review mutation requires --expected-revision .', + EXIT_INVALID_ARG, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const result = await applyV3ReviewLedger({ + projectRoot: project.projectRoot, + taskRef: parseTaskRef(task), + sessionId: session.sessionId, + expectedTaskRevision, + review: await readWorkflowJsonInputFile( + project.projectRoot, + options.file, + ), + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.metadata.taskRef, + metadata: result.metadata, + review: result.review, + verification: result.verification, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_REVIEW_APPLY_FAILED'), + error instanceof Error ? error.message : 'Unable to apply the V3 review.', + ); + } +} + +async function workflowPlanV3( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const task = args[0]; + const action = args[1]; + if (!task || (action !== 'revise' && action !== 'confirm')) { + return printV3Error( + options.json, + 'MANCODE_PLAN_ARGUMENT_INVALID', + 'Use: workflow plan revise --expected-revision --file , or confirm --expected-revision --plan-decision .', + EXIT_INVALID_ARG, + ); + } + const expectedTaskRevision = parseExpectedTaskRevision(options); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Plan mutation requires --expected-revision .', + EXIT_INVALID_ARG, + ); + } + if (action === 'revise' && !options.file) { + return printV3Error( + options.json, + 'MANCODE_PLAN_FILE_REQUIRED', + 'workflow plan revise requires --file .', + EXIT_INVALID_ARG, + ); + } + if (action === 'confirm' && options.planDecision === undefined) { + return printV3Error( + options.json, + 'MANCODE_PLAN_DECISION_REQUIRED', + 'workflow plan confirm requires --plan-decision plan_only or governed_execution.', + EXIT_INVALID_ARG, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const taskRef = parseTaskRef(task); + const plan = + action === 'revise' + ? await readWorkflowInputFile( + project.projectRoot, + options.file as string, + ) + : (await project.store.readTaskSnapshot(taskRef)).plan?.content; + if (plan === undefined || plan === null) { + throw new Error('MANCODE_PLAN_FILE_REQUIRED'); + } + const result = await reviseV3Plan({ + projectRoot: project.projectRoot, + taskRef, + sessionId: session.sessionId, + expectedTaskRevision, + plan, + planDecision: parseV3PlanDecision(options.planDecision), + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.metadata.taskRef, + metadata: result.metadata, + planDigest: result.planDigest, + review: result.review, + verification: result.verification, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_PLAN_REVISION_FAILED'), + error instanceof Error ? error.message : 'Unable to revise the V3 plan.', + ); + } +} + +async function workflowRequirementsV3( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const task = args[0]; + const action = args[1]; + if (!task || action !== 'finalize' || !options.file) { + return printV3Error( + options.json, + 'MANCODE_REQUIREMENTS_ARGUMENT_INVALID', + 'Use: workflow requirements finalize --expected-revision --file .', + EXIT_INVALID_ARG, + ); + } + const expectedTaskRevision = parseExpectedTaskRevision(options); + if (expectedTaskRevision === null) { + return printV3Error( + options.json, + 'MANCODE_EXPECTED_REVISION_REQUIRED', + 'Requirements finalization requires --expected-revision .', + EXIT_INVALID_ARG, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const requirements = await readWorkflowJsonInputFile( + project.projectRoot, + options.file, + ); + const result = await finalizeV3Requirements({ + projectRoot: project.projectRoot, + taskRef: parseTaskRef(task), + sessionId: session.sessionId, + expectedTaskRevision, + requirements, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.metadata.taskRef, + metadata: result.metadata, + requirements: result.requirements, + review: result.review, + verification: result.verification, + aggregate: result.aggregate, + taskHeadFence: result.taskHeadFence, + operation: result.operation, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_REQUIREMENTS_FINALIZE_FAILED'), + error instanceof Error + ? error.message + : 'Unable to finalize V3 requirements.', + ); + } +} + +function parseExpectedTaskRevision(options: WorkflowOptions): number | null { + return options.expectedRevision === undefined + ? null + : parseExactPositiveInteger(options.expectedRevision); +} + +function parseV3PlanDecision( + value: string | undefined, +): 'plan_only' | 'governed_execution' | undefined { + if (value === undefined) return undefined; + if (value === 'plan_only' || value === 'governed_execution') return value; + throw new Error('MANCODE_PLAN_DECISION_INVALID'); +} + +function parseV3Outcome( + value: string | undefined, +): 'fixed' | 'verified' | 'no_repro' | 'manual_test_required' | undefined { + if (value === undefined) return undefined; + if ( + value === 'fixed' || + value === 'verified' || + value === 'no_repro' || + value === 'manual_test_required' + ) { + return value; + } + throw new Error('MANCODE_WORKFLOW_OUTCOME_INVALID'); +} + +function parseV3ParticipantActorIds( + values: string[] | undefined, +): Ulid[] | undefined { + if (values === undefined) return undefined; + for (const value of values) { + assertUlid(value, 'workflow participant actorId'); + } + return values; +} + +async function readWorkflowInputFile( + projectRoot: string, + value: string, +): Promise { + const inputPath = path.isAbsolute(value) + ? value + : path.resolve(projectRoot, value); + return readFile(inputPath, 'utf8'); +} + +async function readWorkflowJsonInputFile( + projectRoot: string, + value: string, +): Promise { + return JSON.parse(await readWorkflowInputFile(projectRoot, value)); +} + +async function workflowCreateV3( + rootDir: string, + args: string[], + options: WorkflowOptions, +): Promise { + const workflowMode = args[0]; + const task = args.slice(1).join(' ').trim(); + if (!task || workflowMode === undefined) { + return printV3Error( + options.json, + 'MANCODE_WORKFLOW_CREATE_ARGUMENT_INVALID', + 'Use: workflow create .', + EXIT_INVALID_ARG, + ); + } + try { + const project = await readV3CommandProject(rootDir); + const session = await resolveV3CommandSession(project, options); + const parentOption = options.parent ?? options.parentTask; + if (options.parentTask !== undefined && options.parent === undefined) { + throw new Error( + 'MANCODE_TASK_REF_REQUIRED: use --parent local: or shared:', + ); + } + const result = await createV3Workflow({ + projectRoot: project.projectRoot, + task, + workflowMode: parseV3WorkflowMode(workflowMode), + sessionId: session.sessionId, + client: commandClient(options.client), + parentTaskRef: + parentOption === undefined ? null : parseTaskRef(parentOption), + visibility: parseV3Visibility(options.visibility), + coordination: parseV3Coordination(options.coordination), + participantActorIds: parseV3ParticipantActorIds(options.participants), + sharedPrivacyConfirmed: options.confirmShared === true, + }); + return printV3Result(options.json, { + schemaVersion: 1, + taskRef: result.taskRef, + metadata: result.metadata, + operation: result.operation, + dimensions: result.resolution.dimensions, + assessment: result.resolution.assessment, + sessionResumed: result.sessionResumed, + }); + } catch (error) { + return printV3Error( + options.json, + v3ErrorCode(error, 'MANCODE_V3_WORKFLOW_CREATE_FAILED'), + error instanceof Error ? error.message : 'Unable to create V3 workflow.', + ); + } +} + +async function readV3ActivationState( + rootDir: string, +): Promise<'v3_active' | 'other' | null> { + try { + const manifest = parseSchemaManifest( + JSON.parse( + await readFile(path.join(rootDir, '.mancode', 'schema.json'), 'utf8'), + ), + ); + return manifest.activationState === 'v3_active' ? 'v3_active' : 'other'; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return null; + throw error; + } +} + +function parseV3WorkflowMode(value: string): 'man' | 'manba' | 'manteam' { + if (value !== 'man' && value !== 'manba' && value !== 'manteam') { + throw new Error('MANCODE_WORKFLOW_MODE_INVALID'); + } + return value; +} + +function parseV3Visibility( + value: string | undefined, +): 'local' | 'shared' | undefined { + if (value === undefined) return undefined; + if (value !== 'local' && value !== 'shared') { + throw new Error('MANCODE_WORKFLOW_VISIBILITY_INVALID'); + } + return value; +} + +function parseV3Coordination( + value: string | undefined, +): 'single' | 'team' | undefined { + if (value === undefined) return undefined; + if (value !== 'single' && value !== 'team') { + throw new Error('MANCODE_WORKFLOW_COORDINATION_INVALID'); + } + return value; +} + async function workflowRequirements( rootDir: string, args: string[], diff --git a/src/context/aggregate.ts b/src/context/aggregate.ts new file mode 100644 index 0000000..c4ef7df --- /dev/null +++ b/src/context/aggregate.ts @@ -0,0 +1,468 @@ +import { type CheckpointV1, checkpointDigest } from '../team/checkpoints.js'; +import { digestCanonicalJson } from './canonical.js'; +import { type Ulid, assertUlid } from './ids.js'; +import { + type RequirementsLedgerV1, + requirementsAreReady, +} from './requirements-ledger.js'; +import { + type ReviewLedgerV1, + assertReviewLedgerAgainstContext, +} from './review-ledger.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; +import { + type VerificationLedgerV1, + assertVerificationLedgerAgainstContext, + assertVerificationLedgerRequirements, +} from './verification-ledger.js'; +import { + type WorkflowMetadataV3, + workflowMetadataDigest, +} from './workflow-metadata.js'; + +export interface TaskAggregateManifestV1 { + taskRef: TaskRef; + taskRevision: number; + ownershipEpoch: number; + metadataDigest: string; + requirementsDigest: string; + reviewDigest: string; + verificationDigest: string; + planVersion: number; + planDigest: string | null; + latestCheckpointId: Ulid | null; + latestCheckpointDigest: string | null; + parentSnapshotDigest: string | null; +} + +export interface TaskAggregateInput { + metadata: WorkflowMetadataV3; + requirements: RequirementsLedgerV1; + review: ReviewLedgerV1; + verification: VerificationLedgerV1; + planDigest: string | null; + latestCheckpoint: CheckpointV1 | null; +} + +export interface TaskCompletionContext { + activeChildTaskRefs: TaskRef[]; + hasPendingRepairOperation: boolean; + activeClaimCount: number; + claimsWillReleaseOrTransfer?: boolean; +} + +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function buildTaskAggregateManifest( + input: TaskAggregateInput, +): TaskAggregateManifestV1 { + assertTaskAggregateConsistency(input); + const latestCheckpoint = input.latestCheckpoint; + return { + taskRef: input.metadata.taskRef, + taskRevision: input.metadata.revision, + ownershipEpoch: input.metadata.ownershipEpoch, + metadataDigest: workflowMetadataDigest(input.metadata), + requirementsDigest: input.requirements.contentDigest, + reviewDigest: input.review.contentDigest, + verificationDigest: input.verification.contentDigest, + planVersion: input.metadata.governance.planVersion, + planDigest: input.planDigest, + latestCheckpointId: latestCheckpoint?.checkpointId ?? null, + latestCheckpointDigest: + latestCheckpoint === null ? null : checkpointDigest(latestCheckpoint), + parentSnapshotDigest: + input.metadata.parent === null + ? null + : digestCanonicalJson(input.metadata.parent), + }; +} + +export function taskAggregateDigest(manifest: TaskAggregateManifestV1): string { + return digestCanonicalJson(manifest); +} + +export function parseTaskAggregateManifest( + value: unknown, +): TaskAggregateManifestV1 { + assertRecord(value, 'task aggregate manifest'); + assertKnownKeys( + value, + [ + 'taskRef', + 'taskRevision', + 'ownershipEpoch', + 'metadataDigest', + 'requirementsDigest', + 'reviewDigest', + 'verificationDigest', + 'planVersion', + 'planDigest', + 'latestCheckpointId', + 'latestCheckpointDigest', + 'parentSnapshotDigest', + ], + 'task aggregate manifest', + ); + const latestCheckpointId = parseUlidOrNull( + value.latestCheckpointId, + 'task aggregate manifest latestCheckpointId', + ); + const latestCheckpointDigest = parseDigestOrNull( + value.latestCheckpointDigest, + 'task aggregate manifest latestCheckpointDigest', + ); + if ((latestCheckpointId === null) !== (latestCheckpointDigest === null)) { + throw new Error( + 'task aggregate manifest checkpoint ID and digest must be supplied together', + ); + } + return { + taskRef: parseTaskRefValue(value.taskRef), + taskRevision: parsePositiveInteger( + value.taskRevision, + 'task aggregate manifest taskRevision', + ), + ownershipEpoch: parseNonNegativeInteger( + value.ownershipEpoch, + 'task aggregate manifest ownershipEpoch', + ), + metadataDigest: parseDigest( + value.metadataDigest, + 'task aggregate manifest metadataDigest', + ), + requirementsDigest: parseDigest( + value.requirementsDigest, + 'task aggregate manifest requirementsDigest', + ), + reviewDigest: parseDigest( + value.reviewDigest, + 'task aggregate manifest reviewDigest', + ), + verificationDigest: parseDigest( + value.verificationDigest, + 'task aggregate manifest verificationDigest', + ), + planVersion: parsePositiveInteger( + value.planVersion, + 'task aggregate manifest planVersion', + ), + planDigest: parseDigestOrNull( + value.planDigest, + 'task aggregate manifest planDigest', + ), + latestCheckpointId, + latestCheckpointDigest, + parentSnapshotDigest: parseDigestOrNull( + value.parentSnapshotDigest, + 'task aggregate manifest parentSnapshotDigest', + ), + }; +} + +export function assertTaskAggregateConsistency( + input: TaskAggregateInput, +): void { + const { metadata, requirements, review, verification } = input; + assertDigestOrNull(input.planDigest, 'task aggregate planDigest'); + assertSameTaskRef(metadata.taskRef, requirements.taskRef, 'requirements'); + assertSameTaskRef(metadata.taskRef, review.taskRef, 'review ledger'); + assertSameTaskRef( + metadata.taskRef, + verification.taskRef, + 'verification ledger', + ); + assertRequirementsCache(metadata, requirements); + assertReviewCache(metadata, review, requirements); + assertVerificationCache(metadata, verification, requirements, review); + assertLatestCheckpoint(metadata, input.latestCheckpoint); +} + +export function assertTaskCompletionGate( + input: TaskAggregateInput, + context: TaskCompletionContext, +): void { + assertTaskAggregateConsistency(input); + const { metadata, requirements, review, verification } = input; + if (metadata.status !== 'in_progress' && metadata.status !== 'planned') { + throw new Error('only active workflows may pass the task completion gate'); + } + if (metadata.transitionState !== 'stable') { + throw new Error('workflows with a pending operation cannot complete'); + } + if (metadata.governance.planDecision === null) { + throw new Error('task completion requires a plan decision'); + } + if (metadata.governance.planDecision === 'solo_handoff') { + assertSoloHandoffCompletionGate(input, context); + return; + } + if (metadata.soloExecution?.state === 'active') { + throw new Error( + 'an active solo execution must complete before the workflow', + ); + } + if ( + metadata.governance.requirementsStatus !== 'ready' || + requirements.status !== 'confirmed' || + !requirementsAreReady(requirements) + ) { + throw new Error('task completion requires ready confirmed requirements'); + } + assertReviewLedgerAgainstContext(review, { + requirementsDigest: requirements.contentDigest, + planVersion: metadata.governance.planVersion, + }); + if (review.status !== 'passed' && review.status !== 'skipped') { + throw new Error( + 'task completion requires a current passing or skipped review', + ); + } + assertVerificationLedgerRequirements(verification, requirements); + assertVerificationLedgerAgainstContext(verification, { + requirementsDigest: requirements.contentDigest, + planVersion: metadata.governance.planVersion, + remediationRound: review.remediationRound, + }); + if (verification.status !== 'passed') { + throw new Error( + 'task completion requires current required acceptance evidence', + ); + } + assertTaskCompletionContext(metadata, context); +} + +/** + * A solo handoff preserves the legacy contract: a confirmed plan is handed to + * one local session instead of traversing the governed review/verification + * stages. It still requires ready requirements, a stable completed assignment, + * a plan artifact, and the normal child/repair/claim context checks. + */ +function assertSoloHandoffCompletionGate( + input: TaskAggregateInput, + context: TaskCompletionContext, +): void { + const { metadata, requirements } = input; + if ( + metadata.workflowMode !== 'man' || + metadata.coordination !== 'single' || + metadata.soloExecution?.state !== 'completed' || + metadata.soloExecution.planVersion !== metadata.governance.planVersion || + input.planDigest === null + ) { + throw new Error('task completion solo handoff assignment is invalid'); + } + if ( + metadata.governance.requirementsStatus !== 'ready' || + requirements.status !== 'confirmed' || + !requirementsAreReady(requirements) + ) { + throw new Error('task completion requires ready confirmed requirements'); + } + assertTaskCompletionContext(metadata, context); +} + +function assertRequirementsCache( + metadata: WorkflowMetadataV3, + requirements: RequirementsLedgerV1, +): void { + if (metadata.governance.requirementsDigest !== requirements.contentDigest) { + throw new Error( + 'workflow metadata requirementsDigest must match the requirements ledger', + ); + } + const expectedStatus = + requirements.status === 'confirmed' && requirementsAreReady(requirements) + ? 'ready' + : 'needs_clarification'; + if (metadata.governance.requirementsStatus !== expectedStatus) { + throw new Error( + `workflow metadata requirementsStatus must be ${expectedStatus}`, + ); + } +} + +function assertReviewCache( + metadata: WorkflowMetadataV3, + review: ReviewLedgerV1, + requirements: RequirementsLedgerV1, +): void { + if (metadata.governance.reviewLedgerDigest !== review.contentDigest) { + throw new Error( + 'workflow metadata reviewLedgerDigest must match the review ledger', + ); + } + if (metadata.governance.reviewStatus !== review.status) { + throw new Error( + 'workflow metadata reviewStatus must match the review ledger', + ); + } + // Legacy migration may faithfully preserve a review that predates the + // requirements/plan foreign keys. Its dedicated compatibility gate was + // already checked during migration parity; ordinary V3 review writes must + // replace it with a fully bound ledger before changing governance state. + if ( + review.legacySource !== null && + (review.requirementsDigest === null || review.planVersion === null) + ) { + return; + } + assertReviewLedgerAgainstContext(review, { + requirementsDigest: requirements.contentDigest, + planVersion: metadata.governance.planVersion, + }); +} + +function assertVerificationCache( + metadata: WorkflowMetadataV3, + verification: VerificationLedgerV1, + requirements: RequirementsLedgerV1, + review: ReviewLedgerV1, +): void { + if ( + metadata.governance.verificationLedgerDigest !== verification.contentDigest + ) { + throw new Error( + 'workflow metadata verificationLedgerDigest must match the verification ledger', + ); + } + if (metadata.governance.verificationStatus !== verification.status) { + throw new Error( + 'workflow metadata verificationStatus must match the verification ledger', + ); + } + assertVerificationLedgerAgainstContext(verification, { + requirementsDigest: requirements.contentDigest, + planVersion: metadata.governance.planVersion, + remediationRound: review.remediationRound, + }); + if (verification.status !== 'stale') { + assertVerificationLedgerRequirements(verification, requirements); + } +} + +function assertLatestCheckpoint( + metadata: WorkflowMetadataV3, + latestCheckpoint: CheckpointV1 | null, +): void { + if (metadata.latestCheckpointRef === null) { + if (latestCheckpoint !== null) { + throw new Error( + 'an aggregate checkpoint requires metadata.latestCheckpointRef', + ); + } + return; + } + if (latestCheckpoint === null) { + throw new Error( + 'metadata.latestCheckpointRef requires an aggregate checkpoint', + ); + } + if ( + metadata.latestCheckpointRef.kind !== 'checkpoint' || + metadata.latestCheckpointRef.artifactId === undefined || + metadata.latestCheckpointRef.artifactId !== latestCheckpoint.checkpointId || + !sameTaskRef(metadata.latestCheckpointRef.taskRef, metadata.taskRef) || + !sameTaskRef(latestCheckpoint.taskRef, metadata.taskRef) + ) { + throw new Error( + 'metadata.latestCheckpointRef must identify the aggregate latest checkpoint', + ); + } +} + +function assertTaskCompletionContext( + metadata: WorkflowMetadataV3, + context: TaskCompletionContext, +): void { + if (!Array.isArray(context.activeChildTaskRefs)) { + throw new Error('task completion activeChildTaskRefs must be an array'); + } + for (const taskRef of context.activeChildTaskRefs) { + parseTaskRefValue(taskRef); + } + if (context.activeChildTaskRefs.length > 0) { + throw new Error('task completion requires no active child workflows'); + } + if (typeof context.hasPendingRepairOperation !== 'boolean') { + throw new Error( + 'task completion hasPendingRepairOperation must be boolean', + ); + } + if (context.hasPendingRepairOperation) { + throw new Error('task completion requires no pending repair operation'); + } + if ( + typeof context.activeClaimCount !== 'number' || + !Number.isSafeInteger(context.activeClaimCount) || + context.activeClaimCount < 0 + ) { + throw new Error( + 'task completion activeClaimCount must be a non-negative integer', + ); + } + if ( + context.claimsWillReleaseOrTransfer !== undefined && + typeof context.claimsWillReleaseOrTransfer !== 'boolean' + ) { + throw new Error( + 'task completion claimsWillReleaseOrTransfer must be boolean when supplied', + ); + } + if ( + metadata.coordination === 'team' && + context.activeClaimCount > 0 && + context.claimsWillReleaseOrTransfer !== true + ) { + throw new Error( + 'team task completion requires active claims to release or transfer in the same operation', + ); + } +} + +function assertSameTaskRef( + expected: TaskRef, + actual: TaskRef, + label: string, +): void { + if (!sameTaskRef(expected, actual)) { + throw new Error(`task aggregate ${label} must target the metadata TaskRef`); + } +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parseNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseDigestOrNull(value: unknown, label: string): string | null { + if (value === null) return null; + return parseDigest(value, label); +} + +function assertDigestOrNull(value: unknown, label: string): void { + parseDigestOrNull(value, label); +} + +function parseUlidOrNull(value: unknown, label: string): Ulid | null { + if (value === null) return null; + assertUlid(value, label); + return value; +} diff --git a/src/context/artifact-ref.ts b/src/context/artifact-ref.ts new file mode 100644 index 0000000..bd78c07 --- /dev/null +++ b/src/context/artifact-ref.ts @@ -0,0 +1,74 @@ +import { type Ulid, assertUlid } from './ids.js'; +import { + type TaskNamespace, + type TaskRef, + parseTaskRefValue, +} from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export type ArtifactKind = + | 'requirements' + | 'requirements_markdown' + | 'plan' + | 'review_ledger' + | 'verification_ledger' + | 'checkpoint' + | 'handoff' + | 'summary' + | 'review_report' + | 'evidence_summary'; + +export interface ArtifactRef { + taskRef: TaskRef; + kind: ArtifactKind; + artifactId?: Ulid; +} + +const ARTIFACT_KINDS = new Set([ + 'requirements', + 'requirements_markdown', + 'plan', + 'review_ledger', + 'verification_ledger', + 'checkpoint', + 'handoff', + 'summary', + 'review_report', + 'evidence_summary', +]); + +export function parseArtifactRef(value: unknown): ArtifactRef { + assertRecord(value, 'ArtifactRef'); + assertKnownKeys(value, ['taskRef', 'kind', 'artifactId'], 'ArtifactRef'); + if ( + typeof value.kind !== 'string' || + !ARTIFACT_KINDS.has(value.kind as ArtifactKind) + ) { + throw new Error('ArtifactRef kind is not supported'); + } + if (value.artifactId !== undefined) { + assertUlid(value.artifactId, 'ArtifactRef artifactId'); + } + return { + taskRef: parseTaskRefValue(value.taskRef), + kind: value.kind as ArtifactKind, + ...(value.artifactId === undefined ? {} : { artifactId: value.artifactId }), + }; +} + +/** + * Shared entities may never persist a reference into a local task namespace. + * Local entities are allowed to refer to a shared task. + */ +export function assertReferenceNamespace( + sourceNamespace: TaskNamespace, + target: TaskRef | ArtifactRef, +): void { + const targetNamespace = + 'taskRef' in target ? target.taskRef.namespace : target.namespace; + if (sourceNamespace === 'shared' && targetNamespace !== 'shared') { + throw new Error( + 'shared entities cannot reference local TaskRef or ArtifactRef values', + ); + } +} diff --git a/src/context/canonical.ts b/src/context/canonical.ts new file mode 100644 index 0000000..929542e --- /dev/null +++ b/src/context/canonical.ts @@ -0,0 +1,141 @@ +import { createHash } from 'node:crypto'; + +export const CANONICALIZATION_VERSION = 'mancode-jcs-v1'; + +export type CanonicalNumberPolicy = 'safe-integer' | 'finite'; + +export interface CanonicalizationOptions { + numberPolicy?: CanonicalNumberPolicy; +} + +/** + * RFC 8785-compatible JSON serialization for values that have already passed + * their entity schema. Schema parsers own field allowlists; this layer rejects + * non-JSON and unsafe scalar values before hashing. + */ +export function canonicalizeJson( + value: unknown, + options: CanonicalizationOptions = {}, +): string { + return canonicalizeValue(value, options.numberPolicy ?? 'safe-integer'); +} + +export function digestCanonicalJson( + value: unknown, + options: CanonicalizationOptions = {}, +): string { + const canonical = canonicalizeJson(value, options); + return `sha256:${createHash('sha256').update(canonical, 'utf8').digest('hex')}`; +} + +/** Collection fields in the V3 contracts are deduplicated and ordered by UTF-8 bytes. */ +export function sortUtf8StringSet(values: readonly string[]): string[] { + const unique = new Set(); + for (const value of values) { + assertCanonicalString(value, 'set item'); + unique.add(value); + } + return [...unique].sort((left, right) => + Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')), + ); +} + +function canonicalizeValue( + value: unknown, + numberPolicy: CanonicalNumberPolicy, +): string { + if (value === null) return 'null'; + if (typeof value === 'string') { + assertCanonicalString(value, 'string'); + return JSON.stringify(value); + } + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number') { + if (!Number.isFinite(value) || Object.is(value, -0)) { + throw new Error( + 'canonical JSON numbers must be finite and must not be negative zero', + ); + } + if (numberPolicy === 'safe-integer' && !Number.isSafeInteger(value)) { + throw new Error( + 'canonical JSON numbers must be safe integers for this schema', + ); + } + return JSON.stringify(value); + } + if (Array.isArray(value)) { + assertCanonicalArray(value); + return `[${value + .map((item) => canonicalizeValue(item, numberPolicy)) + .join(',')}]`; + } + if (isPlainObject(value)) { + assertCanonicalObject(value); + const keys = Object.keys(value).sort(); + return `{${keys + .map((key) => { + assertCanonicalString(key, 'object key'); + return `${JSON.stringify(key)}:${canonicalizeValue(value[key], numberPolicy)}`; + }) + .join(',')}}`; + } + throw new Error('canonical JSON only accepts plain JSON values'); +} + +/** Sparse arrays and hidden/symbol properties are not JSON values. */ +function assertCanonicalArray(value: unknown[]): void { + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) { + throw new Error('canonical JSON arrays must not be sparse'); + } + } + const ownKeys = Object.keys(value); + if (ownKeys.some((key) => !/^(0|[1-9]\d*)$/.test(key))) { + throw new Error('canonical JSON arrays must not have non-index properties'); + } + if ( + Object.getOwnPropertyNames(value).some( + (key) => key !== 'length' && !ownKeys.includes(key), + ) || + Object.getOwnPropertySymbols(value).length > 0 + ) { + throw new Error('canonical JSON arrays must not have hidden properties'); + } +} + +function assertCanonicalObject(value: Record): void { + const ownKeys = Object.keys(value); + if ( + Object.getOwnPropertyNames(value).some((key) => !ownKeys.includes(key)) || + Object.getOwnPropertySymbols(value).length > 0 + ) { + throw new Error('canonical JSON objects must not have hidden properties'); + } +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function assertCanonicalString(value: string, label: string): void { + if (value.includes('\0')) { + throw new Error(`canonical JSON ${label} must not contain NUL`); + } + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit < 0xd800 || codeUnit > 0xdfff) continue; + const next = value.charCodeAt(index + 1); + const isHigh = codeUnit <= 0xdbff; + const isLowNext = next >= 0xdc00 && next <= 0xdfff; + if (!isHigh || !isLowNext) { + throw new Error( + `canonical JSON ${label} must not contain a lone surrogate`, + ); + } + index += 1; + } +} diff --git a/src/context/checkpoint-create.ts b/src/context/checkpoint-create.ts new file mode 100644 index 0000000..b42ad14 --- /dev/null +++ b/src/context/checkpoint-create.ts @@ -0,0 +1,351 @@ +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + createCheckpointRecoveryAction, + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, +} from '../runtime/operation-recovery-payload.js'; +import { + readCheckoutBranch, + readCheckoutCodeHead, +} from '../runtime/project-runtime.js'; +import { + enqueueCacheInvalidationProjection, + reconcileProjectionIntents, +} from '../runtime/projection-outbox.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskEntityKey, + taskHeadEntityKey, + writeTaskAuthorityFile, + writeTaskCheckpoint, +} from '../runtime/task-operation.js'; +import { + type CheckpointKind, + type CheckpointV1, + parseCheckpoint, +} from '../team/checkpoints.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, +} from './aggregate.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { + assertTaskCodeHeadUnchanged, + nextTaskHeadFence, +} from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface CreateV3CheckpointInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + kind: CheckpointKind; + summary: string; + /** Defaults to a stable generic continuation instruction for CLI callers. */ + nextAction?: string; + checkpointId?: Ulid; + operationId?: Ulid; + now?: Date; +} + +export interface CreatedV3Checkpoint { + checkpoint: CheckpointV1; + metadata: WorkflowMetadataV3; + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1 | null; + operation: OperationJournalV1; +} + +/** + * Checkpoints are immutable task snapshots. The journal exposes the pending + * metadata state before the checkpoint is created, so an interrupted write + * can only be repaired forward and can never leave a stable metadata pointer + * to a missing or substituted checkpoint. + */ +export async function createV3Checkpoint( + input: CreateV3CheckpointInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + const now = input.now ?? new Date(); + const checkpointId = input.checkpointId ?? createUlid(now.getTime()); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(checkpointId, 'checkpointId'); + assertUlid(operationId, 'checkpoint operationId'); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId, + extraEntityLocks: [ + `checkpoint:${checkpointId}`, + ...(taskRef.namespace === 'shared' ? [taskHeadEntityKey(taskRef)] : []), + ], + now, + }); + let journal: OperationJournalV1 | null = null; + try { + assertCheckpointEligible(context.task.metadata); + const timestamp = context.now.toISOString(); + const pendingMetadata = markCheckpointOperationPending( + context.task.metadata, + context.operationId, + timestamp, + ); + const [branch, observedCodeHead] = await Promise.all([ + readCheckoutBranch(context.projectRoot), + taskRef.namespace === 'shared' + ? Promise.resolve(context.codeHead) + : readCheckoutCodeHead(context.projectRoot), + ]); + const checkpoint = parseCheckpoint({ + schemaVersion: 1, + checkpointId, + operationId: context.operationId, + taskRef, + taskRevision: pendingMetadata.revision, + ownershipEpochAtOffer: pendingMetadata.ownershipEpoch, + kind: input.kind, + git: { + branch, + head: observedCodeHead, + base: pendingMetadata.base?.head ?? null, + }, + summary: input.summary, + governance: { + requirementsDigest: context.task.requirements.contentDigest, + planVersion: pendingMetadata.governance.planVersion, + reviewLedgerDigest: context.task.review.contentDigest, + verificationLedgerDigest: context.task.verification.contentDigest, + }, + nextAction: + input.nextAction ?? 'Resume the workflow at its current step.', + createdBy: { + actorId: context.session.actorId, + client: context.session.client, + }, + createdAt: timestamp, + }); + const metadata = completeCheckpointMetadata( + pendingMetadata, + checkpoint, + context.operationId, + timestamp, + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: checkpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + + await Promise.all([ + enqueueCacheInvalidationProjection(context.projectRoot, { + operationId: context.operationId, + cacheKind: 'context_pack', + taskRef, + now: context.now, + }), + enqueueCacheInvalidationProjection(context.projectRoot, { + operationId: context.operationId, + cacheKind: 'status_index', + taskRef, + now: context.now, + }), + ]); + + journal = await createTaskOperationJournal(context, { + type: 'checkpoint_create', + action: + taskRef.namespace === 'shared' + ? 'shared_metadata_plan_mutation' + : 'local_workflow_mutation', + expectedRevisions: checkpointExpectedRevisions(context, checkpointId), + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-task-operation-pending', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(pendingMetadata), + }), + createCheckpointRecoveryAction({ + stepId: 'write-checkpoint', + before: null, + checkpoint, + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'update-metadata-checkpoint-ref', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(pendingMetadata), + targetContent: serializeTaskAuthority(metadata), + }), + ...(taskHeadFence === null + ? [] + : [ + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ]), + ], + noOpStepIds: taskHeadFence === null ? ['update-task-head-fence'] : [], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + + journal = await advanceTaskOperation( + context, + journal, + 'mark-task-operation-pending', + false, + ); + await writeTaskAuthorityFile( + context, + 'metadata.json', + `${JSON.stringify(pendingMetadata, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'write-checkpoint', + false, + ); + if (taskRef.namespace === 'shared') { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + } + await writeTaskCheckpoint(context, checkpoint); + + journal = await advanceTaskOperation( + context, + journal, + 'update-metadata-checkpoint-ref', + false, + ); + await writeTaskAuthorityFile( + context, + 'metadata.json', + `${JSON.stringify(metadata, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-task-head-fence', + false, + ); + if (taskHeadFence !== null) { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + } + const operation = await commitTaskOperation(context, journal); + try { + await reconcileProjectionIntents( + context.projectRoot, + context.operationId, + context.now, + ); + } catch { + // Cache invalidation is compensable and remains pending for doctor. + } + return { checkpoint, metadata, aggregate, taskHeadFence, operation }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // The completed write intent keeps the task in the repair envelope. + } + } + throw error; + } finally { + await context.release(); + } +} + +function assertCheckpointEligible(metadata: WorkflowMetadataV3): void { + if ( + metadata.status !== 'in_progress' && + metadata.status !== 'planned' && + metadata.status !== 'blocked' + ) { + throw new Error('MANCODE_CHECKPOINT_WORKFLOW_NOT_ACTIVE'); + } + if (metadata.transitionState !== 'stable') { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } +} + +function markCheckpointOperationPending( + previous: WorkflowMetadataV3, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'operation_pending', + lastOperationId: operationId, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function completeCheckpointMetadata( + previous: WorkflowMetadataV3, + checkpoint: CheckpointV1, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'stable', + lastOperationId: operationId, + latestCheckpointRef: { + taskRef: checkpoint.taskRef, + kind: 'checkpoint', + artifactId: checkpoint.checkpointId, + }, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function checkpointExpectedRevisions( + context: Awaited>, + checkpointId: Ulid, +): Record { + const expected: Record = { + [taskEntityKey(context.taskRef)]: context.task.metadata.revision, + [`checkpoint:${checkpointId}`]: 0, + }; + if (context.taskRef.namespace === 'shared') { + const fence = context.coordination.taskHeadFence; + if (fence === null) throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + expected[taskHeadEntityKey(context.taskRef)] = fence.fenceRevision; + } + return expected; +} diff --git a/src/context/child-result-merge.ts b/src/context/child-result-merge.ts new file mode 100644 index 0000000..8d7d434 --- /dev/null +++ b/src/context/child-result-merge.ts @@ -0,0 +1,496 @@ +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + createCheckpointRecoveryAction, + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, +} from '../runtime/operation-recovery-payload.js'; +import { + readCheckoutBranch, + readCheckoutCodeHead, +} from '../runtime/project-runtime.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { assertTaskHeadFenceMatchesAggregate } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + type OpenedV3TaskOperation, + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskEntityKey, + taskHeadEntityKey, + writeTaskAuthorityFile, + writeTaskCheckpoint, +} from '../runtime/task-operation.js'; +import { type CheckpointV1, parseCheckpoint } from '../team/checkpoints.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, +} from './aggregate.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { assertSharedTextSafe } from './privacy.js'; +import type { StoredTaskSnapshot } from './store.js'; +import { + assertTaskCodeHeadUnchanged, + nextTaskHeadFence, +} from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import { + type WorkflowMetadataV3, + assertParentWorkflowRelation, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface MergeV3ChildResultInput { + projectRoot: string; + parentTaskRef: TaskRef; + childTaskRef: TaskRef; + sessionId: Ulid; + expectedParentRevision: number; + expectedChildRevision: number; + /** A privacy-screened, user-confirmed diagnostic result summary. */ + summary: string; + nextAction: string; + checkpointId?: Ulid; + operationId?: Ulid; + now?: Date; +} + +export interface MergedV3ChildResult { + metadata: WorkflowMetadataV3; + checkpoint: CheckpointV1; + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1 | null; + operation: OperationJournalV1; +} + +/** + * Merges one completed `manba` child's typed outcome into its parent under + * the parent lock. A child snapshot is deliberately never refreshed here: + * if the parent moved since the child was created, its result is stale and + * the caller must explicitly recreate the diagnostic task instead of guessing + * which version of the parent the result applies to. + */ +export async function mergeV3ChildResult( + input: MergeV3ChildResultInput, +): Promise { + const parentTaskRef = parseTaskRefValue(input.parentTaskRef); + const childTaskRef = parseTaskRefValue(input.childTaskRef); + if (sameTaskRef(parentTaskRef, childTaskRef)) { + throw new Error('MANCODE_CHILD_RESULT_PARENT_REQUIRED'); + } + if (parentTaskRef.namespace !== childTaskRef.namespace) { + throw new Error('MANCODE_CHILD_RESULT_NAMESPACE_MISMATCH'); + } + assertPositiveRevision( + input.expectedParentRevision, + 'child merge expected parent revision', + ); + assertPositiveRevision( + input.expectedChildRevision, + 'child merge expected child revision', + ); + const summary = requireText(input.summary, 'child result summary'); + const nextAction = requireText(input.nextAction, 'child result nextAction'); + const now = input.now ?? new Date(); + const checkpointId = input.checkpointId ?? createUlid(now.getTime()); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(checkpointId, 'child merge checkpointId'); + assertUlid(operationId, 'child merge operationId'); + + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef: parentTaskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedParentRevision, + operationId, + extraEntityLocks: [ + taskEntityKey(childTaskRef), + `checkpoint:${checkpointId}`, + ...(parentTaskRef.namespace === 'shared' + ? [taskHeadEntityKey(parentTaskRef), taskHeadEntityKey(childTaskRef)] + : []), + ], + now, + }); + let journal: OperationJournalV1 | null = null; + try { + const [child, childCoordination, branch, observedCodeHead] = + await Promise.all([ + context.store.readTaskSnapshot(childTaskRef), + context.store.readCoordinationSnapshot(childTaskRef, context.homeStore), + readCheckoutBranch(context.projectRoot), + parentTaskRef.namespace === 'shared' + ? Promise.resolve(context.codeHead) + : readCheckoutCodeHead(context.projectRoot), + ]); + assertChildResultMergeEligible( + context, + child, + childCoordination.pendingOperations.length, + input.expectedChildRevision, + ); + assertChildSharedFence(context, child, childCoordination.taskHeadFence); + + const timestamp = context.now.toISOString(); + const pendingMetadata = markParentOperationPending( + context.task.metadata, + context.operationId, + timestamp, + ); + const checkpoint = buildChildMergeCheckpoint({ + context, + child, + pendingMetadata, + checkpointId, + summary, + nextAction, + branch, + codeHead: observedCodeHead, + timestamp, + }); + const metadata = completeParentMergeMetadata( + pendingMetadata, + child.metadata, + checkpoint, + context.operationId, + timestamp, + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: checkpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + + journal = await createTaskOperationJournal(context, { + type: 'child_result_merge', + action: + parentTaskRef.namespace === 'shared' + ? 'task_complete_scope_change_child_merge' + : 'local_workflow_mutation', + expectedRevisions: childMergeExpectedRevisions( + context, + child, + childCoordination.taskHeadFence, + checkpointId, + ), + conditions: + parentTaskRef.namespace === 'shared' + ? { + // This action family is the owner-only, fresh-epoch mutation + // path. The child/parent snapshot gate above is its concrete + // precondition; it does not claim that the parent is complete. + completionGateSatisfied: true, + requiresParentOwner: true, + parentOwnerActorId: context.task.metadata.ownerActorId, + } + : undefined, + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-parent-operation-pending', + taskRef: parentTaskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(pendingMetadata), + }), + createCheckpointRecoveryAction({ + stepId: 'write-merge-checkpoint', + before: null, + checkpoint, + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'update-parent-metadata', + taskRef: parentTaskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(pendingMetadata), + targetContent: serializeTaskAuthority(metadata), + }), + ...(taskHeadFence === null + ? [] + : [ + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ]), + ], + noOpStepIds: taskHeadFence === null ? ['update-task-head-fence'] : [], + }, + }); + journal = await advanceTaskOperation( + context, + journal, + 'validate-parent-snapshot', + true, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'mark-parent-operation-pending', + false, + ); + await writeTaskAuthorityFile( + context, + 'metadata.json', + serializeTaskAuthority(pendingMetadata), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'write-merge-checkpoint', + false, + ); + if (parentTaskRef.namespace === 'shared') { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + } + await writeTaskCheckpoint(context, checkpoint); + + journal = await advanceTaskOperation( + context, + journal, + 'update-parent-metadata', + false, + ); + if (parentTaskRef.namespace === 'shared') { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + } + await writeTaskAuthorityFile( + context, + 'metadata.json', + serializeTaskAuthority(metadata), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-task-head-fence', + false, + ); + if (taskHeadFence !== null) { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + } + const operation = await commitTaskOperation(context, journal); + return { metadata, checkpoint, aggregate, taskHeadFence, operation }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // Once a parent write intent is durable, it must remain repairable. + } + } + throw error; + } finally { + await context.release(); + } +} + +function assertChildResultMergeEligible( + context: OpenedV3TaskOperation, + child: StoredTaskSnapshot, + childPendingOperationCount: number, + expectedChildRevision: number, +): void { + const parent = context.task.metadata; + if ( + (parent.workflowMode !== 'man' && parent.workflowMode !== 'manteam') || + (parent.status !== 'in_progress' && parent.status !== 'blocked') || + parent.currentStep !== 6 || + parent.ownerActorId !== context.session.actorId + ) { + throw new Error('MANCODE_CHILD_RESULT_PARENT_NOT_ELIGIBLE'); + } + if ( + child.aggregate === null || + child.metadata.workflowMode !== 'manba' || + child.metadata.status !== 'completed' || + child.metadata.outcome === null || + child.metadata.transitionState !== 'stable' || + child.metadata.revision !== expectedChildRevision || + childPendingOperationCount > 0 + ) { + throw new Error('MANCODE_CHILD_RESULT_NOT_READY'); + } + try { + assertParentWorkflowRelation(child.metadata, parent); + } catch { + throw new Error('MANCODE_CHILD_RESULT_PARENT_MISMATCH'); + } + const snapshot = child.metadata.parent; + if (snapshot === null || !sameTaskRef(snapshot.taskRef, parent.taskRef)) { + throw new Error('MANCODE_CHILD_RESULT_PARENT_MISMATCH'); + } + const stale = + snapshot.revisionAtCreate !== parent.revision || + snapshot.planVersionAtCreate !== parent.governance.planVersion || + snapshot.requirementsDigestAtCreate !== + parent.governance.requirementsDigest || + snapshot.implementationScopeDigestAtCreate !== + parent.implementationScope.digest || + snapshot.visibility !== parent.visibility || + snapshot.coordination !== parent.coordination; + if (stale) throw new Error('MANCODE_PARENT_STALE'); +} + +function assertChildSharedFence( + context: OpenedV3TaskOperation, + child: StoredTaskSnapshot, + fence: TaskHeadFenceV1 | null, +): void { + if (child.metadata.taskRef.namespace !== 'shared') return; + if (child.aggregate === null || fence === null || context.codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + } + assertTaskHeadFenceMatchesAggregate(fence, child.aggregate, context.codeHead); +} + +function markParentOperationPending( + previous: WorkflowMetadataV3, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'operation_pending', + lastOperationId: operationId, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function buildChildMergeCheckpoint(input: { + context: OpenedV3TaskOperation; + child: StoredTaskSnapshot; + pendingMetadata: WorkflowMetadataV3; + checkpointId: Ulid; + summary: string; + nextAction: string; + branch: string | null; + codeHead: string | null; + timestamp: string; +}): CheckpointV1 { + const outcome = input.child.metadata.outcome; + if (outcome === null) throw new Error('MANCODE_CHILD_RESULT_NOT_READY'); + const childRef = `${input.child.metadata.taskRef.namespace}:${input.child.metadata.taskRef.taskId}`; + const summary = `Merged completed child ${childRef} (${outcome}): ${input.summary}`; + const nextAction = + outcome === 'manual_test_required' + ? `Resolve required manual testing for ${childRef}. ${input.nextAction}` + : input.nextAction; + // The checkpoint parser applies the same content safety rules regardless + // of namespace. Running the checks here produces a stable command error + // before a journal is created. + assertSharedTextSafe(summary, 'child merge checkpoint summary'); + assertSharedTextSafe(nextAction, 'child merge checkpoint nextAction'); + return parseCheckpoint({ + schemaVersion: 1, + checkpointId: input.checkpointId, + operationId: input.context.operationId, + taskRef: input.context.taskRef, + taskRevision: input.pendingMetadata.revision, + ownershipEpochAtOffer: input.pendingMetadata.ownershipEpoch, + kind: + outcome === 'manual_test_required' ? 'blocked' : 'verification_completed', + git: { + branch: input.branch, + head: input.codeHead, + base: input.pendingMetadata.base?.head ?? null, + }, + summary, + governance: { + requirementsDigest: input.context.task.requirements.contentDigest, + planVersion: input.pendingMetadata.governance.planVersion, + reviewLedgerDigest: input.context.task.review.contentDigest, + verificationLedgerDigest: input.context.task.verification.contentDigest, + }, + nextAction, + createdBy: { + actorId: input.context.session.actorId, + client: input.context.session.client, + }, + createdAt: input.timestamp, + }); +} + +function completeParentMergeMetadata( + previous: WorkflowMetadataV3, + child: WorkflowMetadataV3, + checkpoint: CheckpointV1, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const needsManualTesting = child.outcome === 'manual_test_required'; + const childRef = `${child.taskRef.namespace}:${child.taskRef.taskId}`; + const next = parseWorkflowMetadata({ + ...previous, + ...(needsManualTesting + ? { + status: 'blocked', + blockingReason: `Child ${childRef} requires manual testing.`, + } + : {}), + revision: previous.revision + 1, + transitionState: 'stable', + lastOperationId: operationId, + latestCheckpointRef: { + taskRef: previous.taskRef, + kind: 'checkpoint', + artifactId: checkpoint.checkpointId, + }, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function childMergeExpectedRevisions( + context: OpenedV3TaskOperation, + child: StoredTaskSnapshot, + childFence: TaskHeadFenceV1 | null, + checkpointId: Ulid, +): Record { + const expected: Record = { + [taskEntityKey(context.taskRef)]: context.task.metadata.revision, + [taskEntityKey(child.metadata.taskRef)]: child.metadata.revision, + [`checkpoint:${checkpointId}`]: 0, + }; + if (context.taskRef.namespace === 'shared') { + const parentFence = context.coordination.taskHeadFence; + if (parentFence === null || childFence === null) { + throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + } + expected[taskHeadEntityKey(context.taskRef)] = parentFence.fenceRevision; + expected[taskHeadEntityKey(child.metadata.taskRef)] = + childFence.fenceRevision; + } + return expected; +} + +function requireText(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error( + `MANCODE_${label.toUpperCase().replaceAll(' ', '_')}_REQUIRED`, + ); + } + return value; +} + +function assertPositiveRevision(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } +} diff --git a/src/context/compatibility.ts b/src/context/compatibility.ts new file mode 100644 index 0000000..4140b4d --- /dev/null +++ b/src/context/compatibility.ts @@ -0,0 +1,215 @@ +import type { + LegacyBaseline, + ManagedAdapter, + SchemaManifestV1, +} from './manifest.js'; + +export type CompatibilityOperation = + | 'read' + | 'v3_business_write' + | 'migration_stage' + | 'activation_repair' + | 'greenfield_initialize'; + +export type CompatibilityFailureCode = + | 'MANCODE_SCHEMA_EPOCH_MISMATCH' + | 'MANCODE_READER_VERSION_TOO_OLD' + | 'MANCODE_WRITER_VERSION_TOO_OLD' + | 'MANCODE_ADAPTER_VERSION_MISMATCH' + | 'MANCODE_LEGACY_BASELINE_CHANGED' + | 'MANCODE_LEGACY_AUTHORITY_PRESENT' + | 'MANCODE_V3_WRITE_REQUIRES_ACTIVATION' + | 'MANCODE_ACTIVATION_IN_PROGRESS' + | 'MANCODE_REPAIR_REQUIRED'; + +export interface CompatibilityGateInput { + manifest: SchemaManifestV1; + expectedSchemaEpoch: string; + readerVersion: string; + writerVersion: string; + adapterVersions: Partial>; + currentLegacyBaseline: LegacyBaseline | null; + legacyAuthorityPresent: boolean; + operation: CompatibilityOperation; +} + +export interface CompatibilityGateResult { + readAllowed: boolean; + writeAllowed: boolean; + failures: CompatibilityFailureCode[]; +} + +/** + * This gate is shared by readers, writers, migration staging, and repair. It + * does not mutate the manifest: callers choose the journaled repair path after + * receiving a precise failure code. + */ +export function evaluateCompatibilityGate( + input: CompatibilityGateInput, +): CompatibilityGateResult { + const failures: CompatibilityFailureCode[] = []; + if (input.manifest.epoch !== input.expectedSchemaEpoch) { + failures.push('MANCODE_SCHEMA_EPOCH_MISMATCH'); + } + if (compareSemver(input.readerVersion, input.manifest.minReaderVersion) < 0) { + failures.push('MANCODE_READER_VERSION_TOO_OLD'); + } + if ( + input.operation !== 'read' && + compareSemver(input.writerVersion, input.manifest.minWriterVersion) < 0 + ) { + failures.push('MANCODE_WRITER_VERSION_TOO_OLD'); + } + if (!adaptersMatch(input.manifest, input.adapterVersions)) { + failures.push('MANCODE_ADAPTER_VERSION_MISMATCH'); + } + const baselineFailure = baselineFailureFor(input); + if (baselineFailure !== null) failures.push(baselineFailure); + const stateFailure = stateFailureFor(input); + if (stateFailure !== null) failures.push(stateFailure); + const readAllowed = + !failures.includes('MANCODE_SCHEMA_EPOCH_MISMATCH') && + !failures.includes('MANCODE_READER_VERSION_TOO_OLD'); + const writeAllowed = readAllowed && failures.length === 0; + return { readAllowed, writeAllowed, failures }; +} + +export function assertCompatibilityGate(input: CompatibilityGateInput): void { + const result = evaluateCompatibilityGate(input); + const allowed = + input.operation === 'read' ? result.readAllowed : result.writeAllowed; + if (!allowed) { + throw new Error(result.failures[0] ?? 'MANCODE_COMPATIBILITY_BLOCKED'); + } +} + +export function compareSemver(left: string, right: string): number { + const parsedLeft = parseSemver(left, 'left semantic version'); + const parsedRight = parseSemver(right, 'right semantic version'); + for (const index of [0, 1, 2] as const) { + const delta = parsedLeft.core[index] - parsedRight.core[index]; + if (delta !== 0) return delta; + } + if (parsedLeft.prerelease === parsedRight.prerelease) return 0; + if (parsedLeft.prerelease === null) return 1; + if (parsedRight.prerelease === null) return -1; + return comparePrerelease(parsedLeft.prerelease, parsedRight.prerelease); +} + +function baselineFailureFor( + input: CompatibilityGateInput, +): CompatibilityFailureCode | null { + const { manifest, currentLegacyBaseline, legacyAuthorityPresent } = input; + if (manifest.activationState === 'initializing') { + return legacyAuthorityPresent ? 'MANCODE_LEGACY_AUTHORITY_PRESENT' : null; + } + if ( + manifest.activationState === 'dual_read' || + manifest.activationState === 'activating' + ) { + if (manifest.legacyBaseline === null || currentLegacyBaseline === null) { + return 'MANCODE_LEGACY_BASELINE_CHANGED'; + } + return sameBaseline(manifest.legacyBaseline, currentLegacyBaseline) + ? null + : 'MANCODE_LEGACY_BASELINE_CHANGED'; + } + if (manifest.activationState === 'v3_active') { + if (manifest.legacyBaseline === null) { + return legacyAuthorityPresent ? 'MANCODE_LEGACY_AUTHORITY_PRESENT' : null; + } + if (currentLegacyBaseline === null) { + return 'MANCODE_LEGACY_BASELINE_CHANGED'; + } + return sameBaseline(manifest.legacyBaseline, currentLegacyBaseline) + ? null + : 'MANCODE_LEGACY_BASELINE_CHANGED'; + } + return null; +} + +function stateFailureFor( + input: CompatibilityGateInput, +): CompatibilityFailureCode | null { + const { activationState } = input.manifest; + switch (activationState) { + case 'initializing': + return input.operation === 'greenfield_initialize' || + input.operation === 'read' + ? null + : 'MANCODE_V3_WRITE_REQUIRES_ACTIVATION'; + case 'dual_read': + return input.operation === 'read' || input.operation === 'migration_stage' + ? null + : 'MANCODE_V3_WRITE_REQUIRES_ACTIVATION'; + case 'activating': + return input.operation === 'activation_repair' || + input.operation === 'read' + ? null + : 'MANCODE_ACTIVATION_IN_PROGRESS'; + case 'v3_active': + return null; + case 'repair_required': + return input.operation === 'activation_repair' || + input.operation === 'read' + ? null + : 'MANCODE_REPAIR_REQUIRED'; + } +} + +function adaptersMatch( + manifest: SchemaManifestV1, + actual: Partial>, +): boolean { + return (Object.keys(manifest.managedAdapters) as ManagedAdapter[]).every( + (adapter) => actual[adapter] === manifest.managedAdapters[adapter], + ); +} + +function sameBaseline(left: LegacyBaseline, right: LegacyBaseline): boolean { + return ( + left.stateDigest === right.stateDigest && + left.workflowIndexDigest === right.workflowIndexDigest + ); +} + +interface ParsedSemver { + core: [number, number, number]; + prerelease: string | null; +} + +function parseSemver(value: string, label: string): ParsedSemver { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(value); + if (!match) throw new Error(`${label} is invalid`); + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + if (![major, minor, patch].every(Number.isSafeInteger)) { + throw new Error(`${label} is invalid`); + } + return { + core: [major, minor, patch], + prerelease: match[4] ?? null, + }; +} + +function comparePrerelease(left: string, right: string): number { + const leftParts = left.split('.'); + const rightParts = right.split('.'); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const leftPart = leftParts[index]; + const rightPart = rightParts[index]; + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + if (leftPart === rightPart) continue; + const leftNumeric = /^\d+$/.test(leftPart); + const rightNumeric = /^\d+$/.test(rightPart); + if (leftNumeric && rightNumeric) + return Number(leftPart) - Number(rightPart); + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return leftPart.localeCompare(rightPart, 'en'); + } + return 0; +} diff --git a/src/context/confirmed-decision.ts b/src/context/confirmed-decision.ts new file mode 100644 index 0000000..913f425 --- /dev/null +++ b/src/context/confirmed-decision.ts @@ -0,0 +1,340 @@ +import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + type AuthorizationBasisV1, + parseAuthorizationBasis, +} from '../team/authorization.js'; +import { digestCanonicalJson } from './canonical.js'; +import { type Ulid, assertUlid } from './ids.js'; +import { assertSharedTextSafe } from './privacy.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +/** One privacy-reviewed, immutable decision that may inform shared planning. */ +export interface ConfirmedDecisionV1 { + schemaVersion: 1; + decisionId: Ulid; + title: string; + statement: string; + taskRef: TaskRef | null; + confirmedByActorId: Ulid; + confirmedAt: string; + operationId: Ulid; + authorization: AuthorizationBasisV1; +} + +export interface CreateConfirmedDecisionInput { + decisionId: Ulid; + title: string; + statement: string; + taskRef?: TaskRef | null; + actorId: Ulid; + operationId: Ulid; + authorization: AuthorizationBasisV1; + now?: Date; +} + +const DECISION_FILENAME = /^[0-7][0-9A-HJKMNPQRSTVWXYZ]{25}\.json$/; + +export function createConfirmedDecision( + input: CreateConfirmedDecisionInput, +): ConfirmedDecisionV1 { + assertUlid(input.decisionId, 'confirmed decision decisionId'); + assertUlid(input.actorId, 'confirmed decision actorId'); + assertUlid(input.operationId, 'confirmed decision operationId'); + const authorization = parseAuthorizationBasis(input.authorization); + if ( + authorization.action !== 'confirmed_decision_publish' || + authorization.actorId !== input.actorId + ) { + throw new Error('MANCODE_CONFIRMED_DECISION_AUTHORIZATION_INVALID'); + } + return parseConfirmedDecision({ + schemaVersion: 1, + decisionId: input.decisionId, + title: parseDecisionText(input.title, 'confirmed decision title', 256), + statement: parseDecisionText( + input.statement, + 'confirmed decision statement', + 12_000, + ), + taskRef: input.taskRef === undefined ? null : input.taskRef, + confirmedByActorId: input.actorId, + confirmedAt: (input.now ?? new Date()).toISOString(), + operationId: input.operationId, + authorization, + }); +} + +export function parseConfirmedDecision(value: unknown): ConfirmedDecisionV1 { + assertRecord(value, 'confirmed decision'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'decisionId', + 'title', + 'statement', + 'taskRef', + 'confirmedByActorId', + 'confirmedAt', + 'operationId', + 'authorization', + ], + 'confirmed decision', + ); + if (value.schemaVersion !== 1) { + throw new Error('confirmed decision schemaVersion must be 1'); + } + assertUlid(value.decisionId, 'confirmed decision decisionId'); + assertUlid(value.confirmedByActorId, 'confirmed decision confirmedByActorId'); + assertUlid(value.operationId, 'confirmed decision operationId'); + const authorization = parseAuthorizationBasis(value.authorization); + if ( + authorization.action !== 'confirmed_decision_publish' || + authorization.actorId !== value.confirmedByActorId + ) { + throw new Error('MANCODE_CONFIRMED_DECISION_AUTHORIZATION_INVALID'); + } + const taskRef = + value.taskRef === null ? null : parseTaskRefValue(value.taskRef); + if (taskRef?.namespace === 'local') { + throw new Error('MANCODE_CONFIRMED_DECISION_LOCAL_TASK_FORBIDDEN'); + } + return { + schemaVersion: 1, + decisionId: value.decisionId, + title: parseDecisionText(value.title, 'confirmed decision title', 256), + statement: parseDecisionText( + value.statement, + 'confirmed decision statement', + 12_000, + ), + taskRef, + confirmedByActorId: value.confirmedByActorId, + confirmedAt: parseTimestamp( + value.confirmedAt, + 'confirmed decision confirmedAt', + ), + operationId: value.operationId, + authorization, + }; +} + +export function confirmedDecisionDigest(decision: ConfirmedDecisionV1): string { + return digestCanonicalJson(parseConfirmedDecision(decision)); +} + +export function confirmedDecisionDirectory(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'shared', + 'memory', + 'decisions', + ); +} + +export function confirmedDecisionPath( + projectRoot: string, + decisionId: string, +): string { + assertUlid(decisionId, 'confirmed decision decisionId'); + return path.join( + confirmedDecisionDirectory(projectRoot), + `${decisionId}.json`, + ); +} + +/** + * Immutable publication is the decision commit point. An audit event may be + * emitted afterwards and retried, but it never rolls this authority back. + */ +export async function publishConfirmedDecision( + projectRoot: string, + decision: ConfirmedDecisionV1, +): Promise { + const parsed = parseConfirmedDecision(decision); + const directory = confirmedDecisionDirectory(projectRoot); + await ensureSafeDirectory(projectRoot, directory); + const target = confirmedDecisionPath(projectRoot, parsed.decisionId); + try { + await writeFile(target, `${JSON.stringify(parsed, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + return parsed; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readConfirmedDecision( + projectRoot, + parsed.decisionId, + ); + if ( + existing !== null && + confirmedDecisionDigest(existing) === confirmedDecisionDigest(parsed) + ) { + return existing; + } + throw new Error('MANCODE_CONFIRMED_DECISION_ID_CONFLICT'); + } +} + +export async function readConfirmedDecision( + projectRoot: string, + decisionId: string, +): Promise { + assertUlid(decisionId, 'confirmed decision decisionId'); + try { + return parseConfirmedDecision( + JSON.parse( + await readSafeText( + confirmedDecisionDirectory(projectRoot), + `${decisionId}.json`, + ), + ), + ); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_CONFIRMED_DECISION_CORRUPT'); + } + throw error; + } +} + +export async function listConfirmedDecisions( + projectRoot: string, +): Promise { + const directory = confirmedDecisionDirectory(projectRoot); + let entries: string[]; + try { + await assertSafeDirectory(directory); + entries = await readdir(directory); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const decisions: ConfirmedDecisionV1[] = []; + for (const entry of entries.sort(compareUtf8)) { + if (!entry.endsWith('.json')) continue; + if (!DECISION_FILENAME.test(entry)) { + throw new Error('MANCODE_CONTEXT_COLLECTION_ENTRY_INVALID'); + } + const decision = await readConfirmedDecision( + projectRoot, + entry.slice(0, -'.json'.length), + ); + if (decision === null) { + throw new Error('MANCODE_CONTEXT_COLLECTION_CHANGED_DURING_READ'); + } + decisions.push(decision); + } + return decisions.sort((left, right) => + compareUtf8(left.decisionId, right.decisionId), + ); +} + +function parseDecisionText( + value: unknown, + label: string, + maxLength: number, +): string { + if ( + typeof value !== 'string' || + !value.trim() || + value.includes('\0') || + value.length > maxLength + ) { + throw new Error(`${label} is invalid`); + } + assertSharedTextSafe(value, label); + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +async function ensureSafeDirectory( + projectRoot: string, + directory: string, +): Promise { + const root = path.resolve(projectRoot); + const target = path.resolve(directory); + const relative = path.relative(root, target); + if ( + !relative || + path.isAbsolute(relative) || + relative.split(path.sep).some((segment) => segment === '..') + ) { + throw new Error('MANCODE_CONTEXT_PATH_UNSAFE'); + } + await assertSafeDirectory(root); + let current = root; + for (const segment of relative.split(path.sep)) { + current = path.join(current, segment); + try { + await mkdir(current); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + await assertSafeDirectory(current); + } +} + +async function assertSafeDirectory(directory: string): Promise { + const stat = await lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('MANCODE_CONTEXT_PATH_UNSAFE'); + } +} + +async function readSafeText( + directory: string, + filename: string, +): Promise { + await assertSafeDirectory(directory); + const target = path.join(directory, filename); + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_CONTEXT_PATH_UNSAFE'); + } + const content = await readFile(target, 'utf8'); + await assertSafeDirectory(directory); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_CONTEXT_PATH_UNSAFE'); + } + return content; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/context/context-pack.ts b/src/context/context-pack.ts new file mode 100644 index 0000000..bdb16ef --- /dev/null +++ b/src/context/context-pack.ts @@ -0,0 +1,1130 @@ +import { getEncoding } from 'js-tiktoken'; +import { type ArtifactRef, parseArtifactRef } from './artifact-ref.js'; +import { canonicalizeJson, digestCanonicalJson } from './canonical.js'; +import { assertUlid } from './ids.js'; +import { scanSharedText } from './privacy.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; + +export const CONTEXT_PACK_SCHEMA_VERSION = 2; +export const CONTEXT_PACK_TOKENIZER_ID = 'cl100k_base@tiktoken-0.7.0'; +export const CONTEXT_PACK_BUDGET_ALGORITHM_VERSION = 'complete-section-v1'; +export const CONTEXT_PACK_DEFAULT_BUDGETS = { + bootstrap: 400, + task: 1200, +} as const; + +export type ContextLevel = 'bootstrap' | 'task' | 'full'; +export type ContextPurpose = + | 'orient' + | 'plan' + | 'implement' + | 'review' + | 'verify' + | 'handoff'; +export type ContextOmissionReason = + | 'budget' + | 'purpose_excluded' + | 'privacy' + | 'unavailable'; +export type ProvenanceSourceKind = + | 'entity' + | 'artifact' + | 'runtime' + | 'derived'; + +export interface ContextPackSnapshot { + schemaEpoch: string; + taskRevision: number | null; + requirementsDigest: string | null; + reviewDigest: string | null; + verificationDigest: string | null; + ownershipEpoch: number | null; + coordinationRevision: number | null; +} + +export interface ContextPackTokenCounter { + id: typeof CONTEXT_PACK_TOKENIZER_ID; + count(canonicalJson: string): number; +} + +const fixedTokenizer = getEncoding('cl100k_base'); +const FIXED_CONTEXT_PACK_TOKEN_COUNTER: ContextPackTokenCounter = { + id: CONTEXT_PACK_TOKENIZER_ID, + count(canonicalJson: string): number { + return fixedTokenizer.encode(canonicalJson).length; + }, +}; + +/** The tokenizer is fixed by schema V2; callers cannot substitute an estimate. */ +export function contextPackTokenCounter(): ContextPackTokenCounter { + return FIXED_CONTEXT_PACK_TOKEN_COUNTER; +} + +export function defaultContextPackBudget( + level: Exclude, +): number { + return CONTEXT_PACK_DEFAULT_BUDGETS[level]; +} + +export interface ProvenanceEntry { + targetJsonPointer: string; + sourceKind: ProvenanceSourceKind; + taskRef: TaskRef | null; + artifactRef: ArtifactRef | null; + entityKey: string | null; + sourceRevision: number | null; + sourceDigest: string | null; + selectedJsonPointers: string[]; + redactions: string[]; +} + +export interface OmissionRecord { + targetJsonPointer: string; + reason: ContextOmissionReason; + omittedCount: number; + omittedDigest: string | null; +} + +export interface ContextPackSectionInput { + targetJsonPointer: ContextPackSectionPointer; + value: unknown; + provenance: ProvenanceEntry[]; + /** Required items are never removed by budget trimming. */ + required?: boolean; +} + +export type ContextPackSectionPointer = + | '/session' + | '/actor' + | '/project' + | '/collaboration' + | '/activeTask' + | '/governance/requirements' + | '/governance/review' + | '/governance/verification' + | '/parentFreshness' + | '/latestCheckpoint' + | '/latestHandoff' + | '/claims' + | '/conflicts' + | '/capabilities' + | '/transportFreshness'; + +export interface ContextPackBuildInput { + generatedAt: string; + level: ContextLevel; + purpose: ContextPurpose; + snapshot: ContextPackSnapshot; + budgetLimit: number; + sections: ContextPackSectionInput[]; +} + +export interface ContextPackV2 { + schemaVersion: 2; + generatedAt: string; + packDigest: string; + level: ContextLevel; + purpose: ContextPurpose; + snapshot: ContextPackSnapshot; + budget: { + tokenizerId: typeof CONTEXT_PACK_TOKENIZER_ID; + algorithmVersion: typeof CONTEXT_PACK_BUDGET_ALGORITHM_VERSION; + limit: number; + estimated: number; + exceededByRequiredEnvelope: boolean; + }; + session: unknown; + actor: unknown; + project: unknown; + collaboration: unknown; + activeTask: unknown; + governance: { + requirements: unknown; + review: unknown; + verification: unknown; + }; + parentFreshness: unknown; + latestCheckpoint: unknown; + latestHandoff: unknown; + claims: unknown; + conflicts: unknown; + capabilities: unknown; + transportFreshness: unknown; + provenance: ProvenanceEntry[]; + omissions: OmissionRecord[]; +} + +const SECTION_ORDER: ContextPackSectionPointer[] = [ + '/session', + '/actor', + '/project', + '/collaboration', + '/activeTask', + '/governance/requirements', + '/governance/review', + '/governance/verification', + '/parentFreshness', + '/latestCheckpoint', + '/latestHandoff', + '/claims', + '/conflicts', + '/capabilities', + '/transportFreshness', +]; + +const REQUIRED_ENVELOPE = new Set([ + '/session', + '/activeTask', + '/conflicts', + '/capabilities', + '/transportFreshness', +]); + +const PURPOSE_SECTIONS: Record< + ContextPurpose, + Set +> = { + orient: new Set([ + '/session', + '/actor', + '/activeTask', + '/collaboration', + '/conflicts', + '/capabilities', + '/transportFreshness', + ]), + plan: new Set([ + '/session', + '/actor', + '/project', + '/activeTask', + '/governance/requirements', + '/parentFreshness', + '/conflicts', + '/capabilities', + '/transportFreshness', + ]), + implement: new Set([ + '/session', + '/activeTask', + '/collaboration', + '/governance/requirements', + '/latestCheckpoint', + '/claims', + '/conflicts', + '/capabilities', + '/transportFreshness', + ]), + review: new Set([ + '/session', + '/activeTask', + '/governance/requirements', + '/governance/review', + '/governance/verification', + '/conflicts', + '/capabilities', + '/transportFreshness', + ]), + verify: new Set([ + '/session', + '/activeTask', + '/governance/requirements', + '/governance/verification', + '/governance/review', + '/conflicts', + '/capabilities', + '/transportFreshness', + ]), + handoff: new Set([ + '/session', + '/actor', + '/collaboration', + '/activeTask', + '/governance/requirements', + '/governance/review', + '/governance/verification', + '/latestCheckpoint', + '/latestHandoff', + '/claims', + '/conflicts', + '/capabilities', + '/transportFreshness', + ]), +}; + +/** + * Builds a deterministic temporary projection from one already-consistent + * snapshot. This function deliberately performs no entity reads; a resolver + * must retry before calling it if revisions or digests changed during reads. + */ +export function buildContextPack(input: ContextPackBuildInput): ContextPackV2 { + assertBuildInput(input); + const inputs = indexSections(input.sections); + assertRequiredEnvelope(inputs); + assertLevelAllowsSections(input.level, inputs); + const allowed = PURPOSE_SECTIONS[input.purpose]; + const included = new Map< + ContextPackSectionPointer, + ContextPackSectionInput + >(); + const omissions: OmissionRecord[] = []; + + for (const pointer of SECTION_ORDER) { + const section = inputs.get(pointer); + if (section === undefined) continue; + if (!allowed.has(pointer) && !REQUIRED_ENVELOPE.has(pointer)) { + if (section.required === true) { + throw new Error( + `MANCODE_CONTEXT_REQUIRED_PURPOSE_EXCLUDED: ${pointer}`, + ); + } + omissions.push(omissionFor(section, 'purpose_excluded')); + continue; + } + if (containsSensitiveText(section.value)) { + omissions.push(omissionFor(section, 'privacy')); + if (isRequiredSection(section)) { + throw new Error(`MANCODE_CONTEXT_REQUIRED_PRIVACY_BLOCKED: ${pointer}`); + } + continue; + } + included.set(pointer, section); + } + + while (true) { + const candidate = assemblePack(input, included, omissions, false, 0); + const estimated = countPack(candidate); + if (estimated <= input.budgetLimit) { + return finalizePack(candidate, estimated, false); + } + const removable = findNextRemovable(included); + if (removable === undefined) { + return finalizePack(candidate, estimated, true); + } + included.delete(removable.targetJsonPointer); + omissions.push(omissionFor(removable, 'budget')); + } +} + +export function contextPackDigest(pack: ContextPackV2): string { + return digestCanonicalJson(packDigestProjection(pack)); +} + +export function parseContextPack(value: unknown): ContextPackV2 { + if (!isRecord(value)) throw new Error('context pack must be an object'); + const keys = [ + 'schemaVersion', + 'generatedAt', + 'packDigest', + 'level', + 'purpose', + 'snapshot', + 'budget', + 'session', + 'actor', + 'project', + 'collaboration', + 'activeTask', + 'governance', + 'parentFreshness', + 'latestCheckpoint', + 'latestHandoff', + 'claims', + 'conflicts', + 'capabilities', + 'transportFreshness', + 'provenance', + 'omissions', + ]; + assertKnownKeys(value, keys, 'context pack'); + if (value.schemaVersion !== CONTEXT_PACK_SCHEMA_VERSION) { + throw new Error('context pack schemaVersion must be 2'); + } + assertTimestamp(value.generatedAt, 'context pack generatedAt'); + assertContextLevel(value.level); + assertContextPurpose(value.purpose); + const snapshot = parseSnapshot(value.snapshot); + const budget = parseBudget(value.budget); + if (!Array.isArray(value.provenance) || !Array.isArray(value.omissions)) { + throw new Error('context pack provenance and omissions must be arrays'); + } + const provenance = value.provenance.map(parseProvenance); + assertProvenanceIsSorted(provenance); + const pack: ContextPackV2 = { + schemaVersion: 2, + generatedAt: value.generatedAt, + packDigest: parseDigest(value.packDigest, 'context pack packDigest'), + level: value.level, + purpose: value.purpose, + snapshot, + budget, + session: value.session, + actor: value.actor, + project: value.project, + collaboration: value.collaboration, + activeTask: value.activeTask, + governance: parseGovernance(value.governance), + parentFreshness: value.parentFreshness, + latestCheckpoint: value.latestCheckpoint, + latestHandoff: value.latestHandoff, + claims: value.claims, + conflicts: value.conflicts, + capabilities: value.capabilities, + transportFreshness: value.transportFreshness, + provenance, + omissions: value.omissions.map(parseOmission), + }; + canonicalizeJson(packDigestProjection(pack)); + if (pack.packDigest !== contextPackDigest(pack)) { + throw new Error('context pack packDigest does not match canonical content'); + } + assertPackProvenance(pack); + assertContextPackBudget(pack); + assertParsedPackPurposeAndLevel(pack); + return pack; +} + +function indexSections( + sections: ContextPackSectionInput[], +): Map { + const indexed = new Map(); + for (const section of sections) { + assertSection(section); + if (indexed.has(section.targetJsonPointer)) { + throw new Error( + `context pack section is duplicated: ${section.targetJsonPointer}`, + ); + } + indexed.set(section.targetJsonPointer, { + ...section, + provenance: sortProvenance(section.provenance.map(parseProvenance)), + }); + } + return indexed; +} + +function assertRequiredEnvelope( + sections: Map, +): void { + for (const pointer of REQUIRED_ENVELOPE) { + const section = sections.get(pointer); + if (section === undefined || !isRequiredSection(section)) { + throw new Error(`context pack requires envelope section ${pointer}`); + } + } +} + +function assertLevelAllowsSections( + level: ContextLevel, + sections: Map, +): void { + if (level === 'full') { + for (const [pointer, section] of sections) { + if (REQUIRED_ENVELOPE.has(pointer)) continue; + if (section.required) { + throw new Error(`MANCODE_CONTEXT_REQUIRED_LEVEL_EXCLUDED: ${pointer}`); + } + throw new Error(`MANCODE_CONTEXT_FULL_ARTIFACTS_ON_DEMAND: ${pointer}`); + } + return; + } + if (level !== 'bootstrap') return; + for (const [pointer, section] of sections) { + if (REQUIRED_ENVELOPE.has(pointer)) continue; + if (section.required) { + throw new Error(`MANCODE_CONTEXT_REQUIRED_LEVEL_EXCLUDED: ${pointer}`); + } + throw new Error(`MANCODE_CONTEXT_LEVEL_EXCLUDED: ${pointer}`); + } +} + +function isRequiredSection(section: ContextPackSectionInput): boolean { + return ( + REQUIRED_ENVELOPE.has(section.targetJsonPointer) || + section.required === true + ); +} + +function findNextRemovable( + included: Map, +): ContextPackSectionInput | undefined { + const candidates = [...included.values()] + .filter((section) => !isRequiredSection(section)) + .sort( + (left, right) => + sectionPriority(right.targetJsonPointer) - + sectionPriority(left.targetJsonPointer) || + SECTION_ORDER.indexOf(right.targetJsonPointer) - + SECTION_ORDER.indexOf(left.targetJsonPointer), + ); + return candidates[0]; +} + +function assemblePack( + input: ContextPackBuildInput, + included: Map, + omissions: OmissionRecord[], + exceededByRequiredEnvelope: boolean, + estimated: number, +): ContextPackV2 { + const valueFor = (pointer: ContextPackSectionPointer): unknown => + included.get(pointer)?.value ?? defaultSectionValue(pointer); + return { + schemaVersion: 2, + generatedAt: input.generatedAt, + packDigest: '', + level: input.level, + purpose: input.purpose, + snapshot: input.snapshot, + budget: { + tokenizerId: CONTEXT_PACK_TOKENIZER_ID, + algorithmVersion: CONTEXT_PACK_BUDGET_ALGORITHM_VERSION, + limit: input.budgetLimit, + estimated, + exceededByRequiredEnvelope, + }, + session: valueFor('/session'), + actor: valueFor('/actor'), + project: valueFor('/project'), + collaboration: valueFor('/collaboration'), + activeTask: valueFor('/activeTask'), + governance: { + requirements: valueFor('/governance/requirements'), + review: valueFor('/governance/review'), + verification: valueFor('/governance/verification'), + }, + parentFreshness: valueFor('/parentFreshness'), + latestCheckpoint: valueFor('/latestCheckpoint'), + latestHandoff: valueFor('/latestHandoff'), + claims: valueFor('/claims'), + conflicts: valueFor('/conflicts'), + capabilities: valueFor('/capabilities'), + transportFreshness: valueFor('/transportFreshness'), + provenance: sortProvenance( + [snapshotProvenance(input.snapshot)].concat( + SECTION_ORDER.flatMap( + (pointer) => included.get(pointer)?.provenance ?? [], + ), + ), + ), + omissions: sortOmissions(omissions), + }; +} + +function finalizePack( + base: ContextPackV2, + estimated: number, + exceededByRequiredEnvelope: boolean, +): ContextPackV2 { + let candidate = { + ...base, + budget: { ...base.budget, estimated, exceededByRequiredEnvelope }, + }; + for (let attempt = 0; attempt < 8; attempt += 1) { + const nextEstimated = countPack(candidate); + if (nextEstimated === candidate.budget.estimated) break; + candidate = { + ...candidate, + budget: { ...candidate.budget, estimated: nextEstimated }, + }; + } + return { ...candidate, packDigest: contextPackDigest(candidate) }; +} + +function countPack(pack: ContextPackV2): number { + const count = FIXED_CONTEXT_PACK_TOKEN_COUNTER.count( + canonicalizeJson(packDigestProjection(pack)), + ); + if (!Number.isSafeInteger(count) || count < 0) { + throw new Error( + 'context pack token counter must return a non-negative safe integer', + ); + } + return count; +} + +function packDigestProjection( + pack: ContextPackV2, +): Omit { + const { + generatedAt: _generatedAt, + packDigest: _packDigest, + ...projection + } = pack; + return projection; +} + +function omissionFor( + section: ContextPackSectionInput, + reason: ContextOmissionReason, +): OmissionRecord { + return { + targetJsonPointer: section.targetJsonPointer, + reason, + omittedCount: countOmittedItems(section.value), + omittedDigest: digestCanonicalJson(section.value), + }; +} + +function countOmittedItems(value: unknown): number { + return Array.isArray(value) ? value.length : 1; +} + +function defaultSectionValue(pointer: ContextPackSectionPointer): unknown { + return pointer === '/claims' || pointer === '/conflicts' ? [] : null; +} + +function sectionPriority(pointer: ContextPackSectionPointer): number { + return SECTION_ORDER.indexOf(pointer); +} + +function containsSensitiveText(value: unknown): boolean { + if (typeof value === 'string') return scanSharedText(value).length > 0; + if (Array.isArray(value)) + return value.some((item) => containsSensitiveText(item)); + if (isRecord(value)) { + return Object.values(value).some((item) => containsSensitiveText(item)); + } + return false; +} + +function assertBuildInput(input: ContextPackBuildInput): void { + assertTimestamp(input.generatedAt, 'context pack generatedAt'); + assertContextLevel(input.level); + assertContextPurpose(input.purpose); + parseSnapshot(input.snapshot); + if (!Number.isSafeInteger(input.budgetLimit) || input.budgetLimit < 0) { + throw new Error( + 'context pack budgetLimit must be a non-negative safe integer', + ); + } + if (input.level === 'bootstrap' && input.purpose !== 'orient') { + throw new Error('MANCODE_CONTEXT_BOOTSTRAP_REQUIRES_ORIENT'); + } + if (!Array.isArray(input.sections)) { + throw new Error('context pack sections must be an array'); + } +} + +function assertSection(section: ContextPackSectionInput): void { + if (!SECTION_ORDER.includes(section.targetJsonPointer)) { + throw new Error('context pack section targetJsonPointer is invalid'); + } + if (!Array.isArray(section.provenance) || section.provenance.length === 0) { + throw new Error( + `context pack section requires provenance: ${section.targetJsonPointer}`, + ); + } + canonicalizeJson(section.value); + for (const provenance of section.provenance) { + const parsed = parseProvenance(provenance); + if (parsed.targetJsonPointer !== section.targetJsonPointer) { + throw new Error( + 'context pack provenance must target its section pointer', + ); + } + } +} + +function parseSnapshot(value: unknown): ContextPackSnapshot { + if (!isRecord(value)) + throw new Error('context pack snapshot must be an object'); + assertKnownKeys( + value, + [ + 'schemaEpoch', + 'taskRevision', + 'requirementsDigest', + 'reviewDigest', + 'verificationDigest', + 'ownershipEpoch', + 'coordinationRevision', + ], + 'context pack snapshot', + ); + assertUlid(value.schemaEpoch, 'context pack snapshot schemaEpoch'); + return { + schemaEpoch: value.schemaEpoch, + taskRevision: parsePositiveIntegerOrNull( + value.taskRevision, + 'taskRevision', + ), + requirementsDigest: parseDigestOrNull( + value.requirementsDigest, + 'requirementsDigest', + ), + reviewDigest: parseDigestOrNull(value.reviewDigest, 'reviewDigest'), + verificationDigest: parseDigestOrNull( + value.verificationDigest, + 'verificationDigest', + ), + ownershipEpoch: parseNonNegativeIntegerOrNull( + value.ownershipEpoch, + 'ownershipEpoch', + ), + coordinationRevision: parseNonNegativeIntegerOrNull( + value.coordinationRevision, + 'coordinationRevision', + ), + }; +} + +function parseBudget(value: unknown): ContextPackV2['budget'] { + if (!isRecord(value)) + throw new Error('context pack budget must be an object'); + assertKnownKeys( + value, + [ + 'tokenizerId', + 'algorithmVersion', + 'limit', + 'estimated', + 'exceededByRequiredEnvelope', + ], + 'context pack budget', + ); + if (value.tokenizerId !== CONTEXT_PACK_TOKENIZER_ID) { + throw new Error('context pack budget tokenizerId is invalid'); + } + if (value.algorithmVersion !== CONTEXT_PACK_BUDGET_ALGORITHM_VERSION) { + throw new Error('context pack budget algorithmVersion is invalid'); + } + const limit = value.limit; + if (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 0) { + throw new Error('context pack budget limit is invalid'); + } + const estimated = value.estimated; + if ( + typeof estimated !== 'number' || + !Number.isSafeInteger(estimated) || + estimated < 0 + ) { + throw new Error('context pack budget estimated is invalid'); + } + if (typeof value.exceededByRequiredEnvelope !== 'boolean') { + throw new Error( + 'context pack budget exceededByRequiredEnvelope is invalid', + ); + } + return { + tokenizerId: CONTEXT_PACK_TOKENIZER_ID, + algorithmVersion: CONTEXT_PACK_BUDGET_ALGORITHM_VERSION, + limit, + estimated, + exceededByRequiredEnvelope: value.exceededByRequiredEnvelope, + }; +} + +function parseGovernance(value: unknown): ContextPackV2['governance'] { + if (!isRecord(value)) + throw new Error('context pack governance must be an object'); + assertKnownKeys( + value, + ['requirements', 'review', 'verification'], + 'context pack governance', + ); + return { + requirements: value.requirements, + review: value.review, + verification: value.verification, + }; +} + +function parseProvenance(value: unknown): ProvenanceEntry { + if (!isRecord(value)) + throw new Error('context pack provenance must be an object'); + assertKnownKeys( + value, + [ + 'targetJsonPointer', + 'sourceKind', + 'taskRef', + 'artifactRef', + 'entityKey', + 'sourceRevision', + 'sourceDigest', + 'selectedJsonPointers', + 'redactions', + ], + 'context pack provenance', + ); + if ( + typeof value.targetJsonPointer !== 'string' || + !value.targetJsonPointer.startsWith('/') + ) { + throw new Error('context pack provenance targetJsonPointer is invalid'); + } + if ( + value.sourceKind !== 'entity' && + value.sourceKind !== 'artifact' && + value.sourceKind !== 'runtime' && + value.sourceKind !== 'derived' + ) { + throw new Error('context pack provenance sourceKind is invalid'); + } + const parsed: ProvenanceEntry = { + targetJsonPointer: value.targetJsonPointer, + sourceKind: value.sourceKind, + taskRef: value.taskRef === null ? null : parseTaskRef(value.taskRef), + artifactRef: + value.artifactRef === null ? null : parseArtifact(value.artifactRef), + entityKey: parseStringOrNull( + value.entityKey, + 'context pack provenance entityKey', + ), + sourceRevision: parseNonNegativeIntegerOrNull( + value.sourceRevision, + 'context pack provenance sourceRevision', + ), + sourceDigest: parseDigestOrNull( + value.sourceDigest, + 'context pack provenance sourceDigest', + ), + selectedJsonPointers: parseSortedStringSet( + value.selectedJsonPointers, + 'context pack provenance selectedJsonPointers', + ), + redactions: parseSortedStringSet( + value.redactions, + 'context pack provenance redactions', + ), + }; + if (parsed.sourceKind === 'entity' && parsed.entityKey === null) { + throw new Error('entity provenance requires an entityKey'); + } + if (parsed.sourceKind === 'artifact' && parsed.artifactRef === null) { + throw new Error('artifact provenance requires an artifactRef'); + } + if (parsed.sourceKind !== 'derived' && parsed.sourceDigest === null) { + throw new Error('direct provenance requires a sourceDigest'); + } + return parsed; +} + +function parseOmission(value: unknown): OmissionRecord { + if (!isRecord(value)) + throw new Error('context pack omission must be an object'); + assertKnownKeys( + value, + ['targetJsonPointer', 'reason', 'omittedCount', 'omittedDigest'], + 'context pack omission', + ); + if ( + typeof value.targetJsonPointer !== 'string' || + !value.targetJsonPointer.startsWith('/') + ) { + throw new Error('context pack omission targetJsonPointer is invalid'); + } + if ( + value.reason !== 'budget' && + value.reason !== 'purpose_excluded' && + value.reason !== 'privacy' && + value.reason !== 'unavailable' + ) { + throw new Error('context pack omission reason is invalid'); + } + const omittedCount = value.omittedCount; + if ( + typeof omittedCount !== 'number' || + !Number.isSafeInteger(omittedCount) || + omittedCount < 0 + ) { + throw new Error('context pack omission omittedCount is invalid'); + } + return { + targetJsonPointer: value.targetJsonPointer, + reason: value.reason, + omittedCount, + omittedDigest: parseDigestOrNull( + value.omittedDigest, + 'context pack omission omittedDigest', + ), + }; +} + +function assertPackProvenance(pack: ContextPackV2): void { + const available = new Set( + pack.provenance.map((entry) => entry.targetJsonPointer), + ); + if (!available.has('/snapshot')) { + throw new Error('context pack snapshot lacks provenance'); + } + for (const pointer of SECTION_ORDER) { + if (REQUIRED_ENVELOPE.has(pointer) && !available.has(pointer)) { + throw new Error( + `context pack required envelope lacks provenance: ${pointer}`, + ); + } + if (isDefaultValue(pointer, valueAt(pack, pointer))) continue; + if (!available.has(pointer)) { + throw new Error( + `context pack non-empty section lacks provenance: ${pointer}`, + ); + } + } +} + +function assertContextPackBudget(pack: ContextPackV2): void { + const actual = countPack(pack); + if (actual !== pack.budget.estimated) { + throw new Error( + 'context pack budget estimate does not match fixed tokenizer', + ); + } + if ( + (!pack.budget.exceededByRequiredEnvelope && + pack.budget.estimated > pack.budget.limit) || + (pack.budget.exceededByRequiredEnvelope && + pack.budget.estimated <= pack.budget.limit) + ) { + throw new Error( + 'context pack required-envelope budget flag is inconsistent', + ); + } +} + +function assertParsedPackPurposeAndLevel(pack: ContextPackV2): void { + if (pack.level === 'bootstrap' && pack.purpose !== 'orient') { + throw new Error('MANCODE_CONTEXT_BOOTSTRAP_REQUIRES_ORIENT'); + } + for (const pointer of SECTION_ORDER) { + if (REQUIRED_ENVELOPE.has(pointer)) continue; + if (isDefaultValue(pointer, valueAt(pack, pointer))) continue; + if (!PURPOSE_SECTIONS[pack.purpose].has(pointer)) { + throw new Error( + `context pack section is excluded by purpose: ${pointer}`, + ); + } + if (pack.level === 'bootstrap') { + throw new Error(`context pack section is excluded by level: ${pointer}`); + } + if (pack.level === 'full') { + throw new Error(`MANCODE_CONTEXT_FULL_ARTIFACTS_ON_DEMAND: ${pointer}`); + } + } +} + +function snapshotProvenance(snapshot: ContextPackSnapshot): ProvenanceEntry { + return { + targetJsonPointer: '/snapshot', + sourceKind: 'runtime', + taskRef: null, + artifactRef: null, + entityKey: null, + sourceRevision: null, + sourceDigest: digestCanonicalJson(snapshot), + selectedJsonPointers: [''], + redactions: [], + }; +} + +function sortProvenance(entries: ProvenanceEntry[]): ProvenanceEntry[] { + return [...entries].sort((left, right) => + Buffer.from(canonicalizeJson(left), 'utf8').compare( + Buffer.from(canonicalizeJson(right), 'utf8'), + ), + ); +} + +function assertProvenanceIsSorted(entries: ProvenanceEntry[]): void { + const sorted = sortProvenance(entries); + if ( + sorted.some( + (entry, index) => + canonicalizeJson(entry) !== canonicalizeJson(entries[index]), + ) + ) { + throw new Error( + 'context pack provenance must use canonical stable ordering', + ); + } + for (let index = 1; index < sorted.length; index += 1) { + if ( + canonicalizeJson(sorted[index - 1]) === canonicalizeJson(sorted[index]) + ) { + throw new Error('context pack provenance must not contain duplicates'); + } + } +} + +function valueAt( + pack: ContextPackV2, + pointer: ContextPackSectionPointer, +): unknown { + switch (pointer) { + case '/session': + return pack.session; + case '/actor': + return pack.actor; + case '/project': + return pack.project; + case '/collaboration': + return pack.collaboration; + case '/activeTask': + return pack.activeTask; + case '/governance/requirements': + return pack.governance.requirements; + case '/governance/review': + return pack.governance.review; + case '/governance/verification': + return pack.governance.verification; + case '/parentFreshness': + return pack.parentFreshness; + case '/latestCheckpoint': + return pack.latestCheckpoint; + case '/latestHandoff': + return pack.latestHandoff; + case '/claims': + return pack.claims; + case '/conflicts': + return pack.conflicts; + case '/capabilities': + return pack.capabilities; + case '/transportFreshness': + return pack.transportFreshness; + } +} + +function isDefaultValue( + pointer: ContextPackSectionPointer, + value: unknown, +): boolean { + return ( + value === null || + ((pointer === '/claims' || pointer === '/conflicts') && + Array.isArray(value) && + value.length === 0) + ); +} + +function sortOmissions(omissions: OmissionRecord[]): OmissionRecord[] { + return [...omissions].sort( + (left, right) => + SECTION_ORDER.indexOf( + left.targetJsonPointer as ContextPackSectionPointer, + ) - + SECTION_ORDER.indexOf( + right.targetJsonPointer as ContextPackSectionPointer, + ) || left.reason.localeCompare(right.reason, 'en'), + ); +} + +function assertContextLevel(value: unknown): asserts value is ContextLevel { + if (value !== 'bootstrap' && value !== 'task' && value !== 'full') { + throw new Error('context pack level is invalid'); + } +} + +function assertContextPurpose(value: unknown): asserts value is ContextPurpose { + if ( + value !== 'orient' && + value !== 'plan' && + value !== 'implement' && + value !== 'review' && + value !== 'verify' && + value !== 'handoff' + ) { + throw new Error('context pack purpose is invalid'); + } +} + +function assertTimestamp( + value: unknown, + label: string, +): asserts value is string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } +} + +function parseTaskRef(value: unknown): TaskRef { + try { + return parseTaskRefValue(value); + } catch { + throw new Error('context pack provenance taskRef is invalid'); + } +} + +function parseArtifact(value: unknown): ArtifactRef { + try { + return parseArtifactRef(value); + } catch { + throw new Error('context pack provenance artifactRef is invalid'); + } +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseDigestOrNull(value: unknown, label: string): string | null { + return value === null ? null : parseDigest(value, label); +} + +function parseStringOrNull(value: unknown, label: string): string | null { + if (value === null) return null; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} must be a non-empty string or null`); + } + return value; +} + +function parseSortedStringSet(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error(`${label} must be an array of strings`); + } + const normalized = [...new Set(value)].sort((left, right) => + Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')), + ); + if ( + normalized.length !== value.length || + normalized.some((item, index) => item !== value[index]) + ) { + throw new Error(`${label} must be UTF-8 sorted without duplicates`); + } + return [...value]; +} + +function parsePositiveIntegerOrNull( + value: unknown, + label: string, +): number | null { + if (value === null) return null; + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new Error( + `context pack snapshot ${label} must be a positive integer or null`, + ); + } + return value as number; +} + +function parseNonNegativeIntegerOrNull( + value: unknown, + label: string, +): number | null { + if (value === null) return null; + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error( + `context pack ${label} must be a non-negative integer or null`, + ); + } + return value as number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function assertKnownKeys( + value: Record, + allowed: string[], + label: string, +): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) + throw new Error(`${label} contains unknown key: ${key}`); + } +} diff --git a/src/context/creation-resolution.ts b/src/context/creation-resolution.ts new file mode 100644 index 0000000..1be32e7 --- /dev/null +++ b/src/context/creation-resolution.ts @@ -0,0 +1,191 @@ +import type { TeamAssessment } from '../team/assessment.js'; +import type { TeamPolicyV1 } from '../team/policy.js'; +import { + type Coordination, + type ParentWorkflowDescriptor, + type WorkflowDescriptor, + type WorkflowMode, + assertWorkflowDescriptor, + parseWorkflowMode, +} from './schema.js'; +import type { TaskNamespace } from './task-ref.js'; + +export type DimensionSource = + | 'mode_constraint' + | 'parent' + | 'explicit' + | 'policy_default'; + +export interface WorkflowCreationRequest { + workflowMode: WorkflowMode; + parent: ParentWorkflowDescriptor | null; + visibility?: TaskNamespace; + coordination?: Coordination; + policy: Pick | null; + assessment: TeamAssessment | null; +} + +export interface WorkflowCreationResolution { + descriptor: WorkflowDescriptor; + dimensions: { + workflowMode: { value: WorkflowMode; source: 'mode_constraint' }; + visibility: { value: TaskNamespace; source: DimensionSource }; + coordination: { value: Coordination; source: DimensionSource }; + }; + sharedPrivacyConfirmationRequired: boolean; + assessment: TeamAssessment | null; +} + +/** + * Applies the fixed creation precedence: mode → parent → explicit parameters + * → policy default. An assessment is returned for presentation but never + * changes a resolved dimension. + */ +export function resolveWorkflowCreation( + request: WorkflowCreationRequest, +): WorkflowCreationResolution { + const workflowMode = parseWorkflowMode(request.workflowMode); + validateOptionalDimensions(request.visibility, request.coordination); + if (workflowMode !== 'manba' && request.parent !== null) { + throw new Error('only manba workflows may declare a parent'); + } + if (workflowMode === 'manteam') { + rejectConflict(request.visibility, 'shared', 'manteam visibility'); + rejectConflict(request.coordination, 'team', 'manteam coordination'); + return buildResolution( + workflowMode, + 'shared', + 'team', + null, + 'mode_constraint', + 'mode_constraint', + request.assessment, + ); + } + if (workflowMode === 'manba') { + return resolveManba(request, workflowMode); + } + if (request.coordination !== undefined && request.coordination !== 'single') { + throw new Error('man workflows require single coordination'); + } + const visibility = + request.visibility ?? request.policy?.defaultVisibility ?? 'local'; + const visibilitySource: DimensionSource = + request.visibility === undefined + ? request.policy === null + ? 'mode_constraint' + : 'policy_default' + : 'explicit'; + return buildResolution( + workflowMode, + visibility, + 'single', + null, + visibilitySource, + request.coordination === undefined ? 'mode_constraint' : 'explicit', + request.assessment, + ); +} + +function resolveManba( + request: WorkflowCreationRequest, + workflowMode: WorkflowMode, +): WorkflowCreationResolution { + const parent = request.parent; + if (parent === null) { + rejectConflict(request.visibility, 'local', 'standalone manba visibility'); + rejectConflict( + request.coordination, + 'single', + 'standalone manba coordination', + ); + return buildResolution( + workflowMode, + 'local', + 'single', + null, + 'mode_constraint', + 'mode_constraint', + request.assessment, + ); + } + if ( + request.visibility !== undefined && + request.visibility !== parent.visibility + ) { + throw new Error('child manba visibility must inherit its parent'); + } + if ( + request.coordination !== undefined && + request.coordination !== parent.coordination + ) { + throw new Error('child manba coordination must inherit its parent'); + } + return buildResolution( + workflowMode, + parent.visibility, + parent.coordination, + parent, + 'parent', + 'parent', + request.assessment, + ); +} + +function buildResolution( + workflowMode: WorkflowMode, + visibility: TaskNamespace, + coordination: Coordination, + parent: ParentWorkflowDescriptor | null, + visibilitySource: DimensionSource, + coordinationSource: DimensionSource, + assessment: TeamAssessment | null, +): WorkflowCreationResolution { + const descriptor: WorkflowDescriptor = { + workflowMode, + visibility, + coordination, + parent, + }; + assertWorkflowDescriptor(descriptor); + return { + descriptor, + dimensions: { + workflowMode: { value: workflowMode, source: 'mode_constraint' }, + visibility: { value: visibility, source: visibilitySource }, + coordination: { value: coordination, source: coordinationSource }, + }, + sharedPrivacyConfirmationRequired: visibility === 'shared', + assessment, + }; +} + +function validateOptionalDimensions( + visibility: TaskNamespace | undefined, + coordination: Coordination | undefined, +): void { + if ( + visibility !== undefined && + visibility !== 'local' && + visibility !== 'shared' + ) { + throw new Error('workflow create visibility is invalid'); + } + if ( + coordination !== undefined && + coordination !== 'single' && + coordination !== 'team' + ) { + throw new Error('workflow create coordination is invalid'); + } +} + +function rejectConflict( + provided: T | undefined, + expected: T, + label: string, +): void { + if (provided !== undefined && provided !== expected) { + throw new Error(`${label} conflicts with its mode constraint`); + } +} diff --git a/src/context/greenfield-init.ts b/src/context/greenfield-init.ts new file mode 100644 index 0000000..f8912d4 --- /dev/null +++ b/src/context/greenfield-init.ts @@ -0,0 +1,808 @@ +import { + lstat, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { throwIfOperationCrashInjected } from '../runtime/operation-crash-injection.js'; +import { + type ProjectConfigV1, + type TeamPolicyV1, + assertConfigPolicyConsistency, + parseProjectConfig, + parseTeamPolicy, +} from '../team/policy.js'; +import { digestCanonicalJson } from './canonical.js'; +import { type Ulid, assertUlid } from './ids.js'; +import { assertGreenfieldInitializationPreflight } from './layout.js'; +import { + type ManagedAdapter, + type SchemaManifestV1, + assertSchemaManifestTransition, + parseSchemaManifest, +} from './manifest.js'; +import { + type ProjectFactsV1, + parseProjectFacts, + unknownProjectFacts, +} from './project-facts.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export type GreenfieldInitializationState = + | 'staged' + | 'published' + | 'repair_required' + | 'activated' + | 'aborted'; + +export interface GreenfieldInitializationJournalV1 { + schemaVersion: 1; + operationId: Ulid; + workspaceId: Ulid; + state: GreenfieldInitializationState; + stagingDirectoryName: string; + targetDirectoryName: '.mancode'; + manifestDigest: string; + configDigest: string; + policyDigest: string; + projectFactsDigest: string; + bindingRegistered: boolean; + createdAt: string; + updatedAt: string; +} + +export interface GreenfieldInitializationInput { + projectRoot: string; + operationId: Ulid; + workspaceId: Ulid; + schemaEpoch: Ulid; + minReaderVersion: string; + minWriterVersion: string; + managedAdapters: Record; + projectConfig: ProjectConfigV1; + teamPolicy: TeamPolicyV1; + /** Optional detected facts; omitted inputs receive a safe unknown record. */ + projectFacts?: ProjectFactsV1; + now?: Date; +} + +export interface GreenfieldPublicationInput { + projectRoot: string; + operationId: Ulid; + /** Registration must be idempotent because crash recovery can run it again. */ + registerWorkspaceBinding: () => Promise; + now?: Date; +} + +export type GreenfieldRecoveryResult = + | 'safe_abort_available' + | 'forward_repaired' + | 'already_activated'; + +const JOURNAL_RELATIVE_DIRECTORY = path.join( + 'local', + 'runtime', + 'initialization', +); +const V3_IGNORE = [ + 'local/', + 'runtime/', + '**/*.raw.log', + '**/artifacts/private/', +]; + +/** + * Creates a V3 root next to, rather than inside, .mancode. No legacy path is + * touched. The only rollback-safe state is this named staging directory. + */ +export async function stageGreenfieldInitialization( + input: GreenfieldInitializationInput, +): Promise { + const normalized = normalizeInput(input); + await assertGreenfieldInitializationPreflight(normalized.projectRoot); + throwIfOperationCrashInjected( + 'greenfield_initialize', + 'verify-no-legacy-authority', + ); + const stagingRoot = greenfieldStagingPath( + normalized.projectRoot, + normalized.operationId, + ); + try { + await mkdir(stagingRoot); + } catch (error) { + if (isAlreadyExists(error)) { + throw new Error('MANCODE_GREENFIELD_STAGING_EXISTS'); + } + throw error; + } + + const now = (normalized.now ?? new Date()).toISOString(); + const manifest = initializationManifest(normalized, now); + const config = initializationConfig( + normalized.projectConfig, + normalized, + now, + ); + const policy = initializationPolicy(normalized.teamPolicy, normalized, now); + const projectFacts = initializationProjectFacts(normalized, now); + const journal: GreenfieldInitializationJournalV1 = { + schemaVersion: 1, + operationId: normalized.operationId, + workspaceId: normalized.workspaceId, + state: 'staged', + stagingDirectoryName: path.basename(stagingRoot), + targetDirectoryName: '.mancode', + manifestDigest: digestCanonicalJson(manifest), + configDigest: digestCanonicalJson(config), + policyDigest: digestCanonicalJson(policy), + projectFactsDigest: digestCanonicalJson(projectFacts), + bindingRegistered: false, + createdAt: now, + updatedAt: now, + }; + await writeGreenfieldLayout( + stagingRoot, + manifest, + config, + policy, + projectFacts, + journal, + ); + throwIfOperationCrashInjected('greenfield_initialize', 'prepared'); + throwIfOperationCrashInjected( + 'greenfield_initialize', + 'write-initializing-staging-root', + ); + throwIfOperationCrashInjected( + 'greenfield_initialize', + 'write-v3-config-policy-adapters', + ); + return journal; +} + +/** + * Publishes the complete staging root atomically, registers its workspace, + * then activates the manifest. A failure after rename is repair-only. + */ +export async function publishGreenfieldInitialization( + input: GreenfieldPublicationInput, +): Promise { + const root = path.resolve(input.projectRoot); + assertUlid(input.operationId, 'greenfield operationId'); + const stagingRoot = greenfieldStagingPath(root, input.operationId); + const targetRoot = greenfieldTargetPath(root); + const journal = await readGreenfieldJournal(stagingRoot, input.operationId); + if (journal.state !== 'staged') { + throw new Error('MANCODE_GREENFIELD_STAGING_STATE_INVALID'); + } + await assertStageMatchesJournal(stagingRoot, journal); + if ((await lstatOrNull(targetRoot)) !== null) { + throw new Error('MANCODE_V3_TARGET_EXISTS'); + } + await rename(stagingRoot, targetRoot); + throwIfOperationCrashInjected('greenfield_initialize', 'publish-v3-root'); + return finishPublishedInitialization( + { ...input, projectRoot: root }, + journal, + ); +} + +export async function initializeGreenfield( + input: GreenfieldInitializationInput, + publication: Omit, +): Promise { + await stageGreenfieldInitialization(input); + return publishGreenfieldInitialization({ + ...publication, + projectRoot: input.projectRoot, + operationId: input.operationId, + }); +} + +/** + * Recovery makes no guesses: pre-publish staging is only abortable, while a + * published root is only completed forward after validation. + */ +export async function recoverGreenfieldInitialization( + input: GreenfieldPublicationInput, +): Promise { + const root = path.resolve(input.projectRoot); + assertUlid(input.operationId, 'greenfield operationId'); + const stagingRoot = greenfieldStagingPath(root, input.operationId); + const targetRoot = greenfieldTargetPath(root); + const [stagingStat, targetStat] = await Promise.all([ + lstatOrNull(stagingRoot), + lstatOrNull(targetRoot), + ]); + if (stagingStat !== null && targetStat !== null) { + throw new Error('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + } + if (stagingStat !== null) { + const journal = await readGreenfieldJournal(stagingRoot, input.operationId); + if (journal.state !== 'staged') { + throw new Error('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + } + await assertStageMatchesJournal(stagingRoot, journal); + return 'safe_abort_available'; + } + if (targetStat === null) { + throw new Error('MANCODE_GREENFIELD_INITIALIZATION_NOT_FOUND'); + } + const journal = await readGreenfieldJournal(targetRoot, input.operationId); + const manifest = await readManifest(targetRoot); + if ( + journal.state === 'activated' && + manifest.activationState === 'v3_active' + ) { + return 'already_activated'; + } + if ( + manifest.activationState !== 'initializing' && + manifest.activationState !== 'v3_active' + ) { + throw new Error('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + } + if (manifest.activationState === 'v3_active') { + await assertPublishedConfigPolicyMatchesJournal(targetRoot, journal); + if (manifest.lastOperationId !== journal.operationId) { + throw new Error('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + } + const reconciled = await writeJournalAt(targetRoot, { + ...journal, + state: 'activated', + bindingRegistered: true, + updatedAt: (input.now ?? new Date()).toISOString(), + }); + return reconciled.state === 'activated' + ? 'forward_repaired' + : 'already_activated'; + } + await assertStageMatchesJournal(targetRoot, journal); + await finishPublishedInitialization({ ...input, projectRoot: root }, journal); + return 'forward_repaired'; +} + +/** + * Deletes only a verified pre-publish staging directory. A published V3 + * root is never rolled back by this helper. + */ +export async function abortStagedGreenfieldInitialization( + projectRoot: string, + operationId: Ulid, +): Promise { + const root = path.resolve(projectRoot); + assertUlid(operationId, 'greenfield operationId'); + const stagingRoot = greenfieldStagingPath(root, operationId); + if ((await lstatOrNull(greenfieldTargetPath(root))) !== null) { + throw new Error('MANCODE_GREENFIELD_ROLLBACK_FORBIDDEN'); + } + const journal = await readGreenfieldJournal(stagingRoot, operationId); + if ( + journal.state !== 'staged' || + journal.stagingDirectoryName !== path.basename(stagingRoot) + ) { + throw new Error('MANCODE_GREENFIELD_ROLLBACK_FORBIDDEN'); + } + await assertStageMatchesJournal(stagingRoot, journal); + await rm(stagingRoot, { recursive: true, force: false }); +} + +export function greenfieldStagingPath( + projectRoot: string, + operationId: Ulid, +): string { + assertUlid(operationId, 'greenfield operationId'); + return path.join(path.resolve(projectRoot), `.mancode.init-${operationId}`); +} + +export function greenfieldTargetPath(projectRoot: string): string { + return path.join(path.resolve(projectRoot), '.mancode'); +} + +export function parseGreenfieldInitializationJournal( + value: unknown, +): GreenfieldInitializationJournalV1 { + assertRecord(value, 'greenfield initialization journal'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'workspaceId', + 'state', + 'stagingDirectoryName', + 'targetDirectoryName', + 'manifestDigest', + 'configDigest', + 'policyDigest', + 'projectFactsDigest', + 'bindingRegistered', + 'createdAt', + 'updatedAt', + ], + 'greenfield initialization journal', + ); + if (value.schemaVersion !== 1) { + throw new Error( + 'greenfield initialization journal schemaVersion must be 1', + ); + } + assertUlid( + value.operationId, + 'greenfield initialization journal operationId', + ); + assertUlid( + value.workspaceId, + 'greenfield initialization journal workspaceId', + ); + if (!isState(value.state)) { + throw new Error('greenfield initialization journal state is invalid'); + } + if ( + typeof value.stagingDirectoryName !== 'string' || + !/^\.mancode\.init-[0-9A-HJKMNP-TV-Z]{26}$/.test(value.stagingDirectoryName) + ) { + throw new Error( + 'greenfield initialization journal stagingDirectoryName is invalid', + ); + } + if (value.targetDirectoryName !== '.mancode') { + throw new Error( + 'greenfield initialization journal targetDirectoryName is invalid', + ); + } + if (typeof value.bindingRegistered !== 'boolean') { + throw new Error( + 'greenfield initialization journal bindingRegistered is invalid', + ); + } + return { + schemaVersion: 1, + operationId: value.operationId, + workspaceId: value.workspaceId, + state: value.state, + stagingDirectoryName: value.stagingDirectoryName, + targetDirectoryName: '.mancode', + manifestDigest: parseDigest(value.manifestDigest, 'manifestDigest'), + configDigest: parseDigest(value.configDigest, 'configDigest'), + policyDigest: parseDigest(value.policyDigest, 'policyDigest'), + projectFactsDigest: parseDigest( + value.projectFactsDigest, + 'projectFactsDigest', + ), + bindingRegistered: value.bindingRegistered, + createdAt: parseTimestamp(value.createdAt, 'createdAt'), + updatedAt: parseTimestamp(value.updatedAt, 'updatedAt'), + }; +} + +async function finishPublishedInitialization( + input: GreenfieldPublicationInput, + stagedJournal: GreenfieldInitializationJournalV1, +): Promise { + const targetRoot = greenfieldTargetPath(input.projectRoot); + const published = await writeJournalAt(targetRoot, { + ...stagedJournal, + state: 'published', + updatedAt: (input.now ?? new Date()).toISOString(), + }); + try { + await input.registerWorkspaceBinding(); + } catch (error) { + await writeJournalAt(targetRoot, { + ...published, + state: 'repair_required', + updatedAt: (input.now ?? new Date()).toISOString(), + }); + throw new Error('MANCODE_GREENFIELD_BINDING_REGISTRATION_FAILED', { + cause: error, + }); + } + throwIfOperationCrashInjected( + 'greenfield_initialize', + 'register-workspace-binding', + ); + const manifest = await readManifest(targetRoot); + const activeManifest: SchemaManifestV1 = { + ...manifest, + activationState: 'v3_active', + activatedAt: (input.now ?? new Date()).toISOString(), + }; + assertSchemaManifestTransition(manifest, activeManifest); + await writeJson(path.join(targetRoot, 'schema.json'), activeManifest); + throwIfOperationCrashInjected( + 'greenfield_initialize', + 'activate-v3-manifest', + ); + const activated = await writeJournalAt(targetRoot, { + ...published, + state: 'activated', + bindingRegistered: true, + updatedAt: (input.now ?? new Date()).toISOString(), + }); + throwIfOperationCrashInjected('greenfield_initialize', 'commit'); + return activated; +} + +async function writeGreenfieldLayout( + stagingRoot: string, + manifest: SchemaManifestV1, + config: ProjectConfigV1, + policy: TeamPolicyV1, + projectFacts: ProjectFactsV1, + journal: GreenfieldInitializationJournalV1, +): Promise { + await Promise.all([ + mkdir(path.join(stagingRoot, 'shared', 'context'), { recursive: true }), + mkdir(path.join(stagingRoot, 'shared', 'memory', 'decisions'), { + recursive: true, + }), + mkdir(path.join(stagingRoot, 'shared', 'team', 'actors'), { + recursive: true, + }), + mkdir(path.join(stagingRoot, 'shared', 'team', 'handoffs'), { + recursive: true, + }), + mkdir(path.join(stagingRoot, 'shared', 'team', 'events'), { + recursive: true, + }), + mkdir(path.join(stagingRoot, 'shared', 'team', 'transport'), { + recursive: true, + }), + mkdir(path.join(stagingRoot, 'local', 'sessions'), { recursive: true }), + mkdir(path.join(stagingRoot, JOURNAL_RELATIVE_DIRECTORY), { + recursive: true, + }), + mkdir(path.join(stagingRoot, 'local', 'workflows'), { recursive: true }), + mkdir(path.join(stagingRoot, 'local', 'overlays'), { recursive: true }), + mkdir(path.join(stagingRoot, 'local', 'quarantine'), { recursive: true }), + mkdir(path.join(stagingRoot, 'local', 'publish'), { recursive: true }), + mkdir(path.join(stagingRoot, 'local', 'cache'), { recursive: true }), + mkdir(path.join(stagingRoot, 'runtime', 'non-git', journal.workspaceId), { + recursive: true, + }), + ]); + await Promise.all([ + writeJson(path.join(stagingRoot, 'schema.json'), manifest), + writeJson(path.join(stagingRoot, 'shared', 'config.json'), config), + writeJson(path.join(stagingRoot, 'shared', 'team', 'policy.json'), policy), + writeJson( + path.join(stagingRoot, 'shared', 'context', 'project.json'), + projectFacts, + ), + writeJson(journalPath(stagingRoot, journal.operationId), journal), + writeFile( + path.join(stagingRoot, '.gitignore'), + `${V3_IGNORE.join('\n')}\n`, + { encoding: 'utf8', flag: 'wx' }, + ), + ]); +} + +async function readGreenfieldJournal( + root: string, + operationId: Ulid, +): Promise { + try { + const raw = await readFile(journalPath(root, operationId), 'utf8'); + const journal = parseGreenfieldInitializationJournal(JSON.parse(raw)); + if (journal.operationId !== operationId) { + throw new Error('MANCODE_GREENFIELD_JOURNAL_CORRUPT'); + } + return journal; + } catch (error) { + if (error instanceof SyntaxError || isNotFound(error)) { + throw new Error('MANCODE_GREENFIELD_JOURNAL_CORRUPT'); + } + throw error; + } +} + +async function readManifest(root: string): Promise { + try { + return parseSchemaManifest( + JSON.parse(await readFile(path.join(root, 'schema.json'), 'utf8')), + ); + } catch (error) { + if (error instanceof SyntaxError || isNotFound(error)) { + throw new Error('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + } + throw error; + } +} + +async function assertStageMatchesJournal( + root: string, + journal: GreenfieldInitializationJournalV1, +): Promise { + const [manifest, config, policy, projectFacts] = await Promise.all([ + readManifest(root), + readConfig(root), + readPolicy(root), + readProjectFactsAt(root), + ]); + if ( + manifest.activationState !== 'initializing' || + manifest.legacyBaseline !== null || + manifest.lastOperationId !== journal.operationId || + digestCanonicalJson(manifest) !== journal.manifestDigest + ) { + throw new Error('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + } + assertConfigPolicyMatchesJournal(config, policy, journal); + assertProjectFactsMatchesJournal(projectFacts, journal); +} + +async function assertPublishedConfigPolicyMatchesJournal( + root: string, + journal: GreenfieldInitializationJournalV1, +): Promise { + const [config, policy, projectFacts] = await Promise.all([ + readConfig(root), + readPolicy(root), + readProjectFactsAt(root), + ]); + assertConfigPolicyMatchesJournal(config, policy, journal); + assertProjectFactsMatchesJournal(projectFacts, journal); +} + +function assertConfigPolicyMatchesJournal( + config: ProjectConfigV1, + policy: TeamPolicyV1, + journal: GreenfieldInitializationJournalV1, +): void { + if ( + config.workspaceId !== journal.workspaceId || + policy.workspaceId !== journal.workspaceId || + digestCanonicalJson(config) !== journal.configDigest || + digestCanonicalJson(policy) !== journal.policyDigest + ) { + throw new Error('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + } + assertConfigPolicyConsistency(config, policy); +} + +function assertProjectFactsMatchesJournal( + facts: ProjectFactsV1, + journal: GreenfieldInitializationJournalV1, +): void { + if (digestCanonicalJson(facts) !== journal.projectFactsDigest) { + throw new Error('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + } +} + +async function readConfig(root: string): Promise { + let config: ProjectConfigV1; + try { + config = parseProjectConfig( + JSON.parse( + await readFile(path.join(root, 'shared', 'config.json'), 'utf8'), + ), + ); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + } + throw error; + } + return config; +} + +async function readPolicy(root: string): Promise { + let policy: TeamPolicyV1; + try { + policy = parseTeamPolicy( + JSON.parse( + await readFile( + path.join(root, 'shared', 'team', 'policy.json'), + 'utf8', + ), + ), + ); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + } + throw error; + } + return policy; +} + +async function readProjectFactsAt(root: string): Promise { + try { + return parseProjectFacts( + JSON.parse( + await readFile( + path.join(root, 'shared', 'context', 'project.json'), + 'utf8', + ), + ), + ); + } catch (error) { + if (error instanceof SyntaxError || isNotFound(error)) { + throw new Error('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + } + throw error; + } +} + +async function writeJournalAt( + root: string, + journal: GreenfieldInitializationJournalV1, +): Promise { + const parsed = parseGreenfieldInitializationJournal(journal); + await writeJson(journalPath(root, parsed.operationId), parsed); + return parsed; +} + +function normalizeInput( + input: GreenfieldInitializationInput, +): GreenfieldInitializationInput { + assertUlid(input.operationId, 'greenfield operationId'); + assertUlid(input.workspaceId, 'greenfield workspaceId'); + assertUlid(input.schemaEpoch, 'greenfield schemaEpoch'); + if (typeof input.projectRoot !== 'string' || !input.projectRoot.trim()) { + throw new Error('greenfield projectRoot is required'); + } + const config = parseProjectConfig(input.projectConfig); + const policy = parseTeamPolicy(input.teamPolicy); + if ( + config.workspaceId !== input.workspaceId || + policy.workspaceId !== input.workspaceId + ) { + throw new Error( + 'greenfield config and policy must use the requested workspaceId', + ); + } + assertConfigPolicyConsistency(config, policy); + const manifest = initializationManifest( + input, + (input.now ?? new Date()).toISOString(), + ); + parseSchemaManifest(manifest); + return { + ...input, + projectRoot: path.resolve(input.projectRoot), + projectConfig: config, + teamPolicy: policy, + projectFacts: + input.projectFacts === undefined + ? undefined + : parseProjectFacts(input.projectFacts), + }; +} + +function initializationManifest( + input: GreenfieldInitializationInput, + _now: string, +): SchemaManifestV1 { + return parseSchemaManifest({ + manifestVersion: 1, + layoutVersion: 3, + epoch: input.schemaEpoch, + activationState: 'initializing', + minReaderVersion: input.minReaderVersion, + minWriterVersion: input.minWriterVersion, + activatedAt: null, + legacyBaseline: null, + managedAdapters: input.managedAdapters, + lastOperationId: input.operationId, + }); +} + +function initializationConfig( + config: ProjectConfigV1, + input: GreenfieldInitializationInput, + now: string, +): ProjectConfigV1 { + return parseProjectConfig({ + ...config, + workspaceId: input.workspaceId, + lastOperationId: input.operationId, + updatedAt: now, + }); +} + +function initializationPolicy( + policy: TeamPolicyV1, + input: GreenfieldInitializationInput, + now: string, +): TeamPolicyV1 { + return parseTeamPolicy({ + ...policy, + workspaceId: input.workspaceId, + lastOperationId: input.operationId, + updatedAt: now, + }); +} + +function initializationProjectFacts( + input: GreenfieldInitializationInput, + now: string, +): ProjectFactsV1 { + const source = + input.projectFacts ?? + unknownProjectFacts({ + now: new Date(now), + operationId: input.operationId, + }); + return parseProjectFacts({ + ...source, + detectedAt: now, + lastOperationId: input.operationId, + }); +} + +function journalPath(root: string, operationId: Ulid): string { + assertUlid(operationId, 'greenfield operationId'); + return path.join(root, JOURNAL_RELATIVE_DIRECTORY, `${operationId}.json`); +} + +async function writeJson(target: string, value: unknown): Promise { + const directory = path.dirname(target); + await mkdir(directory, { recursive: true }); + const temporary = path.join( + directory, + `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + await rename(temporary, target); +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value)) { + throw new Error(`greenfield initialization journal ${label} is invalid`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`greenfield initialization journal ${label} is invalid`); + } + return value; +} + +function isState(value: unknown): value is GreenfieldInitializationState { + return ( + value === 'staged' || + value === 'published' || + value === 'repair_required' || + value === 'activated' || + value === 'aborted' + ); +} + +async function lstatOrNull(target: string) { + try { + return await lstat(target); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} diff --git a/src/context/ids.ts b/src/context/ids.ts new file mode 100644 index 0000000..78429c8 --- /dev/null +++ b/src/context/ids.ts @@ -0,0 +1,56 @@ +import { randomBytes } from 'node:crypto'; + +const CROCKFORD_BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +const ULID_PATTERN = /^[0-7][0-9A-HJKMNPQRSTVWXYZ]{25}$/; +const MAX_ULID_TIMESTAMP = 2 ** 48 - 1; + +export type Ulid = string; + +export function isUlid(value: unknown): value is Ulid { + return typeof value === 'string' && ULID_PATTERN.test(value); +} + +export function assertUlid( + value: unknown, + label = 'ULID', +): asserts value is Ulid { + if (!isUlid(value)) { + throw new Error(`${label} must be a canonical ULID`); + } +} + +/** + * Creates a canonical 26-character ULID. The optional inputs keep the + * timestamp and entropy boundary testable without weakening production IDs. + */ +export function createUlid( + now: number = Date.now(), + entropy: Uint8Array = randomBytes(10), +): Ulid { + if (!Number.isSafeInteger(now) || now < 0 || now > MAX_ULID_TIMESTAMP) { + throw new Error('ULID timestamp must fit in 48 bits'); + } + if (entropy.length !== 10) { + throw new Error('ULID entropy must contain exactly 10 bytes'); + } + + const timestamp = encodeBase32(BigInt(now), 10); + const random = encodeBase32( + BigInt(`0x${Buffer.from(entropy).toString('hex')}`), + 16, + ); + return `${timestamp}${random}`; +} + +function encodeBase32(value: bigint, length: number): string { + let remaining = value; + let encoded = ''; + for (let index = 0; index < length; index += 1) { + encoded = `${CROCKFORD_BASE32[Number(remaining & 31n)]}${encoded}`; + remaining >>= 5n; + } + if (remaining !== 0n) { + throw new Error('value does not fit in requested base32 length'); + } + return encoded; +} diff --git a/src/context/layout.ts b/src/context/layout.ts new file mode 100644 index 0000000..1dd3e72 --- /dev/null +++ b/src/context/layout.ts @@ -0,0 +1,291 @@ +import { createHash } from 'node:crypto'; +import { lstat, readFile, readdir, readlink } from 'node:fs/promises'; +import path from 'node:path'; +import { digestCanonicalJson } from './canonical.js'; +import type { LegacyBaseline } from './manifest.js'; + +/** + * These are the only paths owned by the legacy implementation. V3 must not + * write any of them, even while a project is in dual-read migration. + */ +export const LEGACY_AUTHORITY_PATHS = [ + 'state.json', + 'config.json', + 'project-profile.json', + 'workflows', + 'memory', +] as const; + +/** V3 paths are deliberately siblings of, never aliases for, legacy paths. */ +export const V3_AUTHORITY_PATHS = [ + 'schema.json', + 'shared', + 'local', + 'runtime', +] as const; + +export type LegacyAuthorityPath = (typeof LEGACY_AUTHORITY_PATHS)[number]; +export type LegacyEntryKind = 'file' | 'directory' | 'symlink' | 'other'; + +export interface LegacyAuthorityEntry { + path: LegacyAuthorityPath; + exists: boolean; + kind: LegacyEntryKind | null; + /** A file, symlink, or non-empty directory is legacy business authority. */ + hasBusinessContent: boolean; + /** Digest of a safe, non-following tree description; never contains content. */ + digest: string | null; +} + +export interface LegacyAuthorityScan { + authorityPresent: boolean; + entries: LegacyAuthorityEntry[]; + baseline: LegacyBaseline | null; + /** Symlinks and special nodes are authority, but cannot be trusted as input. */ + unsafePaths: LegacyAuthorityPath[]; +} + +export interface MancodeLayoutInspection { + legacy: LegacyAuthorityScan; + v3TargetExists: boolean; + v3AuthorityPathsPresent: string[]; +} + +/** + * Reads only the fixed legacy roots without following symlinks. The two + * baseline digests intentionally cover all legacy authority: stateDigest is + * an aggregate of state/config/profile/memory, while workflowIndexDigest is + * the complete legacy workflows tree. This preserves the V1 manifest shape + * while detecting edits outside state.json too. + */ +export async function scanLegacyAuthority( + projectRoot: string, +): Promise { + const root = path.resolve(projectRoot, '.mancode'); + const entries = await Promise.all( + LEGACY_AUTHORITY_PATHS.map((legacyPath) => + scanLegacyEntry(root, legacyPath), + ), + ); + const authorityPresent = entries.some((entry) => entry.hasBusinessContent); + const unsafePaths = entries + .filter((entry) => entry.kind === 'symlink' || entry.kind === 'other') + .map((entry) => entry.path); + return { + authorityPresent, + entries, + baseline: authorityPresent ? baselineFor(entries) : null, + unsafePaths, + }; +} + +/** + * Inspects the physical layout before an initialization or migration. The + * inspection never treats an existing V3 root as permission to overwrite it. + */ +export async function inspectMancodeLayout( + projectRoot: string, +): Promise { + const root = path.resolve(projectRoot); + const mancodeRoot = path.join(root, '.mancode'); + const [legacy, target] = await Promise.all([ + scanLegacyAuthority(root), + lstatOrNull(mancodeRoot), + ]); + const v3AuthorityPathsPresent = ( + await Promise.all( + V3_AUTHORITY_PATHS.map(async (relativePath) => + (await lstatOrNull(path.join(mancodeRoot, relativePath))) === null + ? null + : relativePath, + ), + ) + ).filter( + (value): value is (typeof V3_AUTHORITY_PATHS)[number] => value !== null, + ); + return { + legacy, + v3TargetExists: target !== null, + v3AuthorityPathsPresent, + }; +} + +/** Greenfield initialization must not reinterpret an existing legacy project. */ +export async function assertGreenfieldInitializationPreflight( + projectRoot: string, +): Promise { + const inspection = await inspectMancodeLayout(projectRoot); + if (inspection.legacy.authorityPresent) { + throw new Error('MANCODE_LEGACY_AUTHORITY_PRESENT'); + } + if (inspection.v3TargetExists) { + throw new Error('MANCODE_V3_TARGET_EXISTS'); + } + return inspection; +} + +/** A static invariant, kept executable so future path changes cannot collide. */ +export function assertV3PhysicalIsolation(): void { + for (const legacyPath of LEGACY_AUTHORITY_PATHS) { + for (const v3Path of V3_AUTHORITY_PATHS) { + if (pathsOverlap(legacyPath, v3Path)) { + throw new Error( + `MANCODE_LAYOUT_PATH_COLLISION: ${legacyPath} and ${v3Path}`, + ); + } + } + } +} + +export function sameLegacyBaseline( + left: LegacyBaseline | null, + right: LegacyBaseline | null, +): boolean { + return ( + left === right || + (left !== null && + right !== null && + left.stateDigest === right.stateDigest && + left.workflowIndexDigest === right.workflowIndexDigest) + ); +} + +async function scanLegacyEntry( + root: string, + legacyPath: LegacyAuthorityPath, +): Promise { + const node = await describeNode(path.join(root, legacyPath)); + return { + path: legacyPath, + exists: node !== null, + kind: node?.kind ?? null, + hasBusinessContent: node?.hasBusinessContent ?? false, + digest: node?.digest ?? null, + }; +} + +interface NodeDescription { + kind: LegacyEntryKind; + hasBusinessContent: boolean; + digest: string; +} + +/** Does not call realpath or read a symlink target. */ +async function describeNode(target: string): Promise { + const stat = await lstatOrNull(target); + if (stat === null) return null; + if (stat.isFile()) { + const contents = await readFile(target); + return { + kind: 'file', + hasBusinessContent: true, + digest: sha256(contents), + }; + } + if (stat.isSymbolicLink()) { + const link = await readlink(target); + return { + kind: 'symlink', + hasBusinessContent: true, + digest: sha256(Buffer.from(link, 'utf8')), + }; + } + if (!stat.isDirectory()) { + return { + kind: 'other', + hasBusinessContent: true, + digest: digestCanonicalJson({ kind: 'other' }), + }; + } + const names = await readdir(target); + names.sort(compareUtf8); + const children = await Promise.all( + names.map(async (name) => { + const child = await describeNode(path.join(target, name)); + if (child === null) { + throw new Error('MANCODE_LEGACY_TREE_CHANGED_DURING_SCAN'); + } + return { name, ...child }; + }), + ); + return { + kind: 'directory', + hasBusinessContent: children.some((child) => child.hasBusinessContent), + digest: digestCanonicalJson({ + kind: 'directory', + children: children.map((child) => ({ + name: child.name, + kind: child.kind, + digest: child.digest, + })), + }), + }; +} + +function baselineFor(entries: LegacyAuthorityEntry[]): LegacyBaseline { + const workflow = entryFor(entries, 'workflows'); + const stateEntries = entries + .filter((entry) => entry.path !== 'workflows') + .map((entry) => ({ + path: entry.path, + exists: entry.exists, + kind: entry.kind, + digest: entry.digest, + })); + return { + stateDigest: digestCanonicalJson({ version: 1, entries: stateEntries }), + workflowIndexDigest: digestCanonicalJson({ + version: 1, + exists: workflow.exists, + kind: workflow.kind, + digest: workflow.digest, + }), + }; +} + +function entryFor( + entries: LegacyAuthorityEntry[], + pathName: LegacyAuthorityPath, +): LegacyAuthorityEntry { + const entry = entries.find((candidate) => candidate.path === pathName); + if (entry === undefined) { + throw new Error(`legacy authority entry is missing: ${pathName}`); + } + return entry; +} + +async function lstatOrNull(target: string) { + try { + return await lstat(target); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +function pathsOverlap(left: string, right: string): boolean { + const normalizedLeft = left.replaceAll('\\', '/'); + const normalizedRight = right.replaceAll('\\', '/'); + return ( + normalizedLeft === normalizedRight || + normalizedLeft.startsWith(`${normalizedRight}/`) || + normalizedRight.startsWith(`${normalizedLeft}/`) + ); +} + +function sha256(value: Buffer): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/context/local-overlay.ts b/src/context/local-overlay.ts new file mode 100644 index 0000000..0f68784 --- /dev/null +++ b/src/context/local-overlay.ts @@ -0,0 +1,226 @@ +import { createHash } from 'node:crypto'; +import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { locateTask } from './task-locator.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; + +export interface LocalOverlayArtifactV1 { + schemaVersion: 1; + taskRef: TaskRef; + artifactId: Ulid; + byteLength: number; + contentDigest: string; + path: string; +} + +export interface WriteLocalOverlayArtifactInput { + projectRoot: string; + taskRef: TaskRef; + artifactId?: Ulid; + content: string | Uint8Array; +} + +/** + * Stores private evidence for an existing shared task under local authority. + * The artifact ID is immutable and never becomes a shared ArtifactRef. + */ +export async function writeLocalOverlayArtifact( + input: WriteLocalOverlayArtifactInput, +): Promise { + const taskRef = sharedTaskRef(input.taskRef); + await locateTask(input.projectRoot, taskRef); + const artifactId = input.artifactId ?? createUlid(); + assertUlid(artifactId, 'local overlay artifactId'); + const content = overlayContent(input.content); + const directory = await ensureSafeOverlayDirectory( + input.projectRoot, + taskRef, + ); + const target = path.join(directory, artifactId); + try { + await writeFile(target, content, { flag: 'wx', mode: 0o600 }); + await assertSafeFile(target); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readSafeFile(target); + if (!existing.equals(content)) { + throw new Error('MANCODE_OVERLAY_ARTIFACT_CONFLICT'); + } + } + return overlayArtifact(taskRef, artifactId, content, target); +} + +export async function readLocalOverlayArtifact( + projectRoot: string, + taskRef: TaskRef, + artifactId: Ulid, +): Promise { + const parsedTaskRef = sharedTaskRef(taskRef); + assertUlid(artifactId, 'local overlay artifactId'); + await assertSafeOverlayDirectory(projectRoot, parsedTaskRef); + try { + return await readSafeFile( + localOverlayArtifactPath(projectRoot, parsedTaskRef, artifactId), + ); + } catch (error) { + if (isNotFound(error)) { + throw new Error('MANCODE_OVERLAY_ARTIFACT_NOT_FOUND'); + } + throw error; + } +} + +export function localOverlayArtifactsDirectory( + projectRoot: string, + taskRef: TaskRef, +): string { + const parsed = sharedTaskRef(taskRef); + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'overlays', + parsed.taskId, + 'artifacts', + ); +} + +export function localOverlayArtifactPath( + projectRoot: string, + taskRef: TaskRef, + artifactId: Ulid, +): string { + assertUlid(artifactId, 'local overlay artifactId'); + return path.join( + localOverlayArtifactsDirectory(projectRoot, taskRef), + artifactId, + ); +} + +function sharedTaskRef(value: TaskRef): TaskRef { + const taskRef = parseTaskRefValue(value); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_OVERLAY_REQUIRES_SHARED_TASK'); + } + return taskRef; +} + +function overlayContent(value: string | Uint8Array): Buffer { + if (typeof value === 'string') return Buffer.from(value, 'utf8'); + if (value instanceof Uint8Array) return Buffer.from(value); + throw new Error('MANCODE_OVERLAY_CONTENT_INVALID'); +} + +function overlayArtifact( + taskRef: TaskRef, + artifactId: Ulid, + content: Buffer, + target: string, +): LocalOverlayArtifactV1 { + return { + schemaVersion: 1, + taskRef, + artifactId, + byteLength: content.byteLength, + contentDigest: `sha256:${createHash('sha256').update(content).digest('hex')}`, + path: target, + }; +} + +async function ensureSafeOverlayDirectory( + projectRoot: string, + taskRef: TaskRef, +): Promise { + let current = path.resolve(projectRoot); + for (const segment of [ + '.mancode', + 'local', + 'overlays', + taskRef.taskId, + 'artifacts', + ]) { + current = path.join(current, segment); + try { + await mkdir(current, { mode: 0o700 }); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + await assertSafeDirectory(current); + } + return current; +} + +async function assertSafeOverlayDirectory( + projectRoot: string, + taskRef: TaskRef, +): Promise { + let current = path.resolve(projectRoot); + for (const segment of [ + '.mancode', + 'local', + 'overlays', + taskRef.taskId, + 'artifacts', + ]) { + current = path.join(current, segment); + try { + await assertSafeDirectory(current); + } catch (error) { + if (isNotFound(error)) { + throw new Error('MANCODE_OVERLAY_ARTIFACT_NOT_FOUND'); + } + throw error; + } + } +} + +async function assertSafeDirectory(target: string): Promise { + const entry = await lstat(target); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_OVERLAY_PATH_UNSAFE'); + } +} + +async function assertSafeFile(target: string): Promise { + const entry = await lstat(target); + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error('MANCODE_OVERLAY_PATH_UNSAFE'); + } +} + +async function readSafeFile(target: string): Promise { + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_OVERLAY_PATH_UNSAFE'); + } + const content = await readFile(target); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_OVERLAY_PATH_UNSAFE'); + } + return content; +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/context/manifest.ts b/src/context/manifest.ts new file mode 100644 index 0000000..dbbf6df --- /dev/null +++ b/src/context/manifest.ts @@ -0,0 +1,272 @@ +import { type Ulid, assertUlid } from './ids.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export type ActivationState = + | 'initializing' + | 'dual_read' + | 'activating' + | 'v3_active' + | 'repair_required'; + +export type ManagedAdapter = + | 'claude-code' + | 'codex' + | 'cursor' + | 'copilot' + | 'zcode'; + +export interface LegacyBaseline { + stateDigest: string; + workflowIndexDigest: string; +} + +export interface SchemaManifestV1 { + manifestVersion: 1; + layoutVersion: 3; + epoch: Ulid; + activationState: ActivationState; + minReaderVersion: string; + minWriterVersion: string; + activatedAt: string | null; + legacyBaseline: LegacyBaseline | null; + managedAdapters: Record; + lastOperationId: Ulid | null; +} + +const ACTIVATION_STATES = new Set([ + 'initializing', + 'dual_read', + 'activating', + 'v3_active', + 'repair_required', +]); +const MANAGED_ADAPTERS: ManagedAdapter[] = [ + 'claude-code', + 'codex', + 'cursor', + 'copilot', + 'zcode', +]; +const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function parseSchemaManifest(value: unknown): SchemaManifestV1 { + assertRecord(value, 'schema manifest'); + assertKnownKeys( + value, + [ + 'manifestVersion', + 'layoutVersion', + 'epoch', + 'activationState', + 'minReaderVersion', + 'minWriterVersion', + 'activatedAt', + 'legacyBaseline', + 'managedAdapters', + 'lastOperationId', + ], + 'schema manifest', + ); + if (value.manifestVersion !== 1 || value.layoutVersion !== 3) { + throw new Error( + 'schema manifest must use manifestVersion 1 and layoutVersion 3', + ); + } + assertUlid(value.epoch, 'schema manifest epoch'); + if ( + typeof value.activationState !== 'string' || + !ACTIVATION_STATES.has(value.activationState as ActivationState) + ) { + throw new Error('schema manifest activationState is invalid'); + } + const manifest: SchemaManifestV1 = { + manifestVersion: 1, + layoutVersion: 3, + epoch: value.epoch, + activationState: value.activationState as ActivationState, + minReaderVersion: parseVersion(value.minReaderVersion, 'minReaderVersion'), + minWriterVersion: parseVersion(value.minWriterVersion, 'minWriterVersion'), + activatedAt: parseTimestampOrNull(value.activatedAt, 'activatedAt'), + legacyBaseline: parseLegacyBaseline(value.legacyBaseline), + managedAdapters: parseManagedAdapters(value.managedAdapters), + lastOperationId: parseUlidOrNull(value.lastOperationId, 'lastOperationId'), + }; + assertManifestStateShape(manifest); + return manifest; +} + +export function assertSchemaManifestTransition( + previous: SchemaManifestV1, + next: SchemaManifestV1, +): void { + if ( + previous.manifestVersion !== next.manifestVersion || + previous.layoutVersion !== next.layoutVersion || + previous.epoch !== next.epoch || + !sameLegacyBaseline(previous.legacyBaseline, next.legacyBaseline) + ) { + throw new Error( + 'schema manifest identity and legacy baseline are immutable', + ); + } + if (previous.activationState === next.activationState) return; + if ( + !allowedManifestTransitions(previous.activationState).has( + next.activationState, + ) + ) { + throw new Error( + `invalid schema manifest transition: ${previous.activationState} -> ${next.activationState}`, + ); + } +} + +/** + * Activation rollback is intentionally not a general manifest transition. + * The caller must separately prove that the activation's exact targets have + * not been followed by a V3 business write. + */ +export function assertActivationRollbackManifestTransition( + previous: SchemaManifestV1, + next: SchemaManifestV1, +): void { + if ( + previous.manifestVersion !== next.manifestVersion || + previous.layoutVersion !== next.layoutVersion || + previous.epoch !== next.epoch || + !sameLegacyBaseline(previous.legacyBaseline, next.legacyBaseline) || + previous.activationState !== 'v3_active' || + next.activationState !== 'dual_read' || + previous.activatedAt === null || + next.activatedAt !== null + ) { + throw new Error('invalid activation rollback manifest transition'); + } +} + +function parseVersion(value: unknown, label: string): string { + if (typeof value !== 'string' || !VERSION_PATTERN.test(value)) { + throw new Error(`schema manifest ${label} must be a semantic version`); + } + return value; +} + +function parseTimestampOrNull(value: unknown, label: string): string | null { + if (value === null) return null; + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error( + `schema manifest ${label} must be an ISO timestamp or null`, + ); + } + return value; +} + +function parseLegacyBaseline(value: unknown): LegacyBaseline | null { + if (value === null) return null; + assertRecord(value, 'schema manifest legacyBaseline'); + assertKnownKeys( + value, + ['stateDigest', 'workflowIndexDigest'], + 'schema manifest legacyBaseline', + ); + if ( + typeof value.stateDigest !== 'string' || + !DIGEST_PATTERN.test(value.stateDigest) || + typeof value.workflowIndexDigest !== 'string' || + !DIGEST_PATTERN.test(value.workflowIndexDigest) + ) { + throw new Error( + 'schema manifest legacyBaseline must contain sha256 digests', + ); + } + return { + stateDigest: value.stateDigest, + workflowIndexDigest: value.workflowIndexDigest, + }; +} + +function parseManagedAdapters(value: unknown): Record { + assertRecord(value, 'schema manifest managedAdapters'); + assertKnownKeys(value, MANAGED_ADAPTERS, 'schema manifest managedAdapters'); + const adapters = {} as Record; + for (const adapter of MANAGED_ADAPTERS) { + if (typeof value[adapter] !== 'string' || !value[adapter].trim()) { + throw new Error(`schema manifest managedAdapters.${adapter} is required`); + } + adapters[adapter] = value[adapter]; + } + return adapters; +} + +function parseUlidOrNull(value: unknown, label: string): Ulid | null { + if (value === null) return null; + assertUlid(value, `schema manifest ${label}`); + return value; +} + +function assertManifestStateShape(manifest: SchemaManifestV1): void { + if ( + manifest.activationState === 'initializing' && + manifest.legacyBaseline !== null + ) { + throw new Error( + 'greenfield initializing manifests must not have a legacy baseline', + ); + } + if ( + (manifest.activationState === 'dual_read' || + manifest.activationState === 'activating') && + manifest.legacyBaseline === null + ) { + throw new Error( + `${manifest.activationState} manifests require a legacy baseline`, + ); + } + if ( + manifest.activationState === 'v3_active' && + manifest.activatedAt === null + ) { + throw new Error('v3_active manifests require activatedAt'); + } + if ( + (manifest.activationState === 'initializing' || + manifest.activationState === 'dual_read' || + manifest.activationState === 'activating') && + manifest.activatedAt !== null + ) { + throw new Error( + `${manifest.activationState} manifests must not have activatedAt`, + ); + } +} + +function sameLegacyBaseline( + left: LegacyBaseline | null, + right: LegacyBaseline | null, +): boolean { + return ( + left === right || + (left !== null && + right !== null && + left.stateDigest === right.stateDigest && + left.workflowIndexDigest === right.workflowIndexDigest) + ); +} + +function allowedManifestTransitions( + from: ActivationState, +): Set { + switch (from) { + case 'initializing': + return new Set(['v3_active', 'repair_required']); + case 'dual_read': + return new Set(['activating', 'repair_required']); + case 'activating': + return new Set(['v3_active', 'repair_required']); + case 'repair_required': + return new Set(['v3_active']); + case 'v3_active': + return new Set(['repair_required']); + } +} diff --git a/src/context/migrate.ts b/src/context/migrate.ts new file mode 100644 index 0000000..8146ac1 --- /dev/null +++ b/src/context/migrate.ts @@ -0,0 +1,2851 @@ +import { createHash } from 'node:crypto'; +import { + lstat, + mkdir, + readFile, + readdir, + rename, + rm, + rmdir, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import type { MancodeState } from '../commands/init.js'; +import { + V3_ADAPTER_VERSION, + applyV3AdapterFilePlan, + planV3AdapterFiles, + v3AdapterTargetPath, +} from '../installers/v3-adapter.js'; +import { + operationDirectory, + resolveCoordinationEntityHomeStore, + resolveLocalEntityHomeStore, +} from '../runtime/entity-home-store.js'; +import { acquireOperationEntityLocks } from '../runtime/local-lock.js'; +import { + armOperationCrashAfterVisibleWrite, + throwIfDeferredOperationCrashInjected, + throwIfOperationCrashInjected, +} from '../runtime/operation-crash-injection.js'; +import { + assertOperationJournalMatchesDefinition, + getOperationDefinition, +} from '../runtime/operation-definition.js'; +import { + type OperationJournalV1, + withOperationReservationDigests, +} from '../runtime/operation-journal.js'; +import { + assertOperationRecoveryPayloadCoversJournal, + createMigrationStageFileRecoveryAction, + createMigrationTaskDirectoryRecoveryAction, + createProjectAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, + createV3AdapterFileRecoveryAction, + operationRecoveryPayloadDigest, + parseOperationRecoveryPayload, +} from '../runtime/operation-recovery-payload.js'; +import { + readOperationRecoveryPayload, + writeOperationRecoveryPayload, +} from '../runtime/operation-recovery-store.js'; +import { + prepareOperationStores, + readOperationJournal, + updateOperationJournal, +} from '../runtime/operation-store.js'; +import { ensureProjectRuntimeContext } from '../runtime/project-runtime.js'; +import { readCheckoutCodeHead } from '../runtime/project-runtime.js'; +import { readSession } from '../runtime/session.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { + createTaskHeadFence, + readTaskHeadFence, +} from '../runtime/task-head-store.js'; +import { taskEntityKey, taskHeadEntityKey } from '../runtime/task-operation.js'; +import { readRequirementsLedger } from '../system/requirements-ledger.js'; +import { readReviewLedger } from '../system/review-ledger.js'; +import { readVerificationLedger } from '../system/verification-ledger.js'; +import { + type WorkflowMeta as LegacyWorkflowMeta, + isValidWorkflowTaskId, + readWorkflow, +} from '../system/workflow.js'; +import { readSharedActorProfile } from '../team/actor.js'; +import { createAuthorizationBasis } from '../team/authorization.js'; +import { + type ProjectConfigV1, + type TeamPolicyV1, + assertConfigPolicyConsistency, + assertProjectConfigTransition, + parseProjectConfig, + parseTeamPolicy, +} from '../team/policy.js'; +import { capabilitiesFromProjectConfig } from '../team/transport.js'; +import { VERSION } from '../version.js'; +import { + buildTaskAggregateManifest, + taskAggregateDigest, +} from './aggregate.js'; +import { digestCanonicalJson, sortUtf8StringSet } from './canonical.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { sameLegacyBaseline, scanLegacyAuthority } from './layout.js'; +import { + type LegacyBaseline, + type SchemaManifestV1, + assertActivationRollbackManifestTransition, + assertSchemaManifestTransition, + parseSchemaManifest, +} from './manifest.js'; +import type { ManagedAdapter } from './manifest.js'; +import { + type LegacyMigrationOwner, + type LegacyTaskAliasMap, + type LegacyTaskMigrationSource, + type MigratedLegacyTaskCandidate, + type MigrationParityReportV1, + assertLegacyStatePointers, + assertMigrationParity, + createDeterministicMigrationIdAllocator, + createLegacyTaskAliasMap, + createMigrationParityReport, + migrateLegacyTaskToV3, +} from './migration-parity.js'; +import { assertSafeSharedRelativePath } from './privacy.js'; +import { + type QuarantineArtifact, + type QuarantineCandidateV1, + createQuarantineCandidate, + previewQuarantineCandidate, + quarantineDirectory, + scanQuarantineCandidate, + validateQuarantinePaths, +} from './quarantine.js'; +import { parseRequirementsLedger } from './requirements-ledger.js'; +import { parseReviewLedger } from './review-ledger.js'; +import { normalizeLegacyWorkflowMode } from './schema.js'; +import { taskRootPath } from './task-locator.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; +import { parseVerificationLedger } from './verification-ledger.js'; +import { + type WorkflowMetadataV3, + parseWorkflowMetadata, + workflowMetadataDigest, +} from './workflow-metadata.js'; + +/** + * The migration stage is deliberately local-only. It is an inspection and + * quarantine record, not a second workflow authority and not an activation + * journal. Legacy files remain untouched until a separate activation + * operation reaches its commit point. + */ +export const MIGRATION_STAGE_SCHEMA_VERSION = 1; + +export type MigrationStageState = 'staged' | 'activated' | 'rolled_back'; +export type MigrationTaskStageState = 'ready' | 'blocked'; + +export interface MigrationScopeResolutionV1 { + include: string[]; + exclude: string[]; + modules: string[]; +} + +export interface MigrationTaskResolutionV1 { + ownerActorId: Ulid | null; + implementationScope: MigrationScopeResolutionV1 | null; +} + +export interface MigrationTaskStageV1 { + legacyTaskId: string; + taskRef: TaskRef; + quarantineId: Ulid; + state: MigrationTaskStageState; + blockers: string[]; + candidateDigest: string | null; + parityDigest: string | null; + privacyStatus: 'pending' | 'passed' | 'blocked'; +} + +export interface MigrationStageV1 { + schemaVersion: 1; + stageId: Ulid; + revision: number; + state: MigrationStageState; + sourceBaseline: LegacyBaseline; + sourceInventoryDigest: string; + aliases: LegacyTaskAliasMap; + resolutions: Record; + tasks: MigrationTaskStageV1[]; + createdAt: string; + updatedAt: string; +} + +export interface MigrationDryRunReportV1 { + schemaVersion: 1; + sourceBaseline: LegacyBaseline; + sourceInventoryDigest: string; + aliases: LegacyTaskAliasMap; + tasks: MigrationTaskStageV1[]; +} + +export interface StageLegacyMigrationInput { + projectRoot: string; + /** Supplying an existing ID restages the same immutable legacy baseline. */ + stageId?: Ulid; + now?: Date; + minReaderVersion?: string; + minWriterVersion?: string; + managedAdapters?: Record; +} + +export interface ResolveLegacyMigrationInput { + projectRoot: string; + stageId: Ulid; + legacyTaskId: string; + expectedStageRevision: number; + ownerActorId?: Ulid; + implementationScope?: MigrationScopeResolutionV1; + now?: Date; +} + +export interface ActivateLegacyMigrationInput { + projectRoot: string; + stageId: Ulid; + expectedStageRevision: number; + sessionId: Ulid; + /** The command-level acknowledgement required for a project-wide cutover. */ + explicitConfirmation: boolean; + /** Required when the staged set contains a shared task. */ + sharedPrivacyConfirmed: boolean; + operationId?: Ulid; + now?: Date; +} + +export interface ActivatedLegacyMigration { + manifest: SchemaManifestV1; + stage: MigrationStageV1; + operation: OperationJournalV1; +} + +export interface RollbackLegacyMigrationInput { + projectRoot: string; + operationId: Ulid; + sessionId: Ulid; + explicitConfirmation: boolean; + now?: Date; +} + +export interface RolledBackLegacyMigration { + manifest: SchemaManifestV1; + stage: MigrationStageV1; + operation: OperationJournalV1; +} + +interface LoadedLegacyTask { + legacyTaskId: string; + workflow: LegacyWorkflowMeta; + source: LegacyTaskMigrationSource | null; + sourceFiles: Array<{ relativePath: string; content: string }>; + blockers: string[]; +} + +interface LegacyMigrationSourceSet { + baseline: LegacyBaseline; + inventoryDigest: string; + state: Partial | null; + tasks: LoadedLegacyTask[]; +} + +interface RenderedTask { + stage: MigrationTaskStageV1; + candidate: MigratedLegacyTaskCandidate | null; + report: MigrationParityReportV1 | null; + quarantine: QuarantineCandidateV1; + sourceFiles: Array<{ relativePath: string; content: string }>; +} + +const MANAGED_ADAPTERS: ManagedAdapter[] = [ + 'claude-code', + 'codex', + 'cursor', + 'copilot', + 'zcode', +]; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; +const STAGE_STATES = new Set([ + 'staged', + 'activated', + 'rolled_back', +]); + +/** + * Performs the same source scan and parity rendering as staging, but writes + * nothing. This is the only migration entry point safe to run against an + * unknown checkout without first creating V3 compatibility files. + */ +export async function dryRunLegacyMigration( + projectRoot: string, +): Promise { + const sourceSet = await loadLegacyMigrationSources(projectRoot); + const allocator = createDeterministicMigrationIdAllocator( + migrationSeed(sourceSet), + ); + const aliases = createAliases(sourceSet.tasks, allocator); + const rendered = renderTasks(sourceSet, aliases, {}, allocator, new Date()); + return { + schemaVersion: 1, + sourceBaseline: sourceSet.baseline, + sourceInventoryDigest: sourceSet.inventoryDigest, + aliases, + tasks: rendered.map((item) => item.stage), + }; +} + +/** + * Creates (or validates) the dual-read compatibility shell and writes a + * local-only migration stage. It never updates legacy state/workflow files, + * and it leaves the manifest in `dual_read`. + */ +export async function stageLegacyMigration( + input: StageLegacyMigrationInput, +): Promise { + const root = path.resolve(input.projectRoot); + const sourceSet = await loadLegacyMigrationSources(root); + const now = (input.now ?? new Date()).toISOString(); + const allocator = createDeterministicMigrationIdAllocator( + migrationSeed(sourceSet), + ); + const aliases = createAliases(sourceSet.tasks, allocator); + const stageId = input.stageId ?? createUlid(); + assertUlid(stageId, 'migration stageId'); + + await ensureDualReadShell({ + projectRoot: root, + sourceSet, + allocator, + now, + minReaderVersion: input.minReaderVersion ?? VERSION, + minWriterVersion: input.minWriterVersion ?? VERSION, + managedAdapters: input.managedAdapters ?? defaultManagedAdapters(), + }); + + return withMigrationStageLock(root, stageId, async () => { + const existing = await readMigrationStageOrNull(root, stageId); + if (existing !== null) { + if (existing.state !== 'staged') { + throw new Error('MANCODE_MIGRATION_STAGE_NOT_MUTABLE'); + } + if (!sameLegacyBaseline(existing.sourceBaseline, sourceSet.baseline)) { + throw new Error('MANCODE_LEGACY_BASELINE_CHANGED'); + } + if (existing.sourceInventoryDigest !== sourceSet.inventoryDigest) { + throw new Error('MANCODE_LEGACY_SOURCE_CHANGED'); + } + if (!sameAliases(existing.aliases, aliases)) { + throw new Error('MANCODE_MIGRATION_ALIAS_MAP_CHANGED'); + } + const rendered = renderTasks( + sourceSet, + aliases, + existing.resolutions, + allocator, + new Date(now), + ); + await writeRenderedTasks(root, rendered); + const next = parseMigrationStage({ + ...existing, + revision: existing.revision + 1, + tasks: rendered.map((item) => item.stage), + updatedAt: now, + }); + await writeMigrationStage(root, next); + return next; + } + + const rendered = renderTasks( + sourceSet, + aliases, + {}, + allocator, + new Date(now), + ); + await writeRenderedTasks(root, rendered); + const stage = parseMigrationStage({ + schemaVersion: 1, + stageId, + revision: 1, + state: 'staged', + sourceBaseline: sourceSet.baseline, + sourceInventoryDigest: sourceSet.inventoryDigest, + aliases, + resolutions: {}, + tasks: rendered.map((item) => item.stage), + createdAt: now, + updatedAt: now, + }); + await writeMigrationStage(root, stage); + return stage; + }); +} + +/** + * Resolves only migration-local missing facts. The legacy source is scanned + * again and must still match the stage baseline before a candidate is + * regenerated. This prevents a resolution from accidentally applying to a + * different legacy workflow revision. + */ +export async function resolveLegacyMigration( + input: ResolveLegacyMigrationInput, +): Promise { + const root = path.resolve(input.projectRoot); + assertUlid(input.stageId, 'migration stageId'); + assertLegacyTaskId(input.legacyTaskId); + if ( + !Number.isSafeInteger(input.expectedStageRevision) || + input.expectedStageRevision < 1 + ) { + throw new Error('MANCODE_MIGRATION_STAGE_REVISION_INVALID'); + } + if (input.ownerActorId !== undefined) { + assertUlid(input.ownerActorId, 'migration resolution ownerActorId'); + } + const scope = + input.implementationScope === undefined + ? undefined + : parseMigrationScopeResolution(input.implementationScope); + + return withMigrationStageLock(root, input.stageId, async () => { + const stage = await readMigrationStage(root, input.stageId); + if (stage.state !== 'staged') { + throw new Error('MANCODE_MIGRATION_STAGE_NOT_MUTABLE'); + } + if (stage.revision !== input.expectedStageRevision) { + throw new Error('MANCODE_REVISION_CONFLICT'); + } + if (stage.aliases[input.legacyTaskId] === undefined) { + throw new Error('MANCODE_MIGRATION_LEGACY_TASK_NOT_FOUND'); + } + const sourceSet = await loadLegacyMigrationSources(root); + if (!sameLegacyBaseline(stage.sourceBaseline, sourceSet.baseline)) { + throw new Error('MANCODE_LEGACY_BASELINE_CHANGED'); + } + if (stage.sourceInventoryDigest !== sourceSet.inventoryDigest) { + throw new Error('MANCODE_LEGACY_SOURCE_CHANGED'); + } + const allocator = createDeterministicMigrationIdAllocator( + migrationSeed(sourceSet), + ); + const aliases = createAliases(sourceSet.tasks, allocator); + if (!sameAliases(stage.aliases, aliases)) { + throw new Error('MANCODE_MIGRATION_ALIAS_MAP_CHANGED'); + } + const previous = stage.resolutions[input.legacyTaskId] ?? { + ownerActorId: null, + implementationScope: null, + }; + const resolutions: Record = { + ...stage.resolutions, + [input.legacyTaskId]: parseMigrationTaskResolution({ + ownerActorId: input.ownerActorId ?? previous.ownerActorId, + implementationScope: scope ?? previous.implementationScope, + }), + }; + const now = (input.now ?? new Date()).toISOString(); + const rendered = renderTasks( + sourceSet, + aliases, + resolutions, + allocator, + new Date(now), + ); + await writeRenderedTasks(root, rendered); + const next = parseMigrationStage({ + ...stage, + revision: stage.revision + 1, + resolutions, + tasks: rendered.map((item) => item.stage), + updatedAt: now, + }); + await writeMigrationStage(root, next); + return next; + }); +} + +/** + * Promotes a frozen, parity-checked migration stage through a single + * write-ahead operation. All content is rendered again under the canonical + * stage lock, so activation never trusts mutable quarantine files alone. + */ +export async function activateLegacyMigration( + input: ActivateLegacyMigrationInput, +): Promise { + const root = path.resolve(input.projectRoot); + assertUlid(input.stageId, 'migration stageId'); + assertUlid(input.sessionId, 'migration activation sessionId'); + if ( + !Number.isSafeInteger(input.expectedStageRevision) || + input.expectedStageRevision < 1 + ) { + throw new Error('MANCODE_MIGRATION_STAGE_REVISION_INVALID'); + } + if (input.explicitConfirmation !== true) { + throw new Error('MANCODE_EXPLICIT_CONFIRMATION_REQUIRED'); + } + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'migration activation operationId'); + + return withMigrationStageLock(root, input.stageId, async () => { + const runtime = await ensureProjectRuntimeContext(root, now); + const localStore = resolveLocalEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const coordinationStore = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const session = await readSession(root, input.sessionId); + if (session === null || session.status !== 'active') { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + const store = new (await import('./store.js')).V3ContextStore(root); + const project = await store.readProjectSnapshot(); + const stage = await readMigrationStage(root, input.stageId); + if ( + stage.state !== 'staged' || + stage.revision !== input.expectedStageRevision + ) { + throw new Error( + stage.state !== 'staged' + ? 'MANCODE_MIGRATION_STAGE_NOT_MUTABLE' + : 'MANCODE_REVISION_CONFLICT', + ); + } + if (project.manifest.activationState !== 'dual_read') { + throw new Error('MANCODE_MIGRATION_MANIFEST_STATE_INVALID'); + } + const sourceSet = await loadLegacyMigrationSources(root); + if ( + !sameLegacyBaseline(stage.sourceBaseline, sourceSet.baseline) || + stage.sourceInventoryDigest !== sourceSet.inventoryDigest || + !sameLegacyBaseline(project.manifest.legacyBaseline, sourceSet.baseline) + ) { + throw new Error('MANCODE_LEGACY_BASELINE_CHANGED'); + } + const allocator = createDeterministicMigrationIdAllocator( + migrationSeed(sourceSet), + ); + const aliases = createAliases(sourceSet.tasks, allocator); + if (!sameAliases(stage.aliases, aliases)) { + throw new Error('MANCODE_MIGRATION_ALIAS_MAP_CHANGED'); + } + const rendered = renderTasks( + sourceSet, + aliases, + stage.resolutions, + allocator, + now, + ); + assertRenderedMatchesStage(rendered, stage); + for (const item of rendered) { + if ( + item.candidate === null || + item.report === null || + item.stage.state !== 'ready' || + item.stage.privacyStatus !== 'passed' + ) { + throw new Error('MANCODE_MIGRATION_ACTIVATION_BLOCKED'); + } + assertMigrationParity(item.report); + } + const shared = rendered.filter( + (item) => item.stage.taskRef.namespace === 'shared', + ); + if (shared.length > 0 && input.sharedPrivacyConfirmed !== true) { + throw new Error('MANCODE_PRIVACY_CONFIRMATION_REQUIRED'); + } + const joined = + (await readSharedActorProfile(root, session.actorId)) !== null; + const authorizationBasis = createAuthorizationBasis( + { + action: 'team_policy_config_transport', + actorId: session.actorId, + session: { + sessionId: session.sessionId, + actorId: session.actorId, + status: session.status, + }, + joined, + sharedWriteGuard: capabilitiesFromProjectConfig(project.config) + .writeGuard, + task: null, + claim: null, + handoff: null, + evidence: null, + profileActorId: null, + conditions: { + expectedRevisionMatches: true, + explicitConfirmation: true, + privacyConfirmed: shared.length === 0 || input.sharedPrivacyConfirmed, + }, + }, + now, + ); + const codeHead = + shared.length === 0 ? null : await readCheckoutCodeHead(root); + if (shared.length > 0 && codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + } + const adapterPlans = await planV3AdapterFiles(root); + const entities = buildActivationEntities( + rendered, + runtime, + operationId, + now, + codeHead, + ); + await assertActivationTargetsAbsent(root, coordinationStore, entities); + + const locks = activationLockKeys(input.stageId, entities, adapterPlans); + const primaryStore = shared.length > 0 ? coordinationStore : localStore; + const secondaryStores = + primaryStore.storeId === localStore.storeId ? [] : [localStore]; + const expectedRevisions: Record = { + 'schema:project': 1, + 'config:project': project.config.revision, + [`stage:${stage.stageId}`]: stage.revision, + ...Object.fromEntries( + adapterPlans.map((plan) => [`adapter:${plan.target}`, 0]), + ), + ...Object.fromEntries( + entities.map((entity) => [taskEntityKey(entity.taskRef), 0]), + ), + ...Object.fromEntries( + entities + .filter((entity) => entity.taskRef.namespace === 'shared') + .map((entity) => [taskHeadEntityKey(entity.taskRef), 0]), + ), + }; + const manifestActivating = parseSchemaManifest({ + ...project.manifest, + activationState: 'activating', + lastOperationId: operationId, + }); + assertSchemaManifestTransition(project.manifest, manifestActivating); + const manifestActive = parseSchemaManifest({ + ...manifestActivating, + activationState: 'v3_active', + activatedAt: now.toISOString(), + managedAdapters: Object.fromEntries( + Object.keys(project.manifest.managedAdapters).map((adapter) => [ + adapter, + V3_ADAPTER_VERSION, + ]), + ) as Record, + }); + assertSchemaManifestTransition(manifestActivating, manifestActive); + const activatedConfig = parseProjectConfig({ + ...project.config, + revision: project.config.revision + 1, + lastOperationId: operationId, + updatedAt: now.toISOString(), + }); + assertProjectConfigTransition(project.config, activatedConfig, 'ordinary'); + const activatedStage = parseMigrationStage({ + ...stage, + revision: stage.revision + 1, + state: 'activated', + updatedAt: now.toISOString(), + }); + const payload = parseOperationRecoveryPayload({ + schemaVersion: 1, + operationId, + type: 'v3_activate', + primaryStoreId: primaryStore.storeId, + actions: [ + createProjectAuthorityFileRecoveryAction({ + stepId: 'mark-manifest-activating', + fileName: 'schema.json', + beforeContent: serialize(project.manifest), + targetContent: serialize(manifestActivating), + }), + ...adapterPlans.map((plan) => + createV3AdapterFileRecoveryAction({ + stepId: 'replace-managed-adapters', + target: plan.target, + beforeContent: plan.beforeContent, + targetContent: plan.targetContent, + }), + ), + ...entities.flatMap((entity) => [ + createMigrationTaskDirectoryRecoveryAction({ + stepId: 'promote-staged-tasks', + taskRef: entity.taskRef, + files: entity.files, + reports: entity.reports, + }), + ...(entity.taskHead === null + ? [] + : [ + createTaskHeadFenceRecoveryAction({ + stepId: 'promote-staged-tasks', + before: null, + fence: entity.taskHead, + }), + ]), + ]), + createProjectAuthorityFileRecoveryAction({ + stepId: 'record-adapter-inventory-and-baseline', + fileName: 'shared/config.json', + beforeContent: serialize(project.config), + targetContent: serialize(activatedConfig), + }), + createProjectAuthorityFileRecoveryAction({ + stepId: 'activate-manifest', + fileName: 'schema.json', + beforeContent: serialize(manifestActivating), + targetContent: serialize(manifestActive), + }), + createMigrationStageFileRecoveryAction({ + stepId: 'activate-manifest', + stageId: stage.stageId, + beforeContent: serialize(stage), + targetContent: serialize(activatedStage), + }), + ], + noOpStepIds: [], + }); + let journal: OperationJournalV1 = withOperationReservationDigests({ + schemaVersion: 1, + operationId, + type: 'v3_activate', + state: 'prepared', + primaryStoreId: primaryStore.storeId, + checkoutId: runtime.checkoutId, + secondaryReservations: secondaryStores.map((store) => ({ + storeId: store.storeId, + entityKeys: locks.local, + journalDigest: '', + })), + actorId: session.actorId, + sessionId: session.sessionId, + authorizationBasis, + recoveryPayloadDigest: operationRecoveryPayloadDigest(payload), + entityLocks: uniqueLockKeys([...locks.local, ...locks.shared]), + expectedRevisions, + steps: getOperationDefinition('v3_activate').steps.map((step) => ({ + id: step.id, + state: 'pending', + })), + startedAt: now.toISOString(), + updatedAt: now.toISOString(), + }); + assertOperationJournalMatchesDefinition(journal); + assertOperationRecoveryPayloadCoversJournal(journal, payload); + const operationLocks = await acquireOperationEntityLocks( + operationId, + [ + { + store: localStore, + entityLockKeys: locks.local.filter( + (key) => key !== `stage:${stage.stageId}`, + ), + }, + ...(locks.shared.length === 0 + ? [] + : [{ store: coordinationStore, entityLockKeys: locks.shared }]), + ], + { now }, + ); + + try { + const lockedProject = await store.readProjectSnapshot(); + const lockedStage = await readMigrationStage(root, stage.stageId); + if ( + lockedProject.fingerprint !== project.fingerprint || + lockedStage.revision !== stage.revision + ) { + throw new Error('MANCODE_REVISION_CONFLICT'); + } + await writeOperationRecoveryPayload(primaryStore, payload); + await prepareOperationStores({ + primaryStore, + journal, + secondaryStores, + now, + }); + throwIfOperationCrashInjected('v3_activate', 'prepared'); + journal = await completeActivationStep( + primaryStore, + journal, + 'validate-staged-migration', + now, + ); + journal = await completeActivationStep( + primaryStore, + journal, + 'mark-manifest-activating', + now, + ); + await writeTextAtomic( + path.join(root, '.mancode', 'schema.json'), + serialize(manifestActivating), + ); + journal = await completeActivationStep( + primaryStore, + journal, + 'replace-managed-adapters', + now, + ); + for (const action of payload.actions.filter( + (action) => action.kind === 'v3_adapter_file', + )) { + const { applyV3AdapterFilePlan } = await import( + '../installers/v3-adapter.js' + ); + await applyV3AdapterFilePlan(root, action); + } + journal = await completeActivationStep( + primaryStore, + journal, + 'promote-staged-tasks', + now, + ); + await publishActivationTasks( + root, + coordinationStore, + operationId, + entities, + ); + journal = await completeActivationStep( + primaryStore, + journal, + 'record-adapter-inventory-and-baseline', + now, + ); + await writeTextAtomic( + path.join(root, '.mancode', 'shared', 'config.json'), + serialize(activatedConfig), + ); + journal = await completeActivationStep( + primaryStore, + journal, + 'activate-manifest', + now, + ); + await writeTextAtomic( + path.join(root, '.mancode', 'schema.json'), + serialize(manifestActive), + ); + await writeMigrationStage(root, activatedStage); + journal = await completeActivationStep( + primaryStore, + journal, + 'commit', + now, + 'committed', + ); + throwIfOperationCrashInjected('v3_activate', 'commit'); + return { + manifest: manifestActive, + stage: activatedStage, + operation: journal, + }; + } catch (error) { + if (journal.state !== 'committed') { + await updateOperationJournal( + primaryStore, + { + ...journal, + state: 'repair_required', + updatedAt: now.toISOString(), + }, + { canAbort: false }, + ).catch(() => undefined); + } + throw error; + } finally { + await Promise.allSettled(operationLocks.map((lock) => lock.release())); + } + }); +} + +/** + * Reverses an activation only when the exact activation snapshot still proves + * that no later V3 authority write occurred. Any drift is a forward-repair + * boundary, never an excuse to delete user data. + */ +export async function rollbackLegacyMigration( + input: RollbackLegacyMigrationInput, +): Promise { + const root = path.resolve(input.projectRoot); + assertUlid(input.operationId, 'migration rollback operationId'); + assertUlid(input.sessionId, 'migration rollback sessionId'); + if (input.explicitConfirmation !== true) { + throw new Error('MANCODE_EXPLICIT_CONFIRMATION_REQUIRED'); + } + const now = input.now ?? new Date(); + const runtime = await ensureProjectRuntimeContext(root, now); + const localStore = resolveLocalEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const coordinationStore = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const session = await readSession(root, input.sessionId); + if (session === null || session.status !== 'active') { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + const activation = + (await readOperationJournal(localStore, input.operationId)) ?? + (await readOperationJournal(coordinationStore, input.operationId)); + if ( + activation === null || + activation.type !== 'v3_activate' || + activation.state !== 'committed' || + activation.actorId !== session.actorId + ) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + const primaryStore = + activation.primaryStoreId === localStore.storeId + ? localStore + : coordinationStore; + const payload = await readOperationRecoveryPayload( + primaryStore, + input.operationId, + ); + if ( + payload === null || + operationRecoveryPayloadDigest(payload) !== activation.recoveryPayloadDigest + ) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + const parsed = parseOperationRecoveryPayload(payload); + assertOperationRecoveryPayloadCoversJournal(activation, parsed); + await assertActivationRollbackProof( + root, + localStore, + coordinationStore, + activation, + parsed, + ); + const activeManifest = parseSchemaManifest( + JSON.parse( + await readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), + ), + ); + const manifestAction = parsed.actions.find( + (action) => + action.kind === 'project_authority_file' && + action.stepId === 'mark-manifest-activating' && + action.fileName === 'schema.json', + ); + const stageAction = parsed.actions.find( + (action) => + action.kind === 'migration_stage_file' && + action.stepId === 'activate-manifest', + ); + if ( + manifestAction?.kind !== 'project_authority_file' || + manifestAction.beforeContent === null || + stageAction?.kind !== 'migration_stage_file' || + stageAction.beforeContent === null + ) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + const dualReadManifest = parseSchemaManifest( + JSON.parse(manifestAction.beforeContent), + ); + assertActivationRollbackManifestTransition(activeManifest, dualReadManifest); + const staged = parseMigrationStage(JSON.parse(stageAction.beforeContent)); + const rolledBackStage = parseMigrationStage({ + ...staged, + revision: staged.revision + 1, + state: 'rolled_back', + updatedAt: now.toISOString(), + }); + + for (const action of parsed.actions) { + if (action.kind === 'migration_task_directory') { + await rm(taskRootPath(root, action.taskRef), { + recursive: true, + force: false, + }); + } else if (action.kind === 'task_head_fence') { + const target = path.join( + coordinationStore.root, + 'task-heads', + `${action.fence.taskRef.taskId}.json`, + ); + await rm(target, { force: false }); + } else if (action.kind === 'v3_adapter_file') { + const target = v3AdapterTargetPath(root, action.target); + if (action.beforeContent === null) { + await rm(target, { force: false }); + } else { + await applyV3AdapterFilePlan(root, { + target: action.target, + beforeContent: action.targetContent, + targetContent: action.beforeContent, + }); + } + } else if ( + action.kind === 'project_authority_file' && + action.fileName === 'shared/config.json' && + action.beforeContent !== null + ) { + await writeTextAtomic( + path.join(root, '.mancode', 'shared', 'config.json'), + action.beforeContent, + ); + } + } + await writeTextAtomic( + path.join(root, '.mancode', 'schema.json'), + serialize(dualReadManifest), + ); + await writeMigrationStage(root, rolledBackStage); + return { + manifest: dualReadManifest, + stage: rolledBackStage, + operation: activation, + }; +} + +interface ActivationTaskEntity { + taskRef: TaskRef; + files: Array<{ + fileName: + | 'metadata.json' + | 'requirements.json' + | 'review-ledger.json' + | 'verification-ledger.json' + | 'plan.md'; + content: string; + }>; + reports: Array<{ + kind: 'review_report' | 'evidence_summary'; + artifactId: Ulid; + content: string; + }>; + taskHead: TaskHeadFenceV1 | null; +} + +function buildActivationEntities( + rendered: RenderedTask[], + runtime: Awaited>, + operationId: Ulid, + now: Date, + codeHead: string | null, +): ActivationTaskEntity[] { + return rendered.map((item) => { + if (item.candidate === null) { + throw new Error('MANCODE_MIGRATION_ACTIVATION_BLOCKED'); + } + const timestamp = now.toISOString(); + const metadata = parseWorkflowMetadata({ + ...item.candidate.metadata, + lastOperationId: operationId, + updatedAt: timestamp, + }); + const requirements = parseRequirementsLedger({ + ...item.candidate.requirements, + lastOperationId: operationId, + updatedAt: timestamp, + }); + const review = parseReviewLedger({ + ...item.candidate.review, + lastOperationId: operationId, + updatedAt: timestamp, + }); + const verification = parseVerificationLedger({ + ...item.candidate.verification, + lastOperationId: operationId, + updatedAt: timestamp, + }); + const plan = item.sourceFiles.find( + (file) => file.relativePath === 'artifacts/plan.md', + ); + const sourceByPath = new Map( + item.sourceFiles + .filter((file) => file.relativePath.startsWith('artifacts/')) + .map((file) => [ + file.relativePath.slice('artifacts/'.length), + file.content, + ]), + ); + const reports = item.candidate.artifactAliases.map((alias) => { + const content = sourceByPath.get(alias.legacyPath); + if (content === undefined || alias.artifactRef.artifactId === undefined) { + throw new Error('MANCODE_MIGRATION_REFERENCED_ARTIFACT_MISSING'); + } + if ( + alias.artifactRef.kind !== 'review_report' && + alias.artifactRef.kind !== 'evidence_summary' + ) { + throw new Error('MANCODE_MIGRATION_ARTIFACT_PATH_UNSAFE'); + } + return { + kind: alias.artifactRef.kind, + artifactId: alias.artifactRef.artifactId, + content, + }; + }); + const files: ActivationTaskEntity['files'] = [ + { fileName: 'metadata.json', content: serialize(metadata) }, + { fileName: 'requirements.json', content: serialize(requirements) }, + { fileName: 'review-ledger.json', content: serialize(review) }, + { + fileName: 'verification-ledger.json', + content: serialize(verification), + }, + ...(plan === undefined + ? [] + : [{ fileName: 'plan.md' as const, content: plan.content }]), + ]; + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements, + review, + verification, + planDigest: + plan === undefined + ? null + : digestCanonicalJson({ + artifactRef: { taskRef: metadata.taskRef, kind: 'plan' }, + content: plan.content, + }), + latestCheckpoint: null, + }); + const taskHead = + metadata.taskRef.namespace === 'shared' + ? (() => { + if (codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + } + return { + schemaVersion: 1 as const, + workspaceId: runtime.workspaceId, + taskRef: metadata.taskRef, + fenceRevision: 1, + taskRevision: metadata.revision, + aggregateDigest: taskAggregateDigest(aggregate), + ownershipEpoch: metadata.ownershipEpoch, + codeRef: { head: codeHead }, + checkoutId: runtime.checkoutId, + remoteRevision: null, + lastOperationId: operationId, + updatedAt: timestamp, + } satisfies TaskHeadFenceV1; + })() + : null; + return { taskRef: metadata.taskRef, files, reports, taskHead }; + }); +} + +function assertRenderedMatchesStage( + rendered: RenderedTask[], + stage: MigrationStageV1, +): void { + if (rendered.length !== stage.tasks.length) { + throw new Error('MANCODE_MIGRATION_STAGE_CANDIDATE_CHANGED'); + } + const byLegacyId = new Map( + stage.tasks.map((task) => [task.legacyTaskId, task]), + ); + for (const item of rendered) { + const staged = byLegacyId.get(item.stage.legacyTaskId); + if ( + staged === undefined || + staged.state !== item.stage.state || + staged.candidateDigest !== item.stage.candidateDigest || + staged.parityDigest !== item.stage.parityDigest || + staged.privacyStatus !== item.stage.privacyStatus || + staged.quarantineId !== item.stage.quarantineId || + !sameTaskRefValue(staged.taskRef, item.stage.taskRef) || + JSON.stringify(staged.blockers) !== JSON.stringify(item.stage.blockers) + ) { + throw new Error('MANCODE_MIGRATION_STAGE_CANDIDATE_CHANGED'); + } + } +} + +function activationLockKeys( + stageId: Ulid, + entities: ActivationTaskEntity[], + adapters: Awaited>, +): { local: string[]; shared: string[] } { + const local = [ + 'schema:project', + 'config:project', + `stage:${stageId}`, + ...adapters.map((plan) => `adapter:${plan.target}`), + ...entities + .filter((entity) => entity.taskRef.namespace === 'local') + .map((entity) => taskEntityKey(entity.taskRef)), + ]; + const shared = entities + .filter((entity) => entity.taskRef.namespace === 'shared') + .flatMap((entity) => [ + taskEntityKey(entity.taskRef), + taskHeadEntityKey(entity.taskRef), + ]); + return { + local: uniqueLockKeys(local), + shared: uniqueLockKeys(shared), + }; +} + +async function assertActivationTargetsAbsent( + projectRoot: string, + coordinationStore: ReturnType, + entities: ActivationTaskEntity[], +): Promise { + for (const entity of entities) { + try { + await lstat(taskRootPath(projectRoot, entity.taskRef)); + throw new Error('MANCODE_MIGRATION_DESTINATION_EXISTS'); + } catch (error) { + if (!isNotFound(error)) throw error; + } + if ( + entity.taskHead !== null && + (await readTaskHeadFence(coordinationStore, entity.taskRef)) !== null + ) { + throw new Error('MANCODE_MIGRATION_DESTINATION_EXISTS'); + } + } +} + +async function publishActivationTasks( + projectRoot: string, + coordinationStore: ReturnType, + operationId: Ulid, + entities: ActivationTaskEntity[], +): Promise { + for (const entity of entities) { + const target = taskRootPath(projectRoot, entity.taskRef); + const parent = await ensureActivationTaskParent( + projectRoot, + entity.taskRef, + ); + const staging = path.join( + parent, + `.${entity.taskRef.taskId}.${operationId}.staging`, + ); + try { + await mkdir(staging); + for (const file of entity.files) { + await writeFile(path.join(staging, file.fileName), file.content, { + encoding: 'utf8', + flag: 'wx', + }); + } + if (entity.reports.length > 0) { + const reports = path.join(staging, 'reports'); + await mkdir(reports); + for (const report of entity.reports) { + const name = + report.kind === 'review_report' + ? `${report.artifactId}.md` + : `evidence-${report.artifactId}.md`; + await writeFile(path.join(reports, name), report.content, { + encoding: 'utf8', + flag: 'wx', + }); + } + } + await rename(staging, target); + if (entity.taskHead !== null) { + await createTaskHeadFence(coordinationStore, entity.taskHead); + } + } catch (error) { + await rm(staging, { recursive: true, force: true }).catch( + () => undefined, + ); + throw error; + } + } +} + +async function ensureActivationTaskParent( + projectRoot: string, + taskRef: TaskRef, +): Promise { + let current = path.resolve(projectRoot); + const root = await lstat(current); + if (!root.isDirectory() || root.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + for (const segment of ['.mancode', taskRef.namespace, 'workflows']) { + current = path.join(current, segment); + try { + const entry = await lstat(current); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (!isNotFound(error)) throw error; + await mkdir(current); + } + } + return current; +} + +async function completeActivationStep( + primaryStore: ReturnType, + journal: OperationJournalV1, + stepId: string, + now: Date, + state: OperationJournalV1['state'] = 'applying', +): Promise { + throwIfDeferredOperationCrashInjected(journal.type); + const index = journal.steps.findIndex((step) => step.id === stepId); + if ( + index < 0 || + journal.steps.slice(0, index).some((step) => step.state !== 'completed') + ) { + throw new Error('MANCODE_OPERATION_STEP_ORDER_INVALID'); + } + const completed = await updateOperationJournal( + primaryStore, + { + ...journal, + state, + steps: journal.steps.map((step, current) => + current === index ? { ...step, state: 'completed' as const } : step, + ), + updatedAt: now.toISOString(), + }, + { canAbort: false }, + ); + const step = getOperationDefinition(journal.type).steps[index]; + if (step?.visibility === 'business_write') { + armOperationCrashAfterVisibleWrite(journal.type, stepId); + } else if (step?.visibility === 'preparation') { + throwIfOperationCrashInjected(journal.type, stepId); + } + return completed; +} + +function uniqueLockKeys(keys: string[]): string[] { + return [...new Set(keys)].sort((left, right) => + Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')), + ); +} + +async function assertActivationRollbackProof( + projectRoot: string, + localStore: ReturnType, + coordinationStore: ReturnType, + activation: OperationJournalV1, + payload: ReturnType, +): Promise { + for (const store of [localStore, coordinationStore]) { + let entries: string[]; + try { + entries = await readdir(operationDirectory(store)); + } catch (error) { + if (isNotFound(error)) continue; + throw error; + } + if ( + entries.some( + (entry) => + entry.endsWith('.json') && entry !== `${activation.operationId}.json`, + ) + ) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + } + const projectTargets = new Map(); + for (const action of payload.actions) { + if (action.kind === 'project_authority_file') { + projectTargets.set(action.fileName, action.targetContent); + } + } + for (const [fileName, targetContent] of projectTargets) { + const current = await readFile( + path.join(projectRoot, '.mancode', ...fileName.split('/')), + 'utf8', + ); + if (current !== targetContent) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + } + for (const action of payload.actions) { + if (action.kind === 'migration_stage_file') { + const current = await readFile( + migrationStagePath(projectRoot, action.stageId), + 'utf8', + ); + if (current !== action.targetContent) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + } else if (action.kind === 'v3_adapter_file') { + const current = await readTextOrNull( + v3AdapterTargetPath(projectRoot, action.target), + ); + if (current !== action.targetContent) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + } else if (action.kind === 'migration_task_directory') { + await assertMigrationDirectoryMatches(projectRoot, action); + } else if (action.kind === 'task_head_fence') { + const fence = await readTaskHeadFence( + coordinationStore, + action.fence.taskRef, + ); + if (fence === null || serialize(fence) !== serialize(action.fence)) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + } + } +} + +async function assertMigrationDirectoryMatches( + projectRoot: string, + action: Extract< + ReturnType['actions'][number], + { kind: 'migration_task_directory' } + >, +): Promise { + const root = taskRootPath(projectRoot, action.taskRef); + let entry: Awaited>; + try { + entry = await lstat(root); + } catch { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + const expectedRoot = new Set([ + ...action.files.map((file) => file.fileName), + ...(action.reports.length === 0 ? [] : ['reports']), + ]); + const entries = await readdir(root); + if ( + entries.length !== expectedRoot.size || + entries.some((name) => !expectedRoot.has(name)) + ) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + for (const file of action.files) { + if ( + (await readFile(path.join(root, file.fileName), 'utf8')) !== file.content + ) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + } + if (action.reports.length > 0) { + const reportsRoot = path.join(root, 'reports'); + const reportDirectory = await lstat(reportsRoot); + const expectedReports = new Set( + action.reports.map((report) => + report.kind === 'review_report' + ? `${report.artifactId}.md` + : `evidence-${report.artifactId}.md`, + ), + ); + const reports = await readdir(reportsRoot); + if ( + !reportDirectory.isDirectory() || + reportDirectory.isSymbolicLink() || + reports.length !== expectedReports.size || + reports.some((name) => !expectedReports.has(name)) + ) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + for (const report of action.reports) { + const name = + report.kind === 'review_report' + ? `${report.artifactId}.md` + : `evidence-${report.artifactId}.md`; + if ( + (await readFile(path.join(reportsRoot, name), 'utf8')) !== + report.content + ) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + } + } +} + +async function readTextOrNull(target: string): Promise { + try { + const entry = await lstat(target); + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + } + return await readFile(target, 'utf8'); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +export async function readMigrationStage( + projectRoot: string, + stageId: Ulid, +): Promise { + assertUlid(stageId, 'migration stageId'); + const raw = await readFile(migrationStagePath(projectRoot, stageId), 'utf8'); + try { + return parseMigrationStage(JSON.parse(raw)); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('MANCODE_MIGRATION_STAGE_CORRUPT'); + } + throw error; + } +} + +export async function listMigrationStages( + projectRoot: string, +): Promise { + const directory = migrationStagesDirectory(projectRoot); + let entries: string[]; + try { + entries = await readdir(directory); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const stages: MigrationStageV1[] = []; + for (const entry of entries.sort(compareUtf8)) { + if (!entry.endsWith('.json')) continue; + const stageId = entry.slice(0, -'.json'.length); + assertUlid(stageId, 'migration stage filename'); + stages.push(await readMigrationStage(projectRoot, stageId)); + } + return stages; +} + +export function migrationStagesDirectory(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'migration', + 'stages', + ); +} + +export function migrationStagePath(projectRoot: string, stageId: Ulid): string { + assertUlid(stageId, 'migration stageId'); + return path.join(migrationStagesDirectory(projectRoot), `${stageId}.json`); +} + +export function parseMigrationStage(value: unknown): MigrationStageV1 { + assertRecord(value, 'migration stage'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'stageId', + 'revision', + 'state', + 'sourceBaseline', + 'sourceInventoryDigest', + 'aliases', + 'resolutions', + 'tasks', + 'createdAt', + 'updatedAt', + ], + 'migration stage', + ); + if (value.schemaVersion !== 1) { + throw new Error('migration stage schemaVersion must be 1'); + } + assertUlid(value.stageId, 'migration stage stageId'); + if ( + typeof value.revision !== 'number' || + !Number.isSafeInteger(value.revision) || + value.revision < 1 + ) { + throw new Error('migration stage revision must be a positive integer'); + } + if ( + typeof value.state !== 'string' || + !STAGE_STATES.has(value.state as MigrationStageState) + ) { + throw new Error('migration stage state is invalid'); + } + const aliases = parseAliases(value.aliases); + const resolutions = parseResolutions(value.resolutions, aliases); + const tasks = parseStageTasks(value.tasks, aliases); + if (tasks.length !== Object.keys(aliases).length) { + throw new Error('migration stage must have exactly one record per alias'); + } + return { + schemaVersion: 1, + stageId: value.stageId, + revision: value.revision, + state: value.state as MigrationStageState, + sourceBaseline: parseLegacyBaseline(value.sourceBaseline), + sourceInventoryDigest: parseDigest( + value.sourceInventoryDigest, + 'migration stage sourceInventoryDigest', + ), + aliases, + resolutions, + tasks, + createdAt: parseTimestamp(value.createdAt, 'migration stage createdAt'), + updatedAt: parseTimestamp(value.updatedAt, 'migration stage updatedAt'), + }; +} + +async function loadLegacyMigrationSources( + projectRoot: string, +): Promise { + const root = path.resolve(projectRoot); + const scan = await scanLegacyAuthority(root); + if (!scan.authorityPresent || scan.baseline === null) { + throw new Error('MANCODE_MIGRATION_REQUIRED'); + } + if (scan.unsafePaths.length > 0) { + throw new Error('MANCODE_MIGRATION_LEGACY_PATH_UNSAFE'); + } + const state = await loadLegacyState(root); + const tasks = await loadLegacyTasks(root, state); + if (tasks.length === 0) { + throw new Error('MANCODE_MIGRATION_NO_WORKFLOWS'); + } + return { + baseline: scan.baseline, + inventoryDigest: digestCanonicalJson({ + entries: scan.entries.map((entry) => ({ + path: entry.path, + exists: entry.exists, + kind: entry.kind, + digest: entry.digest, + })), + tasks: tasks.map((task) => ({ + legacyTaskId: task.legacyTaskId, + sourceDigest: task.source === null ? null : task.source.sourceDigests, + blockers: task.blockers, + })), + }), + state, + tasks, + }; +} + +async function loadLegacyState( + root: string, +): Promise | null> { + const raw = await readLegacyTextOrNull(root, 'state.json'); + if (raw === null) return null; + try { + const parsed: unknown = JSON.parse(raw); + assertRecord(parsed, 'legacy state'); + return parsed as Partial; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('MANCODE_MIGRATION_LEGACY_STATE_CORRUPT'); + } + throw error; + } +} + +async function loadLegacyTasks( + root: string, + state: Partial | null, +): Promise { + const workflowsRoot = path.join(root, '.mancode', 'workflows'); + const entries = await readSafeDirectoryOrEmpty(workflowsRoot); + const tasks: LoadedLegacyTask[] = []; + for (const taskId of entries.sort(compareUtf8)) { + assertLegacyTaskId(taskId); + const taskRoot = path.join(workflowsRoot, taskId); + const taskStat = await lstat(taskRoot); + if (!taskStat.isDirectory() || taskStat.isSymbolicLink()) { + throw new Error('MANCODE_MIGRATION_LEGACY_PATH_UNSAFE'); + } + const metadataRaw = await readLegacyText( + root, + path.join('workflows', taskId, 'metadata.json'), + ); + const workflow = await readWorkflow(root, taskId); + await assertLegacyTextUnchanged( + root, + path.join('workflows', taskId, 'metadata.json'), + metadataRaw, + ); + if (workflow === null) { + throw new Error(`MANCODE_MIGRATION_LEGACY_WORKFLOW_INVALID:${taskId}`); + } + const [requirementsRaw, reviewRaw, verificationRaw] = await Promise.all([ + readLegacyTextOrNull( + root, + path.join('workflows', taskId, 'requirements.json'), + ), + readLegacyTextOrNull( + root, + path.join('workflows', taskId, 'review-ledger.json'), + ), + readLegacyTextOrNull( + root, + path.join('workflows', taskId, 'verification-ledger.json'), + ), + ]); + const blockers: string[] = []; + const [requirements, review, verification] = await Promise.all([ + requirementsRaw === null + ? Promise.resolve(null) + : readRequirementsLedger(root, taskId), + reviewRaw === null + ? Promise.resolve(null) + : readReviewLedger(root, taskId), + verificationRaw === null + ? Promise.resolve(null) + : readVerificationLedger(root, taskId), + ]); + await Promise.all([ + requirementsRaw === null + ? Promise.resolve() + : assertLegacyTextUnchanged( + root, + path.join('workflows', taskId, 'requirements.json'), + requirementsRaw, + ), + reviewRaw === null + ? Promise.resolve() + : assertLegacyTextUnchanged( + root, + path.join('workflows', taskId, 'review-ledger.json'), + reviewRaw, + ), + verificationRaw === null + ? Promise.resolve() + : assertLegacyTextUnchanged( + root, + path.join('workflows', taskId, 'verification-ledger.json'), + verificationRaw, + ), + ]); + if (requirementsRaw === null || requirements === null) { + blockers.push('MANCODE_MIGRATION_REQUIREMENTS_LEDGER_MISSING_OR_INVALID'); + } + if (reviewRaw === null || review === null) { + blockers.push('MANCODE_MIGRATION_REVIEW_LEDGER_MISSING_OR_INVALID'); + } + if (verificationRaw === null || verification === null) { + blockers.push('MANCODE_MIGRATION_VERIFICATION_LEDGER_MISSING_OR_INVALID'); + } + const sourceFiles: Array<{ relativePath: string; content: string }> = [ + { relativePath: 'source/metadata.json', content: metadataRaw }, + ...(requirementsRaw === null + ? [] + : [ + { + relativePath: 'source/requirements.json', + content: requirementsRaw, + }, + ]), + ...(reviewRaw === null + ? [] + : [{ relativePath: 'source/review-ledger.json', content: reviewRaw }]), + ...(verificationRaw === null + ? [] + : [ + { + relativePath: 'source/verification-ledger.json', + content: verificationRaw, + }, + ]), + ]; + let source: LegacyTaskMigrationSource | null = null; + if (requirements !== null && review !== null && verification !== null) { + source = { + workflow, + requirements, + review, + verification, + state, + sourceDigests: { + metadata: digestText(metadataRaw), + requirements: digestText(requirementsRaw as string), + review: digestText(reviewRaw as string), + verification: digestText(verificationRaw as string), + }, + }; + const artifacts = await loadReferencedLegacyArtifacts( + root, + taskId, + source, + ); + sourceFiles.push(...artifacts.files); + blockers.push(...artifacts.blockers); + } + tasks.push({ + legacyTaskId: taskId, + workflow, + source, + sourceFiles, + blockers: sortUtf8StringSet(blockers), + }); + } + return tasks; +} + +async function loadReferencedLegacyArtifacts( + root: string, + taskId: string, + source: LegacyTaskMigrationSource, +): Promise<{ + files: Array<{ relativePath: string; content: string }>; + blockers: string[]; +}> { + const sourcePaths = new Set(); + sourcePaths.add('plan.md'); + for (const report of Object.values(source.review.reports)) { + sourcePaths.add(report); + } + for (const check of source.verification.checks) { + if (check.automated?.evidenceFile) + sourcePaths.add(check.automated.evidenceFile); + if (check.manual?.evidenceFile) sourcePaths.add(check.manual.evidenceFile); + } + const files: Array<{ relativePath: string; content: string }> = []; + const blockers: string[] = []; + for (const sourcePath of [...sourcePaths].sort(compareUtf8)) { + try { + const safe = assertSafeSharedRelativePath(sourcePath); + const content = await readLegacyTextOrNull( + root, + path.join('workflows', taskId, ...safe.split('/')), + ); + if (content === null) { + // plan.md is optional in old, pre-plan workflows. References from a + // review or evidence record are not optional and must stay explicit. + if (safe !== 'plan.md') { + blockers.push('MANCODE_MIGRATION_REFERENCED_ARTIFACT_MISSING'); + } + continue; + } + files.push({ relativePath: `artifacts/${safe}`, content }); + } catch (error) { + blockers.push( + error instanceof Error && error.message.startsWith('MANCODE_') + ? error.message + : 'MANCODE_MIGRATION_ARTIFACT_PATH_UNSAFE', + ); + } + } + return { files, blockers: sortUtf8StringSet(blockers) }; +} + +function createAliases( + tasks: LoadedLegacyTask[], + allocator: ReturnType, +): LegacyTaskAliasMap { + const byId = new Map(tasks.map((task) => [task.legacyTaskId, task])); + const namespaceByTask = new Map(); + const visiting = new Set(); + const resolveNamespace = (taskId: string): TaskRef['namespace'] => { + const known = namespaceByTask.get(taskId); + if (known !== undefined) return known; + if (visiting.has(taskId)) { + throw new Error('MANCODE_MIGRATION_PARENT_CYCLE'); + } + const task = byId.get(taskId); + if (task === undefined) { + throw new Error('MANCODE_MIGRATION_PARENT_ALIAS_MISSING'); + } + visiting.add(taskId); + const mode = normalizeLegacyWorkflowMode(task.workflow.mode); + if (mode === null) { + throw new Error('MANCODE_MIGRATION_WORKFLOW_MODE_INVALID'); + } + let namespace: TaskRef['namespace']; + if (mode === 'manteam') { + namespace = 'shared'; + } else if (mode === 'man') { + namespace = 'local'; + } else if (task.workflow.parentTaskId === undefined) { + namespace = 'local'; + } else { + namespace = resolveNamespace(task.workflow.parentTaskId); + } + visiting.delete(taskId); + namespaceByTask.set(taskId, namespace); + return namespace; + }; + const byNamespace: Record = { + local: [], + shared: [], + }; + for (const task of tasks) { + byNamespace[resolveNamespace(task.legacyTaskId)].push(task.legacyTaskId); + } + return { + ...createLegacyTaskAliasMap(byNamespace.local, 'local', allocator), + ...createLegacyTaskAliasMap(byNamespace.shared, 'shared', allocator), + }; +} + +function renderTasks( + sourceSet: LegacyMigrationSourceSet, + aliases: LegacyTaskAliasMap, + resolutions: Record, + allocator: ReturnType, + now: Date, +): RenderedTask[] { + try { + assertLegacyStatePointers(sourceSet.state, aliases); + } catch (error) { + const code = errorCode(error, 'MANCODE_MIGRATION_STATE_POINTER_INVALID'); + return sourceSet.tasks + .slice() + .sort((left, right) => compareUtf8(left.legacyTaskId, right.legacyTaskId)) + .map((task) => renderBlockedTask(task, aliases, allocator, now, [code])); + } + const sources = new Map( + sourceSet.tasks.map((task) => [task.legacyTaskId, task]), + ); + const rendered = new Map(); + const renderOne = (legacyTaskId: string): RenderedTask => { + const existing = rendered.get(legacyTaskId); + if (existing !== undefined) return existing; + const loaded = sources.get(legacyTaskId); + if (loaded === undefined) { + throw new Error('MANCODE_MIGRATION_PARENT_ALIAS_MISSING'); + } + const dependencyBlockers = [...loaded.blockers]; + let parent: { legacyTaskId: string; metadata: WorkflowMetadataV3 } | null = + null; + const parentId = loaded.workflow.parentTaskId; + if (parentId !== undefined) { + try { + const parentRendered = renderOne(parentId); + if (parentRendered.candidate === null) { + dependencyBlockers.push( + 'MANCODE_MIGRATION_PARENT_CANDIDATE_UNAVAILABLE', + ); + } else { + parent = { + legacyTaskId: parentId, + metadata: parentRendered.candidate.metadata, + }; + } + } catch (error) { + dependencyBlockers.push( + errorCode(error, 'MANCODE_MIGRATION_PARENT_ALIAS_MISSING'), + ); + } + } + if (loaded.source === null || dependencyBlockers.length > 0) { + const result = renderBlockedTask( + loaded, + aliases, + allocator, + now, + dependencyBlockers, + ); + rendered.set(legacyTaskId, result); + return result; + } + const resolution = resolutions[legacyTaskId] ?? { + ownerActorId: null, + implementationScope: null, + }; + try { + const owner: LegacyMigrationOwner | null = + resolution.ownerActorId === null + ? null + : { actorId: resolution.ownerActorId }; + let candidate = migrateLegacyTaskToV3({ + ...loaded.source, + aliases, + idAllocator: allocator, + owner, + parent, + }); + if (resolution.implementationScope !== null) { + candidate = { + ...candidate, + metadata: withResolvedImplementationScope( + candidate.metadata, + resolution.implementationScope, + ), + }; + } + const report = createMigrationParityReport( + loaded.source, + candidate, + aliases, + ); + const quarantine = buildQuarantineCandidate( + loaded, + candidate, + allocator.allocate(`quarantine:${legacyTaskId}`), + now, + ); + const blockers = sortUtf8StringSet([ + ...report.activationBlockers, + ...(quarantine.privacy.status === 'blocked' + ? ['MANCODE_PRIVACY_BLOCKED'] + : []), + ]); + const result: RenderedTask = { + stage: { + legacyTaskId, + taskRef: candidate.metadata.taskRef, + quarantineId: quarantine.quarantineId, + state: blockers.length === 0 ? 'ready' : 'blocked', + blockers, + candidateDigest: candidateDigest(candidate), + parityDigest: digestCanonicalJson(report), + privacyStatus: quarantine.privacy.status, + }, + candidate, + report, + quarantine, + sourceFiles: loaded.sourceFiles, + }; + rendered.set(legacyTaskId, result); + return result; + } catch (error) { + const result = renderBlockedTask(loaded, aliases, allocator, now, [ + errorCode(error, 'MANCODE_MIGRATION_CANDIDATE_INVALID'), + ]); + rendered.set(legacyTaskId, result); + return result; + } + }; + for (const task of sourceSet.tasks + .slice() + .sort((left, right) => + compareUtf8(left.legacyTaskId, right.legacyTaskId), + )) { + renderOne(task.legacyTaskId); + } + return [...rendered.values()].sort((left, right) => + compareUtf8(left.stage.legacyTaskId, right.stage.legacyTaskId), + ); +} + +function renderBlockedTask( + loaded: LoadedLegacyTask, + aliases: LegacyTaskAliasMap, + allocator: ReturnType, + now: Date, + blockers: string[], +): RenderedTask { + const taskRef = aliases[loaded.legacyTaskId]; + if (taskRef === undefined) { + throw new Error('MANCODE_MIGRATION_TASK_ALIAS_MISSING'); + } + const quarantine = buildSourceOnlyQuarantine( + loaded, + allocator.allocate(`quarantine:${loaded.legacyTaskId}`), + now, + ); + const allBlockers = sortUtf8StringSet([ + ...blockers, + ...(quarantine.privacy.status === 'blocked' + ? ['MANCODE_PRIVACY_BLOCKED'] + : []), + ]); + return { + stage: { + legacyTaskId: loaded.legacyTaskId, + taskRef, + quarantineId: quarantine.quarantineId, + state: 'blocked', + blockers: allBlockers, + candidateDigest: null, + parityDigest: null, + privacyStatus: quarantine.privacy.status, + }, + candidate: null, + report: null, + quarantine, + sourceFiles: loaded.sourceFiles, + }; +} + +function buildQuarantineCandidate( + loaded: LoadedLegacyTask, + candidate: MigratedLegacyTaskCandidate, + quarantineId: Ulid, + now: Date, +): QuarantineCandidateV1 { + const entityFiles = candidateEntityFiles(candidate); + const artifacts: QuarantineArtifact[] = [ + ...entityFiles.map(({ relativePath, content }) => ({ + relativePath, + classification: 'authority' as const, + includeInPromotion: true, + contentDigest: digestText(content), + })), + ...loaded.sourceFiles + .filter((file) => file.relativePath.startsWith('artifacts/')) + .map(({ relativePath, content }) => ({ + relativePath, + classification: 'human_view' as const, + includeInPromotion: true, + contentDigest: digestText(content), + })), + ]; + let quarantine = createQuarantineCandidate({ + quarantineId, + purpose: 'legacy_migration', + sourceTaskRef: null, + candidateTaskRef: { namespace: 'local', taskId: quarantineId }, + artifacts, + now, + }); + quarantine = validateQuarantinePaths(quarantine, now); + quarantine = scanQuarantineCandidate( + quarantine, + [...entityFiles, ...loaded.sourceFiles].map((file) => file.content), + now, + ); + return quarantine.privacy.status === 'passed' + ? previewQuarantineCandidate(quarantine, now) + : quarantine; +} + +function buildSourceOnlyQuarantine( + loaded: LoadedLegacyTask, + quarantineId: Ulid, + now: Date, +): QuarantineCandidateV1 { + let quarantine = createQuarantineCandidate({ + quarantineId, + purpose: 'legacy_migration', + sourceTaskRef: null, + candidateTaskRef: { namespace: 'local', taskId: quarantineId }, + artifacts: loaded.sourceFiles.map(({ relativePath, content }) => ({ + relativePath, + classification: 'authority' as const, + includeInPromotion: false, + contentDigest: digestText(content), + })), + now, + }); + quarantine = validateQuarantinePaths(quarantine, now); + quarantine = scanQuarantineCandidate( + quarantine, + loaded.sourceFiles.map((file) => file.content), + now, + ); + return quarantine.privacy.status === 'passed' + ? previewQuarantineCandidate(quarantine, now) + : quarantine; +} + +function withResolvedImplementationScope( + metadata: WorkflowMetadataV3, + scope: MigrationScopeResolutionV1, +): WorkflowMetadataV3 { + const normalized = parseMigrationScopeResolution(scope); + const raw = { + ...metadata, + implementationScope: { + source: 'explicit' as const, + include: normalized.include, + exclude: normalized.exclude, + modules: normalized.modules, + digest: digestCanonicalJson({ + source: 'explicit', + include: normalized.include, + exclude: normalized.exclude, + modules: normalized.modules, + }), + }, + }; + return parseWorkflowMetadata(raw); +} + +async function writeRenderedTasks( + projectRoot: string, + rendered: RenderedTask[], +): Promise { + for (const item of rendered) { + const root = quarantineDirectory(projectRoot, item.quarantine.quarantineId); + await mkdir(root, { recursive: true }); + await writeJsonAtomic(path.join(root, 'candidate.json'), item.quarantine); + for (const file of item.sourceFiles) { + await writeTextAtomic( + path.join(root, ...file.relativePath.split('/')), + file.content, + ); + } + if (item.candidate !== null) { + for (const file of candidateEntityFiles(item.candidate)) { + await writeTextAtomic( + path.join(root, ...file.relativePath.split('/')), + file.content, + ); + } + } + if (item.report !== null) { + await writeJsonAtomic( + path.join(root, 'migration-parity.json'), + item.report, + ); + } + } +} + +function candidateEntityFiles( + candidate: MigratedLegacyTaskCandidate, +): Array<{ relativePath: string; content: string }> { + return [ + { + relativePath: 'entities/metadata.json', + content: serialize(candidate.metadata), + }, + { + relativePath: 'entities/requirements.json', + content: serialize(candidate.requirements), + }, + { + relativePath: 'entities/review-ledger.json', + content: serialize(candidate.review), + }, + { + relativePath: 'entities/verification-ledger.json', + content: serialize(candidate.verification), + }, + { + relativePath: 'entities/auxiliary.json', + content: serialize(candidate.auxiliary), + }, + ]; +} + +async function ensureDualReadShell(input: { + projectRoot: string; + sourceSet: LegacyMigrationSourceSet; + allocator: ReturnType; + now: string; + minReaderVersion: string; + minWriterVersion: string; + managedAdapters: Record; +}): Promise { + const root = path.resolve(input.projectRoot); + const mancodeRoot = path.join(root, '.mancode'); + const schemaPath = path.join(mancodeRoot, 'schema.json'); + const configPath = path.join(mancodeRoot, 'shared', 'config.json'); + const policyPath = path.join(mancodeRoot, 'shared', 'team', 'policy.json'); + const existingSchema = await readJsonOrNull(schemaPath); + if (existingSchema !== null) { + const manifest = parseSchemaManifest(existingSchema); + if (manifest.activationState !== 'dual_read') { + throw new Error('MANCODE_MIGRATION_MANIFEST_STATE_INVALID'); + } + if ( + !sameLegacyBaseline(manifest.legacyBaseline, input.sourceSet.baseline) + ) { + throw new Error('MANCODE_LEGACY_BASELINE_CHANGED'); + } + await validateExistingDualReadShell(configPath, policyPath); + return; + } + const lockPath = path.join( + mancodeRoot, + 'local', + 'migration', + '.bootstrap.lock', + ); + await mkdir(path.dirname(lockPath), { recursive: true }); + try { + await mkdir(lockPath); + } catch (error) { + if (isAlreadyExists(error)) + throw new Error('MANCODE_MIGRATION_BOOTSTRAP_LOCK_HELD'); + throw error; + } + try { + const racedSchema = await readJsonOrNull(schemaPath); + if (racedSchema !== null) { + const manifest = parseSchemaManifest(racedSchema); + if ( + manifest.activationState !== 'dual_read' || + !sameLegacyBaseline(manifest.legacyBaseline, input.sourceSet.baseline) + ) { + throw new Error('MANCODE_MIGRATION_MANIFEST_STATE_INVALID'); + } + await validateExistingDualReadShell(configPath, policyPath); + return; + } + const workspaceId = input.allocator.allocate('workspace'); + const config: ProjectConfigV1 = parseProjectConfig({ + schemaVersion: 1, + revision: 1, + workspaceId, + transport: { mode: 'local', remote: null }, + lastOperationId: null, + updatedAt: input.now, + }); + const policy: TeamPolicyV1 = parseTeamPolicy({ + schemaVersion: 1, + revision: 1, + workspaceId, + policy: 'auto', + recentDays: 30, + defaultVisibility: 'local', + shareConfirmedDecisions: false, + retention: { + localRawArtifactDays: 7, + localCacheDays: 7, + completedSessionDays: 30, + }, + lastOperationId: null, + updatedAt: input.now, + }); + const manifest = parseSchemaManifest({ + manifestVersion: 1, + layoutVersion: 3, + epoch: input.allocator.allocate('schema-epoch'), + activationState: 'dual_read', + minReaderVersion: input.minReaderVersion, + minWriterVersion: input.minWriterVersion, + activatedAt: null, + legacyBaseline: input.sourceSet.baseline, + managedAdapters: input.managedAdapters, + lastOperationId: null, + }); + await writeJsonExclusive(schemaPath, manifest); + await writeJsonExclusive(configPath, config); + await writeJsonExclusive(policyPath, policy); + } finally { + await rmdir(lockPath).catch(() => undefined); + } +} + +async function validateExistingDualReadShell( + configPath: string, + policyPath: string, +): Promise { + const [config, policy] = await Promise.all([ + readJsonOrNull(configPath), + readJsonOrNull(policyPath), + ]); + if (config === null || policy === null) { + throw new Error('MANCODE_MIGRATION_DUAL_READ_SHELL_INCOMPLETE'); + } + assertConfigPolicyConsistency( + parseProjectConfig(config), + parseTeamPolicy(policy), + ); +} + +async function readMigrationStageOrNull( + root: string, + stageId: Ulid, +): Promise { + try { + return await readMigrationStage(root, stageId); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +async function writeMigrationStage( + projectRoot: string, + stage: MigrationStageV1, +): Promise { + await writeJsonAtomic(migrationStagePath(projectRoot, stage.stageId), stage); +} + +async function withMigrationStageLock( + projectRoot: string, + stageId: Ulid, + action: () => Promise, +): Promise { + const runtime = await ensureProjectRuntimeContext(projectRoot); + const locks = await acquireOperationEntityLocks(createUlid(), [ + { + store: resolveLocalEntityHomeStore(runtime.entityHomeStoreContext), + entityLockKeys: [`stage:${stageId}`], + }, + ]); + try { + return await action(); + } finally { + await Promise.allSettled(locks.map((lock) => lock.release())); + } +} + +function parseAliases(value: unknown): LegacyTaskAliasMap { + assertRecord(value, 'migration stage aliases'); + const aliases: LegacyTaskAliasMap = {}; + for (const [legacyTaskId, taskRef] of Object.entries(value)) { + assertLegacyTaskId(legacyTaskId); + aliases[legacyTaskId] = parseTaskRefValue(taskRef); + } + return aliases; +} + +function parseResolutions( + value: unknown, + aliases: LegacyTaskAliasMap, +): Record { + assertRecord(value, 'migration stage resolutions'); + const resolutions: Record = {}; + for (const [legacyTaskId, resolution] of Object.entries(value)) { + if (aliases[legacyTaskId] === undefined) { + throw new Error('migration stage resolution has no matching alias'); + } + resolutions[legacyTaskId] = parseMigrationTaskResolution(resolution); + } + return resolutions; +} + +function parseMigrationTaskResolution( + value: unknown, +): MigrationTaskResolutionV1 { + assertRecord(value, 'migration task resolution'); + assertKnownKeys( + value, + ['ownerActorId', 'implementationScope'], + 'migration task resolution', + ); + const ownerActorId = + value.ownerActorId === null + ? null + : (() => { + assertUlid( + value.ownerActorId, + 'migration task resolution ownerActorId', + ); + return value.ownerActorId; + })(); + return { + ownerActorId, + implementationScope: + value.implementationScope === null + ? null + : parseMigrationScopeResolution(value.implementationScope), + }; +} + +function parseMigrationScopeResolution( + value: unknown, +): MigrationScopeResolutionV1 { + assertRecord(value, 'migration implementation scope resolution'); + assertKnownKeys( + value, + ['include', 'exclude', 'modules'], + 'migration implementation scope resolution', + ); + const scope = { + include: parseScopeStrings(value.include, 'migration scope include'), + exclude: parseScopeStrings(value.exclude, 'migration scope exclude'), + modules: parseScopeStrings(value.modules, 'migration scope modules'), + }; + if ( + scope.include.length === 0 && + scope.exclude.length === 0 && + scope.modules.length === 0 + ) { + throw new Error( + 'migration implementation scope resolution must not be empty', + ); + } + return scope; +} + +function parseScopeStrings(value: unknown, label: string): string[] { + if ( + !Array.isArray(value) || + value.some( + (item) => typeof item !== 'string' || !item.trim() || item.includes('\0'), + ) + ) { + throw new Error(`${label} must contain non-empty strings`); + } + const normalized = sortUtf8StringSet(value.map((item) => item.trim())); + if (normalized.length !== value.length) { + throw new Error(`${label} must not contain duplicates`); + } + for (const item of normalized) { + if ( + item.startsWith('/') || + item.startsWith('~') || + /^[A-Za-z]:/.test(item) || + item.includes('\\') || + item + .split('/') + .some( + (segment) => segment === '' || segment === '.' || segment === '..', + ) + ) { + throw new Error('MANCODE_MIGRATION_SCOPE_PATH_UNSAFE'); + } + } + return normalized; +} + +function parseStageTasks( + value: unknown, + aliases: LegacyTaskAliasMap, +): MigrationTaskStageV1[] { + if (!Array.isArray(value)) + throw new Error('migration stage tasks must be an array'); + const seen = new Set(); + const tasks = value.map((raw) => { + assertRecord(raw, 'migration stage task'); + assertKnownKeys( + raw, + [ + 'legacyTaskId', + 'taskRef', + 'quarantineId', + 'state', + 'blockers', + 'candidateDigest', + 'parityDigest', + 'privacyStatus', + ], + 'migration stage task', + ); + if (typeof raw.legacyTaskId !== 'string') { + throw new Error('migration stage task legacyTaskId is invalid'); + } + assertLegacyTaskId(raw.legacyTaskId); + if (seen.has(raw.legacyTaskId) || aliases[raw.legacyTaskId] === undefined) { + throw new Error('migration stage task aliases are invalid'); + } + seen.add(raw.legacyTaskId); + const taskRef = parseTaskRefValue(raw.taskRef); + if (!sameTaskRefValue(taskRef, aliases[raw.legacyTaskId] as TaskRef)) { + throw new Error('migration stage task TaskRef does not match alias'); + } + assertUlid(raw.quarantineId, 'migration stage task quarantineId'); + if (raw.state !== 'ready' && raw.state !== 'blocked') { + throw new Error('migration stage task state is invalid'); + } + const blockers = parseStringSet( + raw.blockers, + 'migration stage task blockers', + ); + const candidateDigest = parseDigestOrNull( + raw.candidateDigest, + 'migration stage task candidateDigest', + ); + const parityDigest = parseDigestOrNull( + raw.parityDigest, + 'migration stage task parityDigest', + ); + if ((candidateDigest === null) !== (parityDigest === null)) { + throw new Error( + 'migration stage candidate and parity digest must be paired', + ); + } + if ( + raw.state === 'ready' && + (blockers.length > 0 || candidateDigest === null) + ) { + throw new Error('ready migration task must have a clean candidate'); + } + if (raw.state === 'blocked' && blockers.length === 0) { + throw new Error('blocked migration task requires blocker codes'); + } + if ( + raw.privacyStatus !== 'pending' && + raw.privacyStatus !== 'passed' && + raw.privacyStatus !== 'blocked' + ) { + throw new Error('migration stage task privacyStatus is invalid'); + } + return { + legacyTaskId: raw.legacyTaskId, + taskRef, + quarantineId: raw.quarantineId, + state: raw.state as MigrationTaskStageState, + blockers, + candidateDigest, + parityDigest, + privacyStatus: raw.privacyStatus as MigrationTaskStageV1['privacyStatus'], + }; + }); + return tasks.sort((left, right) => + compareUtf8(left.legacyTaskId, right.legacyTaskId), + ); +} + +function parseLegacyBaseline(value: unknown): LegacyBaseline { + assertRecord(value, 'migration stage sourceBaseline'); + assertKnownKeys( + value, + ['stateDigest', 'workflowIndexDigest'], + 'migration stage sourceBaseline', + ); + return { + stateDigest: parseDigest( + value.stateDigest, + 'migration stage baseline stateDigest', + ), + workflowIndexDigest: parseDigest( + value.workflowIndexDigest, + 'migration stage baseline workflowIndexDigest', + ), + }; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseDigestOrNull(value: unknown, label: string): string | null { + return value === null ? null : parseDigest(value, label); +} + +function parseStringSet(value: unknown, label: string): string[] { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== 'string' || !item.trim()) + ) { + throw new Error(`${label} must be an array of non-empty strings`); + } + const normalized = sortUtf8StringSet(value); + if (normalized.length !== value.length) { + throw new Error(`${label} must not contain duplicates`); + } + return normalized; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function migrationSeed(sourceSet: LegacyMigrationSourceSet): string { + return digestCanonicalJson({ + format: 'mancode-migration-stage-v1', + baseline: sourceSet.baseline, + inventoryDigest: sourceSet.inventoryDigest, + }); +} + +function candidateDigest(candidate: MigratedLegacyTaskCandidate): string { + return digestCanonicalJson({ + metadata: workflowMetadataDigest(candidate.metadata), + requirements: candidate.requirements.contentDigest, + review: candidate.review.contentDigest, + verification: candidate.verification.contentDigest, + auxiliary: candidate.auxiliary, + }); +} + +function defaultManagedAdapters(): Record { + return Object.fromEntries( + MANAGED_ADAPTERS.map((adapter) => [adapter, 'legacy-unmanaged']), + ) as Record; +} + +function sameAliases( + left: LegacyTaskAliasMap, + right: LegacyTaskAliasMap, +): boolean { + const leftKeys = Object.keys(left).sort(compareUtf8); + const rightKeys = Object.keys(right).sort(compareUtf8); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => + key === rightKeys[index] && + sameTaskRefValue(left[key] as TaskRef, right[key] as TaskRef), + ) + ); +} + +function sameTaskRefValue(left: TaskRef, right: TaskRef): boolean { + return left.namespace === right.namespace && left.taskId === right.taskId; +} + +function assertLegacyTaskId(value: string): void { + if (!isValidWorkflowTaskId(value)) { + throw new Error('MANCODE_MIGRATION_LEGACY_TASK_ID_UNSAFE'); + } +} + +async function readLegacyText( + projectRoot: string, + relativePath: string, +): Promise { + const value = await readLegacyTextOrNull(projectRoot, relativePath); + if (value === null) { + throw new Error('MANCODE_MIGRATION_LEGACY_ENTITY_MISSING'); + } + return value; +} + +async function readLegacyTextOrNull( + projectRoot: string, + relativePath: string, +): Promise { + const root = path.join(path.resolve(projectRoot), '.mancode'); + const segments = safeSegments(relativePath); + await assertSafeDirectory(root); + let directory = root; + for (const segment of segments.slice(0, -1)) { + directory = path.join(directory, segment); + await assertSafeDirectory(directory); + } + const target = path.join(root, ...segments); + let before: Awaited>; + try { + before = await lstat(target); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_MIGRATION_LEGACY_PATH_UNSAFE'); + } + const content = await readFile(target, 'utf8'); + await assertSafeDirectory(directory); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_MIGRATION_LEGACY_PATH_UNSAFE'); + } + return content; +} + +async function assertLegacyTextUnchanged( + projectRoot: string, + relativePath: string, + expected: string, +): Promise { + const actual = await readLegacyText(projectRoot, relativePath); + if (actual !== expected) { + throw new Error('MANCODE_LEGACY_TREE_CHANGED_DURING_SCAN'); + } +} + +async function readSafeDirectoryOrEmpty(target: string): Promise { + try { + await assertSafeDirectory(target); + return await readdir(target); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } +} + +async function assertSafeDirectory(target: string): Promise { + const stat = await lstat(target); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('MANCODE_MIGRATION_LEGACY_PATH_UNSAFE'); + } +} + +function safeSegments(relativePath: string): string[] { + if ( + !relativePath || + path.isAbsolute(relativePath) || + relativePath.includes('\0') + ) { + throw new Error('MANCODE_MIGRATION_LEGACY_PATH_UNSAFE'); + } + const segments = relativePath.split(path.sep); + if ( + segments.some((segment) => !segment || segment === '.' || segment === '..') + ) { + throw new Error('MANCODE_MIGRATION_LEGACY_PATH_UNSAFE'); + } + return segments; +} + +async function writeJsonAtomic(target: string, value: unknown): Promise { + await writeTextAtomic(target, serialize(value)); +} + +async function writeTextAtomic(target: string, content: string): Promise { + await mkdir(path.dirname(target), { recursive: true }); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${createUlid()}.tmp`, + ); + await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' }); + await rename(temporary, target); +} + +async function writeJsonExclusive( + target: string, + value: unknown, +): Promise { + await mkdir(path.dirname(target), { recursive: true }); + try { + await writeFile(target, serialize(value), { encoding: 'utf8', flag: 'wx' }); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readJsonOrNull(target); + if ( + existing === null || + digestCanonicalJson(existing) !== digestCanonicalJson(value) + ) { + throw new Error('MANCODE_MIGRATION_DUAL_READ_SHELL_CONFLICT'); + } + } +} + +async function readJsonOrNull(target: string): Promise { + try { + return JSON.parse(await readFile(target, 'utf8')); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) + throw new Error('MANCODE_MIGRATION_SHELL_CORRUPT'); + throw error; + } +} + +function serialize(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function digestText(value: string): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function errorCode(error: unknown, fallback: string): string { + if (error instanceof Error && /^MANCODE_[A-Z0-9_:.-]+$/.test(error.message)) { + return error.message; + } + return fallback; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} diff --git a/src/context/migration-parity.ts b/src/context/migration-parity.ts new file mode 100644 index 0000000..d044454 --- /dev/null +++ b/src/context/migration-parity.ts @@ -0,0 +1,2321 @@ +import { createHash } from 'node:crypto'; +import type { MancodeState } from '../commands/init.js'; +import { + type RequirementsLedger as LegacyRequirementsLedger, + requirementsAreReady as legacyRequirementsAreReady, +} from '../system/requirements-ledger.js'; +import type { ReviewLedger as LegacyReviewLedger } from '../system/review-ledger.js'; +import type { + VerificationEvidence as LegacyVerificationEvidence, + VerificationLedger as LegacyVerificationLedger, +} from '../system/verification-ledger.js'; +import type { + WorkflowMeta as LegacyWorkflowMeta, + WorkflowMode as LegacyWorkflowMode, +} from '../system/workflow.js'; +import { type ArtifactRef, parseArtifactRef } from './artifact-ref.js'; +import { digestCanonicalJson, sortUtf8StringSet } from './canonical.js'; +import { + type ContextPackSectionInput, + type ContextPackSectionPointer, + type ContextPurpose, + buildContextPack, +} from './context-pack.js'; +import { type Ulid, createUlid } from './ids.js'; +import { + type RequirementsLedgerV1, + parseRequirementsLedger, + requirementsLedgerDigest, + requirementsAreReady as v3RequirementsAreReady, +} from './requirements-ledger.js'; +import { + type ReviewLedgerV1, + deriveReviewLedgerStatus, + parseReviewLedger, + reviewLedgerDigest, +} from './review-ledger.js'; +import { normalizeLegacyWorkflowMode } from './schema.js'; +import { type TaskNamespace, type TaskRef, formatTaskRef } from './task-ref.js'; +import { + type VerificationLedgerStatus, + type VerificationLedgerV1, + deriveVerificationLedgerStatus, + parseVerificationLedger, + verificationLedgerDigest, +} from './verification-ledger.js'; +import { + type RequirementsStatus, + type ReviewStatus, + type VerificationStatus, + type WorkflowMetadataV3, + parseWorkflowMetadata, + workflowMetadataDigest, +} from './workflow-metadata.js'; + +/** Every legacy-to-V3 mapping uses this version, including report readers. */ +export const LEGACY_V3_FIELD_MAP_VERSION = 1; + +export interface LegacyTaskAliasMap { + [legacyTaskId: string]: TaskRef; +} + +export interface LegacySourceDigests { + metadata: string; + requirements: string; + review: string; + verification: string; +} + +/** + * The legacy parser produces normalized objects. Migration deliberately takes + * those objects rather than raw JSON, while raw file digests stay in audit + * metadata through `sourceDigests`. + */ +export interface LegacyTaskMigrationSource { + workflow: LegacyWorkflowMeta; + requirements: LegacyRequirementsLedger; + review: LegacyReviewLedger; + verification: LegacyVerificationLedger; + state: Partial | null; + sourceDigests: LegacySourceDigests; +} + +export interface LegacyMigrationOwner { + actorId: Ulid; + participants?: Ulid[]; +} + +export interface LegacyMigrationIdAllocator { + allocate(scope: string): Ulid; +} + +export interface MigratedParentContext { + legacyTaskId: string; + metadata: WorkflowMetadataV3; +} + +export interface LegacyArtifactAlias { + legacyPath: string; + artifactRef: ArtifactRef; +} + +export interface MigrationResumeHintV1 { + schemaVersion: 1; + source: 'legacy_state'; + taskRef: TaskRef; + currentMode: string; + currentWorkflowMode: 'man' | 'manba' | 'manteam' | null; + lastMode: string | null; +} + +export interface LegacyProjectFactsV1 { + source: 'legacy_state'; + platform: string | null; + techStack: string | null; + uiLibrary: string | null; + projectMode: string | null; +} + +export interface LegacyTeamAssessmentV1 { + source: 'legacy_state'; + teamModeAutoDetected: boolean | null; + contributors: number | null; +} + +export interface LegacyMigrationAuditV1 { + source: 'legacy_state'; + version: string | null; + initializedAt: string | null; +} + +export interface MigratedLegacyAuxiliaryData { + resumeHint: MigrationResumeHintV1 | null; + projectFacts: LegacyProjectFactsV1 | null; + teamAssessment: LegacyTeamAssessmentV1 | null; + migrationAudit: LegacyMigrationAuditV1 | null; +} + +export interface MigratedLegacyTaskCandidate { + metadata: WorkflowMetadataV3; + requirements: RequirementsLedgerV1; + review: ReviewLedgerV1; + verification: VerificationLedgerV1; + artifactAliases: LegacyArtifactAlias[]; + auxiliary: MigratedLegacyAuxiliaryData; +} + +export interface LegacyTaskMigrationInput extends LegacyTaskMigrationSource { + aliases: LegacyTaskAliasMap; + idAllocator: LegacyMigrationIdAllocator; + owner: LegacyMigrationOwner | null; + parent: MigratedParentContext | null; +} + +export type CompletionGateTuple = { + allowed: boolean; + requirementsStatus: RequirementsStatus; + reviewStatus: ReviewStatus; + verificationStatus: VerificationStatus; + blockerIds: string[]; + requiredEvidence: string[]; +}; + +export interface MigrationFieldMapping { + source: string; + sourceFields: string[]; + target: string; + matched: boolean; +} + +export interface ContextPackShadowComparisonV1 { + purpose: ContextPurpose; + legacyPackDigest: string; + v3PackDigest: string; + matched: boolean; + differences: string[]; +} + +export interface MigrationContextPackShadowV1 { + schemaVersion: 1; + comparisons: ContextPackShadowComparisonV1[]; +} + +export interface MigrationParityReportV1 { + schemaVersion: 1; + fieldMapVersion: 1; + legacyTaskId: string; + taskRef: TaskRef; + sourceDigests: LegacySourceDigests; + v3Digests: { + metadata: string; + requirements: string; + review: string; + verification: string; + }; + sourceFieldInventory: string[]; + fieldMappings: MigrationFieldMapping[]; + unmappedFields: string[]; + legacyGate: CompletionGateTuple; + v3Gate: CompletionGateTuple; + contextPackShadow: MigrationContextPackShadowV1; + aliasResolution: { + taskRefs: string[]; + displayIds: string[]; + artifactPaths: string[]; + }; + activationBlockers: string[]; +} + +/** + * Stable IDs are derived from a caller-owned migration seed and a semantic + * scope. The allocator rejects reuse so a mapping cannot silently collapse two + * legacy entities into one V3 identity. + */ +export function createDeterministicMigrationIdAllocator( + seed: string, +): LegacyMigrationIdAllocator { + if (!seed.trim()) throw new Error('migration ID seed is required'); + const allocated = new Set(); + return { + allocate(scope: string): Ulid { + if (!scope.trim()) throw new Error('migration ID scope is required'); + for (let attempt = 0; attempt < 1024; attempt += 1) { + const digest = createHash('sha256') + .update(seed) + .update('\0') + .update(scope) + .update('\0') + .update(String(attempt)) + .digest(); + const timestamp = digest.readUIntBE(0, 6); + const id = createUlid(timestamp, digest.subarray(6, 16)); + if (!allocated.has(id)) { + allocated.add(id); + return id; + } + } + throw new Error(`unable to allocate a stable migration ID for ${scope}`); + }, + }; +} + +/** Allocate task aliases independently of filesystem traversal order. */ +export function createLegacyTaskAliasMap( + legacyTaskIds: string[], + namespace: TaskNamespace, + idAllocator: LegacyMigrationIdAllocator, +): LegacyTaskAliasMap { + const sorted = sortUtf8StringSet(legacyTaskIds); + if (sorted.length !== legacyTaskIds.length) { + throw new Error('legacy task IDs must be unique for alias allocation'); + } + return Object.fromEntries( + sorted.map((legacyTaskId) => [ + legacyTaskId, + { namespace, taskId: idAllocator.allocate(`task:${legacyTaskId}`) }, + ]), + ); +} + +/** + * State pointers are global legacy hints, so validate them against the complete + * alias map before staging individual task candidates. Nothing is guessed when + * an old pointer resolves to zero or multiple tasks. + */ +export function assertLegacyStatePointers( + state: Partial | null, + aliases: LegacyTaskAliasMap, +): void { + if (state === null) return; + if (state.currentTask !== null && state.currentTask !== undefined) { + if ( + typeof state.currentTask !== 'string' || + aliases[state.currentTask] === undefined + ) { + throw new Error('MANCODE_MIGRATION_CURRENT_TASK_ALIAS_MISSING'); + } + } + const activeSoloPlan = state.activeSoloPlan; + if (activeSoloPlan !== null && activeSoloPlan !== undefined) { + if ( + typeof activeSoloPlan.taskId !== 'string' || + aliases[activeSoloPlan.taskId] === undefined || + !Number.isSafeInteger(activeSoloPlan.planVersion) || + activeSoloPlan.planVersion < 1 + ) { + throw new Error('MANCODE_MIGRATION_SOLO_PLAN_ALIAS_MISSING'); + } + } + if ( + state.currentWorkflowMode !== null && + state.currentWorkflowMode !== undefined && + normalizeLegacyWorkflowMode(state.currentWorkflowMode) === null + ) { + throw new Error('MANCODE_MIGRATION_CURRENT_WORKFLOW_MODE_INVALID'); + } +} + +/** + * Creates an inert V3 candidate. It does not write staging files or promote + * data. Any missing owner, parent alias, unsafe artifact path, or invalid + * field is a hard migration failure rather than a guessed replacement. + */ +export function migrateLegacyTaskToV3( + input: LegacyTaskMigrationInput, +): MigratedLegacyTaskCandidate { + assertLegacyStatePointers(input.state, input.aliases); + const workflowMode = normalizeRequiredLegacyMode(input.workflow.mode); + const taskRef = resolveTaskAlias(input.aliases, input.workflow.taskId); + const parent = mapParent(input, workflowMode, taskRef); + assertTaskPlacement(workflowMode, taskRef, parent); + const isActive = !isLegacyTerminalStatus(input.workflow.status); + if (isActive && input.owner === null) { + throw new Error('MANCODE_MIGRATION_OWNER_REQUIRED'); + } + const ownerActorId = input.owner?.actorId ?? null; + const participants = normalizeParticipants(input.owner, ownerActorId); + const requirements = parseRequirementsLedger(mapRequirements(input, taskRef)); + const artifactAliases: LegacyArtifactAlias[] = []; + const review = parseReviewLedger(mapReview(input, taskRef, artifactAliases)); + const verification = parseVerificationLedger( + mapVerification(input, taskRef, requirements, review, artifactAliases), + requirements, + ); + const metadata = parseWorkflowMetadata( + mapWorkflowMetadata( + input, + taskRef, + workflowMode, + parent, + ownerActorId, + participants, + requirements, + review, + verification, + ), + ); + const auxiliary = mapAuxiliaryData(input, taskRef); + return { + metadata, + requirements, + review, + verification, + artifactAliases, + auxiliary, + }; +} + +export function evaluateLegacyCompletionGate( + source: LegacyTaskMigrationSource, +): CompletionGateTuple { + const requirementsStatus = legacyRequirementsStatus(source); + const reviewStatus = legacyReviewStatus(source.review); + const verificationStatus = legacyVerificationStatus(source); + const blockerIds = sortUtf8StringSet( + source.review.blockers + .filter((blocker) => blocker.status === 'open') + .map((blocker) => blocker.id), + ); + const requiredEvidence = legacyRequiredEvidence(source.requirements); + const workflow = source.workflow; + const policyEnabled = + workflow.planningPolicyVersion !== undefined || + workflow.reviewPolicyVersion !== undefined || + workflow.verificationPolicyVersion !== undefined; + const governanceDecision = workflow.planDecision; + const bypassGovernance = governanceDecision === 'solo_handoff'; + const allowed = + !isLegacyTerminalStatus(workflow.status) && + workflow.status !== 'blocked' && + (!policyEnabled || + (requirementsStatus === 'ready' && governanceDecision !== undefined)) && + (workflow.reviewPolicyVersion === undefined || + bypassGovernance || + reviewStatus === 'passed' || + reviewStatus === 'skipped') && + (workflow.verificationPolicyVersion === undefined || + bypassGovernance || + verificationStatus === 'passed'); + return { + allowed, + requirementsStatus, + reviewStatus, + verificationStatus, + blockerIds, + requiredEvidence, + }; +} + +export function evaluateV3CompatibilityGate( + candidate: MigratedLegacyTaskCandidate, +): CompletionGateTuple { + const { metadata, requirements, review, verification } = candidate; + const requirementsStatus: RequirementsStatus = + requirements.status === 'confirmed' && v3RequirementsAreReady(requirements) + ? 'ready' + : 'needs_clarification'; + const reviewStatus = deriveReviewLedgerStatus(review) as ReviewStatus; + const verificationStatus = deriveVerificationLedgerStatus(verification, { + requirementsDigest: requirements.contentDigest, + planVersion: metadata.governance.planVersion, + remediationRound: review.remediationRound, + }) as VerificationStatus; + const blockerIds = sortUtf8StringSet( + review.blockers + .filter((blocker) => blocker.status === 'open') + .map((blocker) => blocker.legacyId ?? blocker.displayId), + ); + const requiredEvidence = sortUtf8StringSet( + verification.checks + .filter((check) => check.required) + .flatMap((check) => [ + ...(check.automated === null + ? [] + : [`${check.legacyId ?? check.displayId}:automated`]), + ...(check.manual === null + ? [] + : [`${check.legacyId ?? check.displayId}:manual`]), + ]), + ); + const policyEnabled = + metadata.governance.policyVersions.planning !== null || + metadata.governance.policyVersions.review !== null || + metadata.governance.policyVersions.verification !== null; + const bypassGovernance = metadata.governance.planDecision === 'solo_handoff'; + const allowed = + !isV3TerminalStatus(metadata.status) && + metadata.status !== 'blocked' && + (!policyEnabled || + (requirementsStatus === 'ready' && + metadata.governance.planDecision !== null)) && + (metadata.governance.policyVersions.review === null || + bypassGovernance || + reviewStatus === 'passed' || + reviewStatus === 'skipped') && + (metadata.governance.policyVersions.verification === null || + bypassGovernance || + verificationStatus === 'passed'); + return { + allowed, + requirementsStatus, + reviewStatus, + verificationStatus, + blockerIds, + requiredEvidence, + }; +} + +export function createMigrationParityReport( + source: LegacyTaskMigrationSource, + candidate: MigratedLegacyTaskCandidate, + aliases: LegacyTaskAliasMap, +): MigrationParityReportV1 { + const mappings: MigrationFieldMapping[] = []; + const inventory = buildLegacyFieldInventory(source); + const expectedTaskRef = aliases[source.workflow.taskId]; + compare( + mappings, + 'workflow.taskId', + 'metadata.legacyCompatibility.legacyTaskId + aliases', + expectedTaskRef !== undefined && + sameTaskRef(expectedTaskRef, candidate.metadata.taskRef) && + candidate.metadata.legacyCompatibility?.legacyTaskId === + source.workflow.taskId, + ); + compare( + mappings, + 'workflow.task', + 'metadata.task', + source.workflow.task === candidate.metadata.task, + ); + compare( + mappings, + 'workflow.mode', + 'metadata.workflowMode', + normalizeRequiredLegacyMode(source.workflow.mode) === + candidate.metadata.workflowMode, + ); + compare( + mappings, + 'workflow.currentStep', + 'metadata.currentStep', + source.workflow.currentStep === candidate.metadata.currentStep, + ); + compare( + mappings, + 'workflow.skippedSteps', + 'metadata.skippedSteps', + sameOrderedStrings( + source.workflow.skippedSteps, + candidate.metadata.skippedSteps, + ), + ); + compare( + mappings, + 'workflow.status', + 'metadata.status', + source.workflow.status === candidate.metadata.status, + ); + compare( + mappings, + 'workflow.blockingReason', + 'metadata.blockingReason', + (source.workflow.blockingReason ?? null) === + candidate.metadata.blockingReason, + ); + compare( + mappings, + 'workflow.outcome', + 'metadata.outcome', + (source.workflow.outcome ?? null) === candidate.metadata.outcome, + ); + compare( + mappings, + 'workflow.planVersion', + 'metadata.governance.planVersion', + (source.workflow.planVersion ?? 1) === + candidate.metadata.governance.planVersion, + ); + compare( + mappings, + [ + 'workflow.planningPolicyVersion', + 'workflow.reviewPolicyVersion', + 'workflow.verificationPolicyVersion', + ], + 'metadata.governance.policyVersions', + (source.workflow.planningPolicyVersion ?? null) === + candidate.metadata.governance.policyVersions.planning && + (source.workflow.reviewPolicyVersion ?? null) === + candidate.metadata.governance.policyVersions.review && + (source.workflow.verificationPolicyVersion ?? null) === + candidate.metadata.governance.policyVersions.verification, + ); + compare( + mappings, + 'workflow.planDecision', + 'metadata.governance.planDecision', + (source.workflow.planDecision ?? null) === + candidate.metadata.governance.planDecision, + ); + compare( + mappings, + 'workflow.parentTaskId', + 'metadata.parent.taskRef', + parentMatches(source.workflow.parentTaskId, candidate.metadata, aliases), + ); + compare( + mappings, + ['workflow.startedAt', 'workflow.updatedAt'], + 'metadata.startedAt/updatedAt', + source.workflow.startedAt === candidate.metadata.startedAt && + source.workflow.updatedAt === candidate.metadata.updatedAt, + ); + compare( + mappings, + 'source.metadataDigest', + 'metadata.legacyCompatibility.sourceMetadataDigest', + source.sourceDigests.metadata === + candidate.metadata.legacyCompatibility?.sourceMetadataDigest, + ); + compare( + mappings, + 'workflow.requirementsStatus', + 'metadata.governance.requirementsStatus', + source.workflow.requirementsStatus === undefined || + source.workflow.requirementsStatus === + candidate.metadata.governance.requirementsStatus, + ); + compare( + mappings, + 'workflow.requirementsDigest', + 'requirements.legacySource.sourceDigest', + source.workflow.requirementsDigest === undefined || + normalizeLegacyDigest(source.workflow.requirementsDigest) === + candidate.requirements.legacySource?.sourceDigest, + ); + compare( + mappings, + 'workflow.verificationStatus', + 'metadata.governance.verificationStatus', + source.workflow.verificationStatus === undefined || + source.workflow.verificationStatus === + candidate.metadata.governance.verificationStatus, + ); + compareRequirements(source, candidate, mappings); + compareReview(source, candidate, mappings); + compareVerification(source, candidate, mappings); + compareState(source, candidate, aliases, mappings); + + const legacyGate = evaluateLegacyCompletionGate(source); + const v3Gate = evaluateV3CompatibilityGate(candidate); + const contextPackShadow = createMigrationContextPackShadow( + source, + candidate, + aliases, + ); + const gateMatches = sameGateTuple(legacyGate, v3Gate); + if (!gateMatches) { + mappings.push({ + source: 'completionGate', + sourceFields: [], + target: 'v3CompatibilityGate', + matched: false, + }); + } + const coveredFields = new Set( + mappings.flatMap((mapping) => mapping.sourceFields), + ); + const unmappedFields = sortUtf8StringSet([ + ...mappings + .filter((mapping) => !mapping.matched) + .flatMap((mapping) => mapping.sourceFields), + ...inventory.filter((field) => !coveredFields.has(field)), + ]); + const activationBlockers = [...unmappedFields]; + if (!gateMatches) activationBlockers.push('completionGate'); + for (const comparison of contextPackShadow.comparisons) { + if (!comparison.matched) { + activationBlockers.push(`contextPackShadow:${comparison.purpose}`); + } + } + if ( + candidate.metadata.workflowMode === 'manteam' && + !isV3TerminalStatus(candidate.metadata.status) && + candidate.metadata.implementationScope.source === 'legacy_unspecified' + ) { + activationBlockers.push('implementationScope.confirmationRequired'); + } + const taskRefs = [formatTaskRef(candidate.metadata.taskRef)]; + if (candidate.metadata.parent !== null) { + taskRefs.push(formatTaskRef(candidate.metadata.parent.taskRef)); + } + return { + schemaVersion: 1, + fieldMapVersion: LEGACY_V3_FIELD_MAP_VERSION, + legacyTaskId: source.workflow.taskId, + taskRef: candidate.metadata.taskRef, + sourceDigests: source.sourceDigests, + v3Digests: { + metadata: workflowMetadataDigest(candidate.metadata), + requirements: candidate.requirements.contentDigest, + review: candidate.review.contentDigest, + verification: candidate.verification.contentDigest, + }, + sourceFieldInventory: inventory, + fieldMappings: mappings, + unmappedFields, + legacyGate, + v3Gate, + contextPackShadow, + aliasResolution: { + taskRefs: sortUtf8StringSet(taskRefs), + displayIds: sortUtf8StringSet([ + ...candidate.requirements.acceptanceCriteria.map( + (criterion) => criterion.displayId, + ), + ...candidate.review.blockers.map((blocker) => blocker.displayId), + ...candidate.verification.checks.map((check) => check.displayId), + ]), + artifactPaths: sortUtf8StringSet( + candidate.artifactAliases.map((alias) => alias.legacyPath), + ), + }, + activationBlockers: sortUtf8StringSet(activationBlockers), + }; +} + +export function assertMigrationParity(report: MigrationParityReportV1): void { + if (report.unmappedFields.length > 0) { + throw new Error( + `MANCODE_MIGRATION_PARITY_FAILED: unmapped fields: ${report.unmappedFields.join(', ')}`, + ); + } + if (!sameGateTuple(report.legacyGate, report.v3Gate)) { + throw new Error('MANCODE_MIGRATION_PARITY_FAILED: completion gate differs'); + } + if (report.contextPackShadow.comparisons.some((item) => !item.matched)) { + throw new Error('MANCODE_MIGRATION_PARITY_FAILED: context pack differs'); + } + if (report.activationBlockers.length > 0) { + throw new Error( + `MANCODE_MIGRATION_PARITY_FAILED: activation blockers: ${report.activationBlockers.join(', ')}`, + ); + } +} + +const CONTEXT_PACK_SHADOW_PURPOSES: ContextPurpose[] = [ + 'orient', + 'plan', + 'implement', + 'review', + 'verify', + 'handoff', +]; + +const CONTEXT_PACK_SHADOW_POINTERS: ContextPackSectionPointer[] = [ + '/session', + '/project', + '/collaboration', + '/activeTask', + '/governance/requirements', + '/governance/review', + '/governance/verification', + '/parentFreshness', + '/conflicts', + '/capabilities', + '/transportFreshness', +]; + +interface ContextPackShadowProjection { + project: unknown; + collaboration: unknown; + activeTask: unknown; + requirements: unknown; + review: unknown; + verification: unknown; + parentFreshness: unknown; +} + +/** + * Runs the purpose and omission rules of the V2 Context Pack builder against + * privacy-safe semantic fingerprints of both authorities. The report stores + * only digests, while a mismatch still blocks activation for the exact + * purpose where an adapter would observe different context. + */ +export function createMigrationContextPackShadow( + source: LegacyTaskMigrationSource, + candidate: MigratedLegacyTaskCandidate, + aliases: LegacyTaskAliasMap, +): MigrationContextPackShadowV1 { + const taskRef = candidate.metadata.taskRef; + const legacy = legacyContextPackShadowProjection(source, aliases); + const v3 = v3ContextPackShadowProjection(candidate); + return { + schemaVersion: 1, + comparisons: CONTEXT_PACK_SHADOW_PURPOSES.map((purpose) => { + const legacyPack = buildContextPackShadow( + purpose, + taskRef, + source.workflow.updatedAt, + legacy, + ); + const v3Pack = buildContextPackShadow( + purpose, + taskRef, + candidate.metadata.updatedAt, + v3, + ); + const differences: string[] = CONTEXT_PACK_SHADOW_POINTERS.filter( + (pointer) => + digestCanonicalJson(contextPackShadowValue(legacyPack, pointer)) !== + digestCanonicalJson(contextPackShadowValue(v3Pack, pointer)), + ); + if ( + legacyPack.packDigest !== v3Pack.packDigest && + differences.length === 0 + ) { + differences.push('/pack'); + } + return { + purpose, + legacyPackDigest: legacyPack.packDigest, + v3PackDigest: v3Pack.packDigest, + matched: differences.length === 0, + differences, + }; + }), + }; +} + +function buildContextPackShadow( + purpose: ContextPurpose, + taskRef: TaskRef, + generatedAt: string, + projection: ContextPackShadowProjection, +) { + return buildContextPack({ + generatedAt, + level: 'task', + purpose, + snapshot: { + schemaEpoch: taskRef.taskId, + taskRevision: 1, + requirementsDigest: null, + reviewDigest: null, + verificationDigest: null, + ownershipEpoch: null, + coordinationRevision: 1, + }, + budgetLimit: Number.MAX_SAFE_INTEGER, + sections: [ + contextPackShadowSection('/session', { state: 'migration_shadow' }, true), + contextPackShadowSection('/activeTask', projection.activeTask, true), + contextPackShadowSection('/conflicts', [], true), + contextPackShadowSection( + '/capabilities', + { transport: 'migration_shadow' }, + true, + ), + contextPackShadowSection( + '/transportFreshness', + { state: 'migration_shadow' }, + true, + ), + contextPackShadowSection('/project', projection.project), + contextPackShadowSection('/collaboration', projection.collaboration), + contextPackShadowSection( + '/governance/requirements', + projection.requirements, + ), + contextPackShadowSection('/governance/review', projection.review), + contextPackShadowSection( + '/governance/verification', + projection.verification, + ), + contextPackShadowSection('/parentFreshness', projection.parentFreshness), + ], + }); +} + +function contextPackShadowSection( + targetJsonPointer: ContextPackSectionPointer, + projection: unknown, + required = false, +): ContextPackSectionInput { + return { + targetJsonPointer, + value: { semanticDigest: digestCanonicalJson(projection) }, + required, + provenance: [ + { + targetJsonPointer, + sourceKind: 'derived', + taskRef: null, + artifactRef: null, + entityKey: null, + sourceRevision: null, + sourceDigest: null, + selectedJsonPointers: [''], + redactions: [], + }, + ], + }; +} + +function contextPackShadowValue( + pack: ReturnType, + pointer: ContextPackSectionPointer, +): unknown { + switch (pointer) { + case '/session': + return pack.session; + case '/project': + return pack.project; + case '/collaboration': + return pack.collaboration; + case '/activeTask': + return pack.activeTask; + case '/governance/requirements': + return pack.governance.requirements; + case '/governance/review': + return pack.governance.review; + case '/governance/verification': + return pack.governance.verification; + case '/parentFreshness': + return pack.parentFreshness; + case '/conflicts': + return pack.conflicts; + case '/capabilities': + return pack.capabilities; + case '/transportFreshness': + return pack.transportFreshness; + default: + return null; + } +} + +function legacyContextPackShadowProjection( + source: LegacyTaskMigrationSource, + aliases: LegacyTaskAliasMap, +): ContextPackShadowProjection { + const workflowMode = normalizeRequiredLegacyMode(source.workflow.mode); + const taskRef = resolveTaskAlias(aliases, source.workflow.taskId); + return { + project: legacyProjectShadow(source, aliases), + collaboration: { + workflowMode, + parentTaskRef: + source.workflow.parentTaskId === undefined + ? null + : formatTaskRef( + resolveTaskAlias(aliases, source.workflow.parentTaskId), + ), + }, + activeTask: { + taskRef: formatTaskRef(taskRef), + task: source.workflow.task, + workflowMode, + status: source.workflow.status, + currentStep: source.workflow.currentStep, + skippedSteps: source.workflow.skippedSteps, + blockingReason: source.workflow.blockingReason ?? null, + outcome: source.workflow.outcome ?? null, + governance: { + requirementsStatus: legacyRequirementsStatus(source), + planVersion: source.workflow.planVersion ?? 1, + planDecision: source.workflow.planDecision ?? null, + reviewStatus: legacyReviewStatus(source.review), + verificationStatus: legacyVerificationStatus(source), + }, + }, + requirements: { + goal: source.requirements.goal, + confirmedScope: source.requirements.confirmedScope, + excludedScope: source.requirements.excludedScope, + technicalDecisions: source.requirements.technicalDecisions, + defaults: source.requirements.defaults, + coverage: source.requirements.coverage, + acceptanceCriteria: source.requirements.acceptanceCriteria, + blockingUnknowns: source.requirements.blockingUnknowns, + }, + review: source.review, + verification: { + version: 1, + planVersion: source.verification.planVersion, + requirementsDigest: normalizeLegacyDigest( + source.verification.requirementsDigest, + ), + remediationRound: source.verification.remediationRound, + status: source.verification.status, + checks: source.verification.checks.map((item) => ({ + acceptanceId: item.acceptanceId, + required: item.required, + ...(item.automated === undefined + ? {} + : { automated: legacyVerificationEvidenceShadow(item.automated) }), + ...(item.manual === undefined + ? {} + : { manual: legacyVerificationEvidenceShadow(item.manual) }), + })), + }, + parentFreshness: { + parentTaskRef: + source.workflow.parentTaskId === undefined + ? null + : formatTaskRef( + resolveTaskAlias(aliases, source.workflow.parentTaskId), + ), + }, + }; +} + +function v3ContextPackShadowProjection( + candidate: MigratedLegacyTaskCandidate, +): ContextPackShadowProjection { + const { metadata, requirements, review, verification } = candidate; + return { + project: candidate.auxiliary, + collaboration: { + workflowMode: metadata.workflowMode, + parentTaskRef: + metadata.parent === null + ? null + : formatTaskRef(metadata.parent.taskRef), + }, + activeTask: { + taskRef: formatTaskRef(metadata.taskRef), + task: metadata.task, + workflowMode: metadata.workflowMode, + status: metadata.status, + currentStep: metadata.currentStep, + skippedSteps: metadata.skippedSteps, + blockingReason: metadata.blockingReason, + outcome: metadata.outcome, + governance: { + requirementsStatus: metadata.governance.requirementsStatus, + planVersion: metadata.governance.planVersion, + planDecision: metadata.governance.planDecision, + reviewStatus: metadata.governance.reviewStatus, + verificationStatus: metadata.governance.verificationStatus, + }, + }, + requirements: { + goal: requirements.goal, + confirmedScope: requirements.functionalScope.inScope, + excludedScope: requirements.functionalScope.outOfScope, + technicalDecisions: requirements.technicalDecisions.map( + (item) => item.statement, + ), + defaults: requirements.defaults.map((item) => item.statement), + coverage: requirements.coverage.map( + ({ dimension, status, rationale }) => ({ + dimension, + status, + rationale, + }), + ), + acceptanceCriteria: requirements.acceptanceCriteria.map((item) => ({ + id: item.legacyId, + description: item.statement, + required: item.required, + method: item.verificationRequirement, + })), + blockingUnknowns: requirements.blockingUnknowns.map( + (item) => item.statement, + ), + }, + review: { + version: '1.0', + depth: review.depth, + requiredDomains: review.requiredDomains, + completedDomains: review.domains + .filter((item) => item.status === 'passed') + .map((item) => item.domain), + reports: Object.fromEntries( + review.domains.flatMap((item) => { + if (item.reportRef === null) return []; + const alias = candidate.artifactAliases.find((candidateAlias) => + sameJson(candidateAlias.artifactRef, item.reportRef), + ); + return alias === undefined ? [] : [[item.domain, alias.legacyPath]]; + }), + ), + blockers: review.blockers.map((item) => ({ + id: item.legacyId, + domain: item.domain, + status: item.status, + })), + remediationRounds: review.remediationRound, + ...(review.skip === null + ? {} + : { + skipped: { + reason: review.skip.reason, + recordedAt: review.skip.approvedAt, + }, + }), + }, + verification: { + version: 1, + planVersion: verification.planVersion, + requirementsDigest: + verification.legacySource?.sourceRequirementsDigest ?? null, + remediationRound: verification.remediationRound, + status: verification.status, + checks: verification.checks.map((item) => ({ + acceptanceId: item.legacyId, + required: item.required, + automated: v3VerificationEvidenceShadow(item.automated, candidate), + manual: v3VerificationEvidenceShadow(item.manual, candidate), + })), + }, + parentFreshness: { + parentTaskRef: + metadata.parent === null + ? null + : formatTaskRef(metadata.parent.taskRef), + }, + }; +} + +function legacyProjectShadow( + source: LegacyTaskMigrationSource, + aliases: LegacyTaskAliasMap, +): unknown { + const state = source.state; + if (state === null) { + return { + resumeHint: null, + projectFacts: null, + teamAssessment: null, + migrationAudit: null, + }; + } + return { + resumeHint: + state.currentTask === source.workflow.taskId && + typeof state.currentMode === 'string' + ? { + schemaVersion: 1, + source: 'legacy_state', + taskRef: resolveTaskAlias(aliases, source.workflow.taskId), + currentMode: state.currentMode, + currentWorkflowMode: normalizeLegacyWorkflowMode( + state.currentWorkflowMode, + ), + lastMode: + typeof state.lastMode === 'string' ? state.lastMode : null, + } + : null, + projectFacts: { + source: 'legacy_state', + platform: typeof state.platform === 'string' ? state.platform : null, + techStack: typeof state.techStack === 'string' ? state.techStack : null, + uiLibrary: typeof state.uiLibrary === 'string' ? state.uiLibrary : null, + projectMode: + typeof state.projectMode === 'string' ? state.projectMode : null, + }, + teamAssessment: { + source: 'legacy_state', + teamModeAutoDetected: + typeof state.teamModeAutoDetected === 'boolean' + ? state.teamModeAutoDetected + : null, + contributors: + typeof state.contributors === 'number' ? state.contributors : null, + }, + migrationAudit: { + source: 'legacy_state', + version: typeof state.version === 'string' ? state.version : null, + initializedAt: + typeof state.initializedAt === 'string' ? state.initializedAt : null, + }, + }; +} + +function v3VerificationEvidenceShadow( + evidence: VerificationLedgerV1['checks'][number]['automated'], + candidate: MigratedLegacyTaskCandidate, +): unknown { + if (evidence === null) return undefined; + const artifactAlias = + evidence.artifactRef === null + ? undefined + : candidate.artifactAliases.find((item) => + sameJson(item.artifactRef, evidence.artifactRef), + ); + return { + status: evidence.status, + ...(evidence.summary === null ? {} : { evidence: evidence.summary }), + ...(evidence.updatedAt === null ? {} : { updatedAt: evidence.updatedAt }), + ...(evidence.command === null ? {} : { command: evidence.command }), + ...(evidence.exitCode === null ? {} : { exitCode: evidence.exitCode }), + ...(artifactAlias === undefined + ? {} + : { evidenceFile: artifactAlias.legacyPath }), + }; +} + +function legacyVerificationEvidenceShadow( + evidence: LegacyVerificationEvidence, +): unknown { + return { + status: evidence.status, + ...(evidence.evidence === undefined ? {} : { evidence: evidence.evidence }), + ...(evidence.updatedAt === undefined + ? {} + : { updatedAt: evidence.updatedAt }), + ...(evidence.command === undefined ? {} : { command: evidence.command }), + ...(evidence.exitCode === undefined ? {} : { exitCode: evidence.exitCode }), + ...(evidence.evidenceFile === undefined + ? {} + : { evidenceFile: evidence.evidenceFile }), + }; +} + +function mapRequirements( + input: LegacyTaskMigrationInput, + taskRef: TaskRef, +): RequirementsLedgerV1 { + const legacy = input.requirements; + const draft: RequirementsLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef, + revision: 1, + status: legacy.blockingUnknowns.length === 0 ? 'confirmed' : 'draft', + goal: legacy.goal, + functionalScope: { + inScope: [...legacy.confirmedScope], + outOfScope: [...legacy.excludedScope], + }, + technicalDecisions: legacy.technicalDecisions.map((statement, index) => ({ + displayId: `TD-${index + 1}`, + legacyId: null, + decisionId: input.idAllocator.allocate( + `${input.workflow.taskId}:technicalDecision:${index}`, + ), + statement, + })), + defaults: legacy.defaults.map((statement, index) => ({ + displayId: `D-${index + 1}`, + legacyId: null, + defaultId: input.idAllocator.allocate( + `${input.workflow.taskId}:default:${index}`, + ), + statement, + })), + coverage: legacy.coverage.map((coverage, index) => ({ + coverageId: input.idAllocator.allocate( + `${input.workflow.taskId}:coverage:${coverage.dimension}:${index}`, + ), + dimension: coverage.dimension, + status: coverage.status, + rationale: coverage.rationale, + })), + requirements: [], + acceptanceCriteria: legacy.acceptanceCriteria.map((criterion, index) => ({ + displayId: criterion.id, + legacyId: criterion.id, + criterionId: input.idAllocator.allocate( + `${input.workflow.taskId}:acceptance:${criterion.id}:${index}`, + ), + requirementIds: [], + statement: criterion.description, + required: criterion.required, + verificationRequirement: criterion.method, + })), + blockingUnknowns: legacy.blockingUnknowns.map((statement, index) => ({ + displayId: `U-${index + 1}`, + legacyId: null, + unknownId: input.idAllocator.allocate( + `${input.workflow.taskId}:blockingUnknown:${index}`, + ), + statement, + status: 'open', + })), + legacySource: { + sourceSchema: 'requirements-v1', + sourceDigest: + input.workflow.requirementsDigest === undefined + ? requireDigest( + input.sourceDigests.requirements, + 'legacy requirements source digest', + ) + : requireDigest( + normalizeLegacyDigest(input.workflow.requirementsDigest), + 'legacy requirements digest', + ), + fieldMapVersion: LEGACY_V3_FIELD_MAP_VERSION, + }, + contentDigest: '', + lastOperationId: null, + updatedAt: input.workflow.updatedAt, + }; + return { ...draft, contentDigest: requirementsLedgerDigest(draft) }; +} + +function mapReview( + input: LegacyTaskMigrationInput, + taskRef: TaskRef, + artifactAliases: LegacyArtifactAlias[], +): ReviewLedgerV1 { + const legacy = input.review; + const skipped = legacy.skipped; + const draft: ReviewLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef, + revision: 1, + status: 'pending', + depth: legacy.depth, + requirementsDigest: null, + planVersion: null, + requiredDomains: skipped ? [] : [...legacy.requiredDomains], + domains: skipped + ? [] + : legacy.requiredDomains.map((domain) => { + const legacyPath = legacy.reports[domain]; + return { + domain, + status: legacy.completedDomains.includes(domain) + ? ('passed' as const) + : ('pending' as const), + reportRef: + legacyPath === undefined + ? null + : createArtifactAlias( + input, + taskRef, + legacyPath, + 'review_report', + artifactAliases, + ), + }; + }), + blockers: skipped + ? [] + : legacy.blockers.map((blocker, index) => ({ + displayId: blocker.id, + legacyId: blocker.id, + blockerId: input.idAllocator.allocate( + `${input.workflow.taskId}:reviewBlocker:${blocker.id}:${index}`, + ), + domain: blocker.domain, + severity: 'legacy_unknown' as const, + status: blocker.status, + summary: null, + waiver: null, + })), + remediationRound: skipped ? 0 : legacy.remediationRounds, + skip: + skipped === undefined + ? null + : { + reason: skipped.reason, + approvedByActorId: null, + approvedAt: skipped.recordedAt, + source: 'legacy_migration' as const, + }, + legacySource: { + sourceSchema: 'review-ledger-1.0', + sourceDigest: requireDigest( + input.sourceDigests.review, + 'legacy review source digest', + ), + sourceRequirementsDigest: optionalLegacyDigest( + input.workflow.requirementsDigest, + ), + fieldMapVersion: LEGACY_V3_FIELD_MAP_VERSION, + }, + contentDigest: '', + lastOperationId: null, + updatedAt: input.workflow.updatedAt, + }; + const withStatus = { ...draft, status: deriveReviewLedgerStatus(draft) }; + return { ...withStatus, contentDigest: reviewLedgerDigest(withStatus) }; +} + +function mapVerification( + input: LegacyTaskMigrationInput, + taskRef: TaskRef, + requirements: RequirementsLedgerV1, + review: ReviewLedgerV1, + artifactAliases: LegacyArtifactAlias[], +): VerificationLedgerV1 { + const legacy = input.verification; + const criteria = new Map( + requirements.acceptanceCriteria.map((criterion) => [ + criterion.displayId, + criterion, + ]), + ); + const checks = legacy.checks.map((legacyCheck, index) => { + const criterion = criteria.get(legacyCheck.acceptanceId); + if (criterion === undefined) { + throw new Error( + `MANCODE_MIGRATION_ACCEPTANCE_ALIAS_MISSING: ${legacyCheck.acceptanceId}`, + ); + } + return { + displayId: criterion.displayId, + legacyId: criterion.legacyId, + checkId: input.idAllocator.allocate( + `${input.workflow.taskId}:verificationCheck:${legacyCheck.acceptanceId}:${index}`, + ), + criterionId: criterion.criterionId, + required: legacyCheck.required, + verificationRequirement: criterion.verificationRequirement, + automated: mapVerificationEvidence( + input, + taskRef, + legacyCheck.automated, + 'automated', + `${legacyCheck.acceptanceId}:automated`, + artifactAliases, + ), + manual: mapVerificationEvidence( + input, + taskRef, + legacyCheck.manual, + 'manual', + `${legacyCheck.acceptanceId}:manual`, + artifactAliases, + ), + }; + }); + const draft: VerificationLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef, + revision: 1, + status: 'pending', + requirementsDigest: requirements.contentDigest, + planVersion: legacy.planVersion, + remediationRound: legacy.remediationRound, + checks, + legacySource: { + sourceSchema: 'verification-v1', + sourceDigest: requireDigest( + input.sourceDigests.verification, + 'legacy verification source digest', + ), + sourceRequirementsDigest: requireDigest( + normalizeLegacyDigest(legacy.requirementsDigest), + 'legacy verification requirements digest', + ), + fieldMapVersion: LEGACY_V3_FIELD_MAP_VERSION, + }, + contentDigest: '', + lastOperationId: null, + updatedAt: input.workflow.updatedAt, + }; + const stale = + legacy.planVersion !== (input.workflow.planVersion ?? 1) || + legacy.remediationRound !== review.remediationRound || + (input.workflow.requirementsDigest !== undefined && + normalizeLegacyDigest(legacy.requirementsDigest) !== + normalizeLegacyDigest(input.workflow.requirementsDigest)); + const withStatus = { + ...draft, + status: stale + ? ('stale' as const) + : (deriveVerificationLedgerStatus(draft) as VerificationLedgerStatus), + }; + return { + ...withStatus, + contentDigest: verificationLedgerDigest(withStatus), + }; +} + +function mapVerificationEvidence( + input: LegacyTaskMigrationInput, + taskRef: TaskRef, + legacy: LegacyVerificationEvidence | undefined, + kind: 'automated' | 'manual', + scope: string, + artifactAliases: LegacyArtifactAlias[], +): VerificationLedgerV1['checks'][number]['automated'] { + if (legacy === undefined) return null; + return { + evidenceId: input.idAllocator.allocate( + `${input.workflow.taskId}:verificationEvidence:${scope}`, + ), + status: legacy.status, + summary: legacy.evidence ?? null, + command: legacy.command ?? null, + exitCode: legacy.exitCode ?? null, + artifactRef: + legacy.evidenceFile === undefined + ? null + : createArtifactAlias( + input, + taskRef, + legacy.evidenceFile, + 'evidence_summary', + artifactAliases, + ), + confirmedByActorId: null, + confirmationSource: + kind === 'manual' && legacy.status === 'passed' + ? 'legacy_migration' + : null, + updatedAt: legacy.updatedAt ?? null, + }; +} + +function mapWorkflowMetadata( + input: LegacyTaskMigrationInput, + taskRef: TaskRef, + workflowMode: WorkflowMetadataV3['workflowMode'], + parent: WorkflowMetadataV3['parent'], + ownerActorId: Ulid | null, + participants: Ulid[], + requirements: RequirementsLedgerV1, + review: ReviewLedgerV1, + verification: VerificationLedgerV1, +): WorkflowMetadataV3 { + const legacy = input.workflow; + const scope = { + source: 'legacy_unspecified' as const, + include: [], + exclude: [], + modules: [], + }; + const activeSoloPlan = matchingActiveSoloPlan(input.state, legacy.taskId); + const planDecision = legacy.planDecision ?? null; + if (activeSoloPlan !== null && planDecision !== 'solo_handoff') { + throw new Error('MANCODE_MIGRATION_SOLO_PLAN_DECISION_MISMATCH'); + } + if ( + activeSoloPlan !== null && + activeSoloPlan.planVersion !== (legacy.planVersion ?? 1) + ) { + throw new Error('MANCODE_MIGRATION_SOLO_PLAN_VERSION_MISMATCH'); + } + return { + schemaVersion: 3, + taskRef, + displaySlug: legacy.taskId, + task: legacy.task, + workflowMode, + visibility: taskRef.namespace, + coordination: + workflowMode === 'manteam' || parent?.coordination === 'team' + ? 'team' + : 'single', + status: legacy.status, + currentStep: legacy.currentStep, + skippedSteps: [...legacy.skippedSteps], + blockingReason: legacy.blockingReason ?? null, + outcome: legacy.outcome ?? null, + revision: 1, + transitionState: 'stable', + lastOperationId: null, + ownerActorId, + ownershipEpoch: ownerActorId === null ? 0 : 1, + participants, + createdBy: { + actorId: null, + client: 'legacy-migration', + source: 'legacy_migration', + }, + base: null, + implementationScope: { + ...scope, + digest: digestScope(scope), + }, + governance: { + requirementsStatus: legacyRequirementsStatus(input), + requirementsDigest: requirements.contentDigest, + planVersion: legacy.planVersion ?? 1, + planDecision, + policyVersions: { + planning: legacy.planningPolicyVersion ?? null, + review: legacy.reviewPolicyVersion ?? null, + verification: legacy.verificationPolicyVersion ?? null, + }, + reviewStatus: review.status, + reviewLedgerDigest: review.contentDigest, + verificationStatus: verification.status, + verificationLedgerDigest: verification.contentDigest, + }, + soloExecution: + activeSoloPlan === null + ? null + : { + state: 'active', + planVersion: activeSoloPlan.planVersion, + assignedSessionId: null, + startedAt: null, + completedAt: null, + }, + latestCheckpointRef: null, + parent, + successorTaskRef: null, + legacyCompatibility: { + legacyTaskId: legacy.taskId, + sourceMetadataDigest: requireDigest( + input.sourceDigests.metadata, + 'legacy metadata source digest', + ), + fieldMapVersion: LEGACY_V3_FIELD_MAP_VERSION, + }, + startedAt: legacy.startedAt, + updatedAt: legacy.updatedAt, + }; +} + +function mapParent( + input: LegacyTaskMigrationInput, + workflowMode: WorkflowMetadataV3['workflowMode'], + taskRef: TaskRef, +): WorkflowMetadataV3['parent'] { + const parentTaskId = input.workflow.parentTaskId; + if (parentTaskId === undefined) { + if (input.parent !== null) { + throw new Error('MANCODE_MIGRATION_PARENT_UNEXPECTED'); + } + return null; + } + if (workflowMode !== 'manba') { + throw new Error('MANCODE_MIGRATION_PARENT_MODE_INVALID'); + } + if (input.parent === null || input.parent.legacyTaskId !== parentTaskId) { + throw new Error('MANCODE_MIGRATION_PARENT_ALIAS_MISSING'); + } + const parent = input.parent.metadata; + if ( + !sameTaskRef(resolveTaskAlias(input.aliases, parentTaskId), parent.taskRef) + ) { + throw new Error('MANCODE_MIGRATION_PARENT_ALIAS_MISMATCH'); + } + if (parent.taskRef.namespace !== taskRef.namespace) { + throw new Error('MANCODE_MIGRATION_PARENT_NAMESPACE_MISMATCH'); + } + return { + taskRef: parent.taskRef, + revisionAtCreate: parent.revision, + planVersionAtCreate: parent.governance.planVersion, + requirementsDigestAtCreate: parent.governance.requirementsDigest, + implementationScopeDigestAtCreate: parent.implementationScope.digest, + visibility: parent.visibility, + coordination: parent.coordination, + participants: parent.participants, + }; +} + +function assertTaskPlacement( + workflowMode: WorkflowMetadataV3['workflowMode'], + taskRef: TaskRef, + parent: WorkflowMetadataV3['parent'], +): void { + if (workflowMode === 'manteam' && taskRef.namespace !== 'shared') { + throw new Error('MANCODE_MIGRATION_MANTEAM_MUST_BE_SHARED'); + } + if (workflowMode === 'manba' && parent !== null) { + if (parent.visibility !== taskRef.namespace) { + throw new Error('MANCODE_MIGRATION_CHILD_NAMESPACE_MISMATCH'); + } + if (parent.coordination === 'team' && taskRef.namespace !== 'shared') { + throw new Error('MANCODE_MIGRATION_CHILD_PLACEMENT_INVALID'); + } + } +} + +function mapAuxiliaryData( + input: LegacyTaskMigrationInput, + taskRef: TaskRef, +): MigratedLegacyAuxiliaryData { + const state = input.state; + if (state === null) { + return { + resumeHint: null, + projectFacts: null, + teamAssessment: null, + migrationAudit: null, + }; + } + const currentTaskMatches = state.currentTask === input.workflow.taskId; + const currentWorkflowMode = normalizeLegacyWorkflowMode( + state.currentWorkflowMode, + ); + return { + resumeHint: + currentTaskMatches && typeof state.currentMode === 'string' + ? { + schemaVersion: 1, + source: 'legacy_state', + taskRef, + currentMode: state.currentMode, + currentWorkflowMode, + lastMode: + typeof state.lastMode === 'string' ? state.lastMode : null, + } + : null, + projectFacts: { + source: 'legacy_state', + platform: typeof state.platform === 'string' ? state.platform : null, + techStack: typeof state.techStack === 'string' ? state.techStack : null, + uiLibrary: typeof state.uiLibrary === 'string' ? state.uiLibrary : null, + projectMode: + typeof state.projectMode === 'string' ? state.projectMode : null, + }, + teamAssessment: { + source: 'legacy_state', + teamModeAutoDetected: + typeof state.teamModeAutoDetected === 'boolean' + ? state.teamModeAutoDetected + : null, + contributors: + typeof state.contributors === 'number' ? state.contributors : null, + }, + migrationAudit: { + source: 'legacy_state', + version: typeof state.version === 'string' ? state.version : null, + initializedAt: + typeof state.initializedAt === 'string' ? state.initializedAt : null, + }, + }; +} + +function createArtifactAlias( + input: LegacyTaskMigrationInput, + taskRef: TaskRef, + legacyPath: string, + kind: 'review_report' | 'evidence_summary', + aliases: LegacyArtifactAlias[], +): ArtifactRef { + assertSafeLegacyArtifactPath(legacyPath); + const existing = aliases.find( + (alias) => + alias.legacyPath === legacyPath && alias.artifactRef.kind === kind, + ); + if (existing !== undefined) return existing.artifactRef; + const artifactRef = parseArtifactRef({ + taskRef, + kind, + artifactId: input.idAllocator.allocate( + `${input.workflow.taskId}:artifact:${kind}:${legacyPath}`, + ), + }); + aliases.push({ legacyPath, artifactRef }); + return artifactRef; +} + +function compareRequirements( + source: LegacyTaskMigrationSource, + candidate: MigratedLegacyTaskCandidate, + mappings: MigrationFieldMapping[], +): void { + const legacy = source.requirements; + const v3 = candidate.requirements; + compare( + mappings, + 'requirements.goal', + 'requirements.goal', + legacy.goal === v3.goal, + ); + compare( + mappings, + 'requirements.confirmedScope', + 'requirements.functionalScope.inScope', + sameOrderedStrings(legacy.confirmedScope, v3.functionalScope.inScope), + ); + compare( + mappings, + 'requirements.excludedScope', + 'requirements.functionalScope.outOfScope', + sameOrderedStrings(legacy.excludedScope, v3.functionalScope.outOfScope), + ); + compare( + mappings, + 'requirements.technicalDecisions', + 'requirements.technicalDecisions[].statement', + sameOrderedStrings( + legacy.technicalDecisions, + v3.technicalDecisions.map((item) => item.statement), + ), + ); + compare( + mappings, + 'requirements.defaults', + 'requirements.defaults[].statement', + sameOrderedStrings( + legacy.defaults, + v3.defaults.map((item) => item.statement), + ), + ); + compare( + mappings, + 'requirements.coverage', + 'requirements.coverage', + sameJson( + legacy.coverage, + v3.coverage.map(({ dimension, status, rationale }) => ({ + dimension, + status, + rationale, + })), + ), + ); + compare( + mappings, + 'requirements.acceptanceCriteria', + 'requirements.acceptanceCriteria', + sameJson( + legacy.acceptanceCriteria, + v3.acceptanceCriteria.map((criterion) => ({ + id: criterion.legacyId, + description: criterion.statement, + required: criterion.required, + method: criterion.verificationRequirement, + })), + ), + ); + compare( + mappings, + 'requirements.blockingUnknowns', + 'requirements.blockingUnknowns[].statement', + sameOrderedStrings( + legacy.blockingUnknowns, + v3.blockingUnknowns.map((item) => item.statement), + ) && v3.blockingUnknowns.every((item) => item.status === 'open'), + ); + compare( + mappings, + 'requirements.sourceDigest', + 'migration-parity.sourceDigests.requirements', + legacyRequirementsSourceDigest(source) === v3.legacySource?.sourceDigest, + ); +} + +function legacyRequirementsSourceDigest( + source: LegacyTaskMigrationSource, +): string { + return source.workflow.requirementsDigest === undefined + ? source.sourceDigests.requirements + : normalizeLegacyDigest(source.workflow.requirementsDigest); +} + +function compareReview( + source: LegacyTaskMigrationSource, + candidate: MigratedLegacyTaskCandidate, + mappings: MigrationFieldMapping[], +): void { + const legacy = source.review; + const v3 = candidate.review; + compare(mappings, 'review.depth', 'review.depth', legacy.depth === v3.depth); + compare( + mappings, + 'review.requiredDomains', + 'review.requiredDomains', + sameOrderedStrings(legacy.requiredDomains, v3.requiredDomains), + ); + compare( + mappings, + 'review.completedDomains', + 'review.domains[].status', + sameOrderedStrings( + legacy.completedDomains, + v3.domains + .filter((domain) => domain.status === 'passed') + .map((domain) => domain.domain), + ), + ); + compare( + mappings, + 'review.reports', + 'artifactAliases[review_report]', + Object.entries(legacy.reports).every(([domain, legacyPath]) => { + const mappedDomain = v3.domains.find((item) => item.domain === domain); + return ( + mappedDomain?.reportRef !== null && + mappedDomain?.reportRef !== undefined && + candidate.artifactAliases.some( + (alias) => + alias.legacyPath === legacyPath && + alias.artifactRef.kind === 'review_report' && + sameJson(alias.artifactRef, mappedDomain.reportRef), + ) + ); + }), + ); + compare( + mappings, + 'review.blockers', + 'review.blockers', + sameJson( + legacy.blockers, + v3.blockers.map((blocker) => ({ + id: blocker.legacyId, + domain: blocker.domain, + status: blocker.status, + })), + ), + ); + compare( + mappings, + 'review.remediationRounds', + 'review.remediationRound', + legacy.remediationRounds === v3.remediationRound, + ); + compare( + mappings, + 'review.skipped', + 'review.skip', + sameJson( + legacy.skipped ?? null, + v3.skip === null + ? null + : { reason: v3.skip.reason, recordedAt: v3.skip.approvedAt }, + ), + ); + compare( + mappings, + 'review.sourceDigest', + 'review.legacySource.sourceDigest', + source.sourceDigests.review === v3.legacySource?.sourceDigest, + ); +} + +function compareVerification( + source: LegacyTaskMigrationSource, + candidate: MigratedLegacyTaskCandidate, + mappings: MigrationFieldMapping[], +): void { + const legacy = source.verification; + const v3 = candidate.verification; + compare( + mappings, + 'verification.planVersion', + 'verification.planVersion', + legacy.planVersion === v3.planVersion, + ); + compare( + mappings, + 'verification.requirementsDigest', + 'verification.legacySource.sourceRequirementsDigest', + normalizeLegacyDigest(legacy.requirementsDigest) === + v3.legacySource?.sourceRequirementsDigest, + ); + compare( + mappings, + 'verification.remediationRound', + 'verification.remediationRound', + legacy.remediationRound === v3.remediationRound, + ); + compare( + mappings, + 'verification.checks', + 'verification.checks', + legacy.checks.length === v3.checks.length && + legacy.checks.every((legacyCheck) => { + const mapped = v3.checks.find( + (check) => check.legacyId === legacyCheck.acceptanceId, + ); + return ( + mapped !== undefined && + mapped.required === legacyCheck.required && + evidenceMatches( + legacyCheck.automated, + mapped.automated, + candidate.artifactAliases, + 'automated', + ) && + evidenceMatches( + legacyCheck.manual, + mapped.manual, + candidate.artifactAliases, + 'manual', + ) + ); + }), + ); + compare( + mappings, + 'verification.sourceDigest', + 'verification.legacySource.sourceDigest', + source.sourceDigests.verification === v3.legacySource?.sourceDigest, + ); +} + +function compareState( + source: LegacyTaskMigrationSource, + candidate: MigratedLegacyTaskCandidate, + aliases: LegacyTaskAliasMap, + mappings: MigrationFieldMapping[], +): void { + const state = source.state; + if (state === null) return; + const auxiliary = candidate.auxiliary; + compare( + mappings, + [ + 'state.platform', + 'state.techStack', + 'state.uiLibrary', + 'state.projectMode', + ], + 'auxiliary.projectFacts', + auxiliary.projectFacts !== null && + auxiliary.projectFacts.platform === (state.platform ?? null) && + auxiliary.projectFacts.techStack === (state.techStack ?? null) && + auxiliary.projectFacts.uiLibrary === (state.uiLibrary ?? null) && + auxiliary.projectFacts.projectMode === (state.projectMode ?? null), + ); + compare( + mappings, + ['state.teamModeAutoDetected', 'state.contributors'], + 'auxiliary.teamAssessment', + auxiliary.teamAssessment !== null && + auxiliary.teamAssessment.teamModeAutoDetected === + (state.teamModeAutoDetected ?? null) && + auxiliary.teamAssessment.contributors === (state.contributors ?? null), + ); + compare( + mappings, + ['state.version', 'state.initializedAt'], + 'auxiliary.migrationAudit', + auxiliary.migrationAudit !== null && + auxiliary.migrationAudit.version === (state.version ?? null) && + auxiliary.migrationAudit.initializedAt === (state.initializedAt ?? null), + ); + if (state.currentTask !== source.workflow.taskId) return; + compare( + mappings, + [ + 'state.currentTask', + 'state.currentWorkflowMode', + 'state.currentMode', + 'state.lastMode', + ], + 'auxiliary.resumeHint', + auxiliary.resumeHint !== null && + sameTaskRef( + aliases[source.workflow.taskId] as TaskRef, + auxiliary.resumeHint.taskRef, + ) && + auxiliary.resumeHint.currentMode === state.currentMode && + auxiliary.resumeHint.currentWorkflowMode === + normalizeLegacyWorkflowMode(state.currentWorkflowMode) && + auxiliary.resumeHint.lastMode === (state.lastMode ?? null), + ); + compare( + mappings, + 'state.skippedSteps', + 'metadata.skippedSteps', + sameOrderedStrings( + state.skippedSteps ?? [], + candidate.metadata.skippedSteps, + ), + ); + const activeSoloPlan = matchingActiveSoloPlan(state, source.workflow.taskId); + compare( + mappings, + 'state.activeSoloPlan', + 'metadata.soloExecution', + activeSoloPlan === null + ? candidate.metadata.soloExecution === null + : candidate.metadata.soloExecution?.state === 'active' && + candidate.metadata.soloExecution.planVersion === + activeSoloPlan.planVersion, + ); +} + +function buildLegacyFieldInventory( + source: LegacyTaskMigrationSource, +): string[] { + const fields = [ + 'workflow.taskId', + 'workflow.task', + 'workflow.mode', + 'workflow.currentStep', + 'workflow.skippedSteps', + 'workflow.startedAt', + 'workflow.updatedAt', + 'workflow.status', + 'requirements.goal', + 'requirements.confirmedScope', + 'requirements.excludedScope', + 'requirements.technicalDecisions', + 'requirements.defaults', + 'requirements.coverage', + 'requirements.acceptanceCriteria', + 'requirements.blockingUnknowns', + 'review.depth', + 'review.requiredDomains', + 'review.completedDomains', + 'review.reports', + 'review.blockers', + 'review.remediationRounds', + 'verification.planVersion', + 'verification.requirementsDigest', + 'verification.remediationRound', + 'verification.checks', + ]; + const workflow = source.workflow; + if (workflow.blockingReason !== undefined) + fields.push('workflow.blockingReason'); + if (workflow.parentTaskId !== undefined) fields.push('workflow.parentTaskId'); + if (workflow.outcome !== undefined) fields.push('workflow.outcome'); + if (workflow.planVersion !== undefined) fields.push('workflow.planVersion'); + if (workflow.reviewPolicyVersion !== undefined) + fields.push('workflow.reviewPolicyVersion'); + if (workflow.planningPolicyVersion !== undefined) + fields.push('workflow.planningPolicyVersion'); + if (workflow.verificationPolicyVersion !== undefined) + fields.push('workflow.verificationPolicyVersion'); + if (workflow.requirementsStatus !== undefined) + fields.push('workflow.requirementsStatus'); + if (workflow.requirementsDigest !== undefined) + fields.push('workflow.requirementsDigest'); + if (workflow.planDecision !== undefined) fields.push('workflow.planDecision'); + if (workflow.verificationStatus !== undefined) + fields.push('workflow.verificationStatus'); + if (source.review.skipped !== undefined) fields.push('review.skipped'); + if (source.state !== null) { + fields.push( + 'state.version', + 'state.platform', + 'state.initializedAt', + 'state.techStack', + 'state.uiLibrary', + 'state.teamModeAutoDetected', + 'state.contributors', + 'state.projectMode', + ); + if (source.state.currentTask === source.workflow.taskId) { + fields.push( + 'state.currentMode', + 'state.lastMode', + 'state.currentTask', + 'state.currentWorkflowMode', + 'state.skippedSteps', + 'state.activeSoloPlan', + ); + } + } + return sortUtf8StringSet(fields); +} + +function evidenceMatches( + legacy: LegacyVerificationEvidence | undefined, + mapped: VerificationLedgerV1['checks'][number]['automated'], + aliases: LegacyArtifactAlias[], + kind: 'automated' | 'manual', +): boolean { + if (legacy === undefined) return mapped === null; + if (mapped === null) return false; + const artifactMatches = + legacy.evidenceFile === undefined + ? mapped.artifactRef === null + : mapped.artifactRef !== null && + aliases.some( + (alias) => + alias.legacyPath === legacy.evidenceFile && + alias.artifactRef.kind === 'evidence_summary' && + sameJson(alias.artifactRef, mapped.artifactRef), + ); + return ( + legacy.status === mapped.status && + (legacy.evidence ?? null) === mapped.summary && + (legacy.command ?? null) === mapped.command && + (legacy.exitCode ?? null) === mapped.exitCode && + (legacy.updatedAt ?? null) === mapped.updatedAt && + artifactMatches && + (kind !== 'manual' || + legacy.status !== 'passed' || + mapped.confirmationSource === 'legacy_migration') + ); +} + +function legacyRequirementsStatus( + source: LegacyTaskMigrationSource, +): RequirementsStatus { + if (source.workflow.requirementsStatus !== undefined) { + return source.workflow.requirementsStatus; + } + return legacyRequirementsAreReady(source.requirements) + ? 'ready' + : 'needs_clarification'; +} + +function legacyReviewStatus(legacy: LegacyReviewLedger): ReviewStatus { + if (legacy.skipped !== undefined) return 'skipped'; + if (legacy.blockers.some((blocker) => blocker.status === 'open')) { + return 'blocked'; + } + if ( + legacy.requiredDomains.every((domain) => + legacy.completedDomains.includes(domain), + ) + ) { + return 'passed'; + } + if (legacy.completedDomains.length === 0) return 'pending'; + return 'in_review'; +} + +function legacyVerificationStatus( + source: LegacyTaskMigrationSource, +): VerificationStatus { + const legacy = source.verification; + const stale = + legacy.planVersion !== (source.workflow.planVersion ?? 1) || + legacy.remediationRound !== source.review.remediationRounds || + (source.workflow.requirementsDigest !== undefined && + normalizeLegacyDigest(legacy.requirementsDigest) !== + normalizeLegacyDigest(source.workflow.requirementsDigest)); + return stale ? 'stale' : legacy.status; +} + +function legacyRequiredEvidence( + requirements: LegacyRequirementsLedger, +): string[] { + return sortUtf8StringSet( + requirements.acceptanceCriteria + .filter((criterion) => criterion.required) + .flatMap((criterion) => [ + ...(criterion.method === 'manual' ? [] : [`${criterion.id}:automated`]), + ...(criterion.method === 'automated' ? [] : [`${criterion.id}:manual`]), + ]), + ); +} + +function resolveTaskAlias( + aliases: LegacyTaskAliasMap, + legacyTaskId: string, +): TaskRef { + const taskRef = aliases[legacyTaskId]; + if (taskRef === undefined) { + throw new Error(`MANCODE_MIGRATION_TASK_ALIAS_MISSING: ${legacyTaskId}`); + } + return taskRef; +} + +function normalizeRequiredLegacyMode( + mode: LegacyWorkflowMode, +): WorkflowMetadataV3['workflowMode'] { + const normalized = normalizeLegacyWorkflowMode(mode); + if (normalized === null) { + throw new Error(`MANCODE_MIGRATION_WORKFLOW_MODE_INVALID: ${mode}`); + } + return normalized; +} + +function normalizeParticipants( + owner: LegacyMigrationOwner | null, + ownerActorId: Ulid | null, +): Ulid[] { + if (owner === null || ownerActorId === null) return []; + const participants = sortUtf8StringSet([ + ownerActorId, + ...(owner.participants ?? []), + ]); + return participants as Ulid[]; +} + +function matchingActiveSoloPlan( + state: Partial | null, + legacyTaskId: string, +): { taskId: string; planVersion: number } | null { + const active = state?.activeSoloPlan; + if ( + active === null || + active === undefined || + active.taskId !== legacyTaskId + ) { + return null; + } + return active; +} + +function parentMatches( + legacyParentTaskId: string | undefined, + metadata: WorkflowMetadataV3, + aliases: LegacyTaskAliasMap, +): boolean { + if (legacyParentTaskId === undefined) return metadata.parent === null; + const expected = aliases[legacyParentTaskId]; + return ( + expected !== undefined && + metadata.parent !== null && + sameTaskRef(expected, metadata.parent.taskRef) + ); +} + +function sameTaskRef(left: TaskRef, right: TaskRef): boolean { + return left.namespace === right.namespace && left.taskId === right.taskId; +} + +function sameGateTuple( + left: CompletionGateTuple, + right: CompletionGateTuple, +): boolean { + return ( + left.allowed === right.allowed && + left.requirementsStatus === right.requirementsStatus && + left.reviewStatus === right.reviewStatus && + left.verificationStatus === right.verificationStatus && + sameOrderedStrings(left.blockerIds, right.blockerIds) && + sameOrderedStrings(left.requiredEvidence, right.requiredEvidence) + ); +} + +function sameOrderedStrings(left: string[], right: string[]): boolean { + return ( + left.length === right.length && + left.every((item, index) => item === right[index]) + ); +} + +function sameJson(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function compare( + mappings: MigrationFieldMapping[], + source: string | string[], + target: string, + matched: boolean, +): void { + const sourceFields = Array.isArray(source) ? source : [source]; + mappings.push({ + source: sourceFields.join(' + '), + sourceFields, + target, + matched, + }); +} + +function requireDigest(value: string, label: string): string { + if (!/^sha256:[a-f0-9]{64}$/.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function optionalLegacyDigest(value: string | undefined): string | null { + if (value === undefined) return null; + return normalizeLegacyDigest(value); +} + +function normalizeLegacyDigest(value: string): string { + if (/^sha256:[a-f0-9]{64}$/.test(value)) return value; + if (/^[a-f0-9]{64}$/.test(value)) return `sha256:${value}`; + throw new Error('legacy digest must be a SHA-256 digest'); +} + +function digestScope(scope: { + source: 'legacy_unspecified'; + include: string[]; + exclude: string[]; + modules: string[]; +}): string { + return digestCanonicalJson(scope); +} + +function assertSafeLegacyArtifactPath(value: string): void { + if ( + !value || + value.includes('\0') || + value.startsWith('/') || + value.startsWith('\\') || + /^[A-Za-z]:[\\/]/.test(value) || + value.split(/[\\/]/).some((part) => !part || part === '.' || part === '..') + ) { + throw new Error('MANCODE_MIGRATION_ARTIFACT_PATH_UNSAFE'); + } +} + +function isLegacyTerminalStatus(status: LegacyWorkflowMeta['status']): boolean { + return status === 'completed' || status === 'abandoned'; +} + +function isV3TerminalStatus(status: WorkflowMetadataV3['status']): boolean { + return ( + status === 'completed' || status === 'abandoned' || status === 'superseded' + ); +} diff --git a/src/context/parent-snapshot.ts b/src/context/parent-snapshot.ts new file mode 100644 index 0000000..065cb4f --- /dev/null +++ b/src/context/parent-snapshot.ts @@ -0,0 +1,144 @@ +import { sortUtf8StringSet } from './canonical.js'; +import { type Ulid, assertUlid } from './ids.js'; +import { + type TaskNamespace, + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export interface ParentSnapshot { + taskRef: TaskRef; + revisionAtCreate: number; + planVersionAtCreate: number; + requirementsDigestAtCreate: string; + implementationScopeDigestAtCreate: string; + visibility: TaskNamespace; + coordination: 'single' | 'team'; + participants: Ulid[]; +} + +export interface ParentSnapshotSource { + taskRef: TaskRef; + revision: number; + planVersion: number; + requirementsDigest: string; + implementationScopeDigest: string; + visibility: TaskNamespace; + coordination: 'single' | 'team'; +} + +export type ParentStaleReason = + | 'task_ref' + | 'revision' + | 'plan_version' + | 'requirements_digest' + | 'implementation_scope_digest' + | 'visibility' + | 'coordination'; + +export function parseParentSnapshot(value: unknown): ParentSnapshot { + assertRecord(value, 'parent snapshot envelope'); + assertKnownKeys(value, ['parent'], 'parent snapshot envelope'); + assertRecord(value.parent, 'parent snapshot'); + assertKnownKeys( + value.parent, + [ + 'taskRef', + 'revisionAtCreate', + 'planVersionAtCreate', + 'requirementsDigestAtCreate', + 'implementationScopeDigestAtCreate', + 'visibility', + 'coordination', + 'participants', + ], + 'parent snapshot', + ); + const parent = value.parent; + if (parent.visibility !== 'local' && parent.visibility !== 'shared') { + throw new Error('parent snapshot visibility must be local or shared'); + } + if (parent.coordination !== 'single' && parent.coordination !== 'team') { + throw new Error('parent snapshot coordination must be single or team'); + } + const taskRef = parseTaskRefValue(parent.taskRef); + if (taskRef.namespace !== parent.visibility) { + throw new Error('parent snapshot TaskRef namespace must match visibility'); + } + return { + taskRef, + revisionAtCreate: parsePositiveInteger( + parent.revisionAtCreate, + 'parent snapshot revisionAtCreate', + ), + planVersionAtCreate: parsePositiveInteger( + parent.planVersionAtCreate, + 'parent snapshot planVersionAtCreate', + ), + requirementsDigestAtCreate: parseDigest( + parent.requirementsDigestAtCreate, + 'parent snapshot requirementsDigestAtCreate', + ), + implementationScopeDigestAtCreate: parseDigest( + parent.implementationScopeDigestAtCreate, + 'parent snapshot implementationScopeDigestAtCreate', + ), + visibility: parent.visibility, + coordination: parent.coordination, + participants: parseParticipants(parent.participants), + }; +} + +export function parentSnapshotStaleReasons( + snapshot: ParentSnapshot, + source: ParentSnapshotSource, +): ParentStaleReason[] { + const reasons: ParentStaleReason[] = []; + if (!sameTaskRef(snapshot.taskRef, source.taskRef)) reasons.push('task_ref'); + if (snapshot.revisionAtCreate !== source.revision) reasons.push('revision'); + if (snapshot.planVersionAtCreate !== source.planVersion) + reasons.push('plan_version'); + if (snapshot.requirementsDigestAtCreate !== source.requirementsDigest) { + reasons.push('requirements_digest'); + } + if ( + snapshot.implementationScopeDigestAtCreate !== + source.implementationScopeDigest + ) { + reasons.push('implementation_scope_digest'); + } + if (snapshot.visibility !== source.visibility) reasons.push('visibility'); + if (snapshot.coordination !== source.coordination) + reasons.push('coordination'); + return reasons; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (!Number.isInteger(value) || (value as number) < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value as number; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseParticipants(value: unknown): Ulid[] { + if (!Array.isArray(value)) { + throw new Error('parent snapshot participants must be an array'); + } + for (const participant of value) { + assertUlid(participant, 'parent snapshot participant'); + } + const normalized = sortUtf8StringSet(value); + if (normalized.length !== value.length) { + throw new Error('parent snapshot participants must not contain duplicates'); + } + return normalized as Ulid[]; +} diff --git a/src/context/plan-revision.ts b/src/context/plan-revision.ts new file mode 100644 index 0000000..97216cd --- /dev/null +++ b/src/context/plan-revision.ts @@ -0,0 +1,330 @@ +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, +} from '../runtime/operation-recovery-payload.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskHeadEntityKey, + writeTaskAuthorityFile, +} from '../runtime/task-operation.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, +} from './aggregate.js'; +import { digestCanonicalJson } from './canonical.js'; +import type { Ulid } from './ids.js'; +import { assertSharedTextSafe } from './privacy.js'; +import { + type RequirementsLedgerV1, + requirementsAreReady, +} from './requirements-ledger.js'; +import type { ReviewLedgerV1 } from './review-ledger.js'; +import { + assertTaskCodeHeadUnchanged, + markTaskReviewStale, + markTaskVerificationStale, + nextTaskHeadFence, + taskMutationExpectedRevisions, +} from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; +import type { VerificationLedgerV1 } from './verification-ledger.js'; +import { + type PlanDecision, + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export type V3PlanDecision = Exclude; + +export interface ReviseV3PlanInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + plan: string; + /** Omitting the decision leaves the workflow at the step-four plan gate. */ + planDecision?: V3PlanDecision; + operationId?: Ulid; + now?: Date; +} + +export interface RevisedV3Plan { + metadata: WorkflowMetadataV3; + review: ReviewLedgerV1; + verification: VerificationLedgerV1; + planDigest: string; + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1 | null; + operation: OperationJournalV1; +} + +/** + * Writes a new plan version and makes every prior review and verification + * result explicitly stale before the task becomes stable again. + */ +export async function reviseV3Plan( + input: ReviseV3PlanInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + const plan = requirePlan(input.plan); + const planDecision = parsePlanDecision(input.planDecision); + if (taskRef.namespace === 'shared') { + assertSharedTextSafe(plan, 'plan'); + } + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId: input.operationId, + extraEntityLocks: + taskRef.namespace === 'shared' ? [taskHeadEntityKey(taskRef)] : [], + now: input.now, + }); + let journal: OperationJournalV1 | null = null; + try { + assertPlanRevisionEligible( + context.task.metadata, + context.task.requirements, + ); + if (context.task.plan?.content === plan && planDecision === null) { + throw new Error('MANCODE_PLAN_CONTENT_UNCHANGED'); + } + const timestamp = context.now.toISOString(); + const planDigest = digestCanonicalJson({ + artifactRef: { taskRef, kind: 'plan' }, + content: plan, + }); + const review = markTaskReviewStale( + context.task.review, + context.operationId, + timestamp, + ); + const verification = markTaskVerificationStale( + context.task.verification, + context.operationId, + timestamp, + ); + const metadata = updateMetadata( + context.task.metadata, + context.task.requirements, + review, + verification, + planDecision, + context.operationId, + timestamp, + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review, + verification, + planDigest, + latestCheckpoint: context.task.latestCheckpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + + journal = await createTaskOperationJournal(context, { + type: 'plan_revision', + action: + taskRef.namespace === 'shared' + ? 'shared_metadata_plan_mutation' + : 'local_workflow_mutation', + expectedRevisions: taskMutationExpectedRevisions(context, [ + 'plan', + 'review', + 'verification', + ]), + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'write-plan', + taskRef, + fileName: 'plan.md', + beforeContent: context.task.plan?.content ?? null, + targetContent: plan, + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'update-metadata', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(metadata), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-review-verification-stale', + taskRef, + fileName: 'review-ledger.json', + beforeContent: serializeTaskAuthority(context.task.review), + targetContent: serializeTaskAuthority(review), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-review-verification-stale', + taskRef, + fileName: 'verification-ledger.json', + beforeContent: serializeTaskAuthority(context.task.verification), + targetContent: serializeTaskAuthority(verification), + }), + ...(taskHeadFence === null + ? [] + : [ + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ]), + ], + noOpStepIds: taskHeadFence === null ? ['update-task-head-fence'] : [], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + + journal = await advanceTaskOperation(context, journal, 'write-plan', false); + await writeTaskAuthorityFile(context, 'plan.md', plan); + + journal = await advanceTaskOperation( + context, + journal, + 'update-metadata', + false, + ); + await writeTaskAuthorityFile( + context, + 'metadata.json', + `${JSON.stringify(metadata, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'mark-review-verification-stale', + false, + ); + await writeTaskAuthorityFile( + context, + 'review-ledger.json', + `${JSON.stringify(review, null, 2)}\n`, + ); + await writeTaskAuthorityFile( + context, + 'verification-ledger.json', + `${JSON.stringify(verification, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-task-head-fence', + false, + ); + if (taskHeadFence !== null) { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + } + const operation = await commitTaskOperation(context, journal); + return { + metadata, + review, + verification, + planDigest, + aggregate, + taskHeadFence, + operation, + }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A durable journal is already enough to block ordinary writes. + } + } + throw error; + } finally { + await context.release(); + } +} + +function requirePlan(value: unknown): string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error('MANCODE_PLAN_CONTENT_REQUIRED'); + } + return value; +} + +function parsePlanDecision(value: unknown): V3PlanDecision | null { + if (value === undefined) return null; + if (value !== 'plan_only' && value !== 'governed_execution') { + throw new Error('MANCODE_PLAN_DECISION_INVALID'); + } + return value; +} + +function assertPlanRevisionEligible( + metadata: WorkflowMetadataV3, + requirements: RequirementsLedgerV1, +): void { + if (metadata.workflowMode !== 'man' && metadata.workflowMode !== 'manteam') { + throw new Error('MANCODE_PLAN_WORKFLOW_MODE_INVALID'); + } + if (metadata.status !== 'in_progress') { + throw new Error('MANCODE_PLAN_WORKFLOW_NOT_ACTIVE'); + } + if (metadata.currentStep < 2 || metadata.currentStep > 4) { + throw new Error('MANCODE_PLAN_STEP_INVALID'); + } + if ( + metadata.governance.planDecision !== null || + metadata.governance.requirementsStatus !== 'ready' || + metadata.governance.requirementsDigest !== requirements.contentDigest || + requirements.status !== 'confirmed' || + !requirementsAreReady(requirements) + ) { + throw new Error('MANCODE_PLAN_REQUIREMENTS_OR_DECISION_INVALID'); + } +} + +function updateMetadata( + previous: WorkflowMetadataV3, + requirements: RequirementsLedgerV1, + review: ReviewLedgerV1, + verification: VerificationLedgerV1, + planDecision: V3PlanDecision | null, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const status = planDecision === 'plan_only' ? 'planned' : 'in_progress'; + const currentStep = planDecision === 'governed_execution' ? 5 : 4; + const next = parseWorkflowMetadata({ + ...previous, + status, + currentStep, + revision: previous.revision + 1, + transitionState: 'stable', + lastOperationId: operationId, + governance: { + ...previous.governance, + requirementsStatus: 'ready', + requirementsDigest: requirements.contentDigest, + planVersion: previous.governance.planVersion + 1, + planDecision, + reviewStatus: 'stale', + reviewLedgerDigest: review.contentDigest, + verificationStatus: 'stale', + verificationLedgerDigest: verification.contentDigest, + }, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} diff --git a/src/context/privacy.ts b/src/context/privacy.ts new file mode 100644 index 0000000..bf4597a --- /dev/null +++ b/src/context/privacy.ts @@ -0,0 +1,149 @@ +import path from 'node:path'; + +export type SharedPrivacyFindingKind = + | 'authorization' + | 'cookie' + | 'private_key' + | 'secret' + | 'absolute_path' + | 'email'; + +export interface SharedPrivacyFinding { + kind: SharedPrivacyFindingKind; + start: number; + end: number; +} + +export interface SharedTextRedaction { + text: string; + redactions: Array<{ kind: SharedPrivacyFindingKind; count: number }>; +} + +const FINDING_PATTERNS: Array<{ + kind: SharedPrivacyFindingKind; + pattern: RegExp; +}> = [ + { + kind: 'private_key', + pattern: + /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/gi, + }, + { + kind: 'authorization', + pattern: + /\b(?:authorization|proxy-authorization)\s*:\s*(?:bearer|basic|token)\s+[^\s,;]+/gi, + }, + { + kind: 'cookie', + pattern: /\b(?:set-cookie|cookie)\s*:\s*[^\r\n]+/gi, + }, + { + kind: 'secret', + pattern: + /\b(?:api[_-]?key|secret|token|password|passwd|access[_-]?key)\s*[=:]\s*['"]?[^\s'";,]+/gi, + }, + { + kind: 'absolute_path', + pattern: + /(?:\b[A-Z]:\\|\/(?:Users|home|private|var\/folders|tmp|etc)\/)[^\s'"`]+/g, + }, + { + kind: 'email', + pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, + }, +]; + +export function scanSharedText(value: string): SharedPrivacyFinding[] { + assertText(value, 'shared text'); + const findings: SharedPrivacyFinding[] = []; + for (const { kind, pattern } of FINDING_PATTERNS) { + for (const match of value.matchAll(pattern)) { + if (match.index === undefined) continue; + findings.push({ + kind, + start: match.index, + end: match.index + match[0].length, + }); + } + } + return removeNestedFindings(findings); +} + +/** + * Applies only deterministic substitutions. The result carries categories and + * counts for provenance; it never preserves a raw sensitive value in metadata. + */ +export function redactSharedText(value: string): SharedTextRedaction { + assertText(value, 'shared text'); + const findings = scanSharedText(value); + if (findings.length === 0) return { text: value, redactions: [] }; + let redacted = ''; + let cursor = 0; + const counts = new Map(); + for (const finding of findings) { + redacted += value.slice(cursor, finding.start); + redacted += `[REDACTED:${finding.kind}]`; + cursor = finding.end; + counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1); + } + redacted += value.slice(cursor); + if (scanSharedText(redacted).length > 0) { + throw new Error('MANCODE_PRIVACY_BLOCKED'); + } + return { + text: redacted, + redactions: [...counts.entries()] + .sort(([left], [right]) => left.localeCompare(right, 'en')) + .map(([kind, count]) => ({ kind, count })), + }; +} + +export function assertSharedTextSafe(value: string, label: string): void { + assertText(value, label); + if (scanSharedText(value).length > 0) { + throw new Error(`MANCODE_PRIVACY_BLOCKED: ${label}`); + } +} + +/** Safe relative paths are the only custom report-path representation. */ +export function assertSafeSharedRelativePath(value: string): string { + if ( + typeof value !== 'string' || + !value || + value.includes('\0') || + path.isAbsolute(value) || + /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) + ) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const normalized = value.split(/[\\/]/); + if ( + normalized.some( + (segment) => segment === '' || segment === '.' || segment === '..', + ) + ) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return normalized.join('/'); +} + +function removeNestedFindings( + findings: SharedPrivacyFinding[], +): SharedPrivacyFinding[] { + const nonOverlapping: SharedPrivacyFinding[] = []; + let end = -1; + for (const finding of findings.sort( + (left, right) => left.start - right.start || right.end - left.end, + )) { + if (finding.start < end) continue; + nonOverlapping.push(finding); + end = finding.end; + } + return nonOverlapping; +} + +function assertText(value: unknown, label: string): asserts value is string { + if (typeof value !== 'string' || value.includes('\0')) { + throw new Error(`${label} must be a NUL-free string`); + } +} diff --git a/src/context/project-facts.ts b/src/context/project-facts.ts new file mode 100644 index 0000000..317f537 --- /dev/null +++ b/src/context/project-facts.ts @@ -0,0 +1,315 @@ +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + type ProjectKind, + type ProjectProfile, + primaryUiLibrary, +} from '../system/project-profile.js'; +import { type Ulid, assertUlid } from './ids.js'; +import { assertSharedTextSafe } from './privacy.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export interface ProjectFactsV1 { + schemaVersion: 1; + revision: number; + trust: 'detected'; + profile: ProjectProfile; + uiLibrary: string | null; + detectedAt: string; + lastOperationId: Ulid | null; +} + +const PROJECT_KINDS = new Set([ + 'backend', + 'web', + 'mobile', + 'desktop', + 'cli', + 'library', + 'data', + 'mixed', + 'unknown', +]); +const UI_ASSET_VALUES = new Set([ + 'none', + 'detected', +]); +const BROWSER_AUTOMATION_VALUES = new Set([ + 'available', + 'unavailable', + 'unknown', +]); +const CONFIDENCE_VALUES = new Set([ + 'high', + 'medium', + 'low', +]); + +export function createProjectFacts( + profile: ProjectProfile, + options: { + revision?: number; + now?: Date; + operationId?: Ulid | null; + } = {}, +): ProjectFactsV1 { + const facts: ProjectFactsV1 = { + schemaVersion: 1, + revision: options.revision ?? 1, + trust: 'detected', + profile: parseProjectProfile(profile), + uiLibrary: primaryUiLibrary(profile), + detectedAt: (options.now ?? new Date()).toISOString(), + lastOperationId: options.operationId ?? null, + }; + return parseProjectFacts(facts); +} + +export function unknownProjectFacts( + options: { + now?: Date; + operationId?: Ulid | null; + } = {}, +): ProjectFactsV1 { + return createProjectFacts( + { + version: '1.0', + projectKind: 'unknown', + languages: [], + frameworks: [], + sourceRoots: [], + manifests: [], + availableValidation: [], + uiAssets: 'none', + browserAutomation: 'unknown', + confidence: 'low', + }, + options, + ); +} + +export function parseProjectFacts(value: unknown): ProjectFactsV1 { + assertRecord(value, 'project facts'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'revision', + 'trust', + 'profile', + 'uiLibrary', + 'detectedAt', + 'lastOperationId', + ], + 'project facts', + ); + if (value.schemaVersion !== 1) { + throw new Error('project facts schemaVersion must be 1'); + } + const revision = value.revision; + if ( + typeof revision !== 'number' || + !Number.isSafeInteger(revision) || + revision < 1 + ) { + throw new Error('project facts revision must be a positive integer'); + } + if (value.trust !== 'detected') { + throw new Error('project facts trust must be detected'); + } + const uiLibrary = parseTextOrNull(value.uiLibrary, 'project facts uiLibrary'); + return { + schemaVersion: 1, + revision, + trust: 'detected', + profile: parseProjectProfile(value.profile), + uiLibrary, + detectedAt: parseTimestamp(value.detectedAt, 'project facts detectedAt'), + lastOperationId: parseUlidOrNull( + value.lastOperationId, + 'project facts lastOperationId', + ), + }; +} + +export function projectFactsPath(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'shared', + 'context', + 'project.json', + ); +} + +export async function readProjectFacts( + projectRoot: string, +): Promise { + try { + return parseProjectFacts( + JSON.parse(await readFile(projectFactsPath(projectRoot), 'utf8')), + ); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error( + 'MANCODE_CONTEXT_ENTITY_CORRUPT: shared/context/project.json', + ); + } + throw error; + } +} + +/** Facts are detected/rebuildable, so a refresh safely replaces the whole record. */ +export async function writeProjectFacts( + projectRoot: string, + facts: ProjectFactsV1, +): Promise { + const parsed = parseProjectFacts(facts); + const target = projectFactsPath(projectRoot); + await mkdir(path.dirname(target), { recursive: true }); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`, + ); + try { + await writeFile(temporary, `${JSON.stringify(parsed, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } + return parsed; +} + +function parseProjectProfile(value: unknown): ProjectProfile { + assertRecord(value, 'project facts profile'); + assertKnownKeys( + value, + [ + 'version', + 'projectKind', + 'languages', + 'frameworks', + 'sourceRoots', + 'manifests', + 'availableValidation', + 'uiAssets', + 'browserAutomation', + 'confidence', + ], + 'project facts profile', + ); + if (value.version !== '1.0') { + throw new Error('project facts profile version must be 1.0'); + } + if ( + typeof value.projectKind !== 'string' || + !PROJECT_KINDS.has(value.projectKind as ProjectKind) + ) { + throw new Error('project facts profile projectKind is invalid'); + } + if ( + typeof value.uiAssets !== 'string' || + !UI_ASSET_VALUES.has(value.uiAssets as ProjectProfile['uiAssets']) + ) { + throw new Error('project facts profile uiAssets is invalid'); + } + if ( + typeof value.browserAutomation !== 'string' || + !BROWSER_AUTOMATION_VALUES.has( + value.browserAutomation as ProjectProfile['browserAutomation'], + ) + ) { + throw new Error('project facts profile browserAutomation is invalid'); + } + if ( + typeof value.confidence !== 'string' || + !CONFIDENCE_VALUES.has(value.confidence as ProjectProfile['confidence']) + ) { + throw new Error('project facts profile confidence is invalid'); + } + return { + version: '1.0', + projectKind: value.projectKind as ProjectKind, + languages: parseTextArray( + value.languages, + 'project facts profile languages', + ), + frameworks: parseTextArray( + value.frameworks, + 'project facts profile frameworks', + ), + sourceRoots: parseSafeRelativePathArray( + value.sourceRoots, + 'project facts profile sourceRoots', + ), + manifests: parseSafeRelativePathArray( + value.manifests, + 'project facts profile manifests', + ), + availableValidation: parseTextArray( + value.availableValidation, + 'project facts profile availableValidation', + ), + uiAssets: value.uiAssets as ProjectProfile['uiAssets'], + browserAutomation: + value.browserAutomation as ProjectProfile['browserAutomation'], + confidence: value.confidence as ProjectProfile['confidence'], + }; +} + +function parseTextArray(value: unknown, label: string): string[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + return value.map((item) => parseText(item, label)); +} + +function parseSafeRelativePathArray(value: unknown, label: string): string[] { + return parseTextArray(value, label).map((item) => { + if ( + path.isAbsolute(item) || + item.includes('\\') || + item.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw new Error(`${label} must contain safe relative paths`); + } + return item; + }); +} + +function parseText(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error(`${label} must contain non-empty text`); + } + assertSharedTextSafe(value, label); + return value; +} + +function parseTextOrNull(value: unknown, label: string): string | null { + if (value === null) return null; + return parseText(value, label); +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function parseUlidOrNull(value: unknown, label: string): Ulid | null { + if (value === null) return null; + assertUlid(value, label); + return value; +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/context/publish-promote.ts b/src/context/publish-promote.ts new file mode 100644 index 0000000..6cd1d9c --- /dev/null +++ b/src/context/publish-promote.ts @@ -0,0 +1,1314 @@ +import { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + type EntityHomeStore, + resolveTaskEntityHomeStore, +} from '../runtime/entity-home-store.js'; +import { + armOperationCrashAfterVisibleWrite, + throwIfDeferredOperationCrashInjected, + throwIfOperationCrashInjected, +} from '../runtime/operation-crash-injection.js'; +import { + assertOperationJournalMatchesDefinition, + getOperationDefinition, +} from '../runtime/operation-definition.js'; +import { + type OperationJournalV1, + withOperationReservationDigests, +} from '../runtime/operation-journal.js'; +import { + assertOperationRecoveryPayloadCoversJournal, + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, + createWorkflowTaskDirectoryRecoveryAction, + operationRecoveryPayloadDigest, + parseOperationRecoveryPayload, +} from '../runtime/operation-recovery-payload.js'; +import { writeOperationRecoveryPayload } from '../runtime/operation-recovery-store.js'; +import { removeOperationReservation } from '../runtime/operation-reservation.js'; +import { + prepareOperationStores, + updateOperationJournal, +} from '../runtime/operation-store.js'; +import { + readCheckoutCodeHead, + readProjectRuntimeContext, +} from '../runtime/project-runtime.js'; +import { + type TaskHeadFenceV1, + parseTaskHeadFence, +} from '../runtime/task-head-fence.js'; +import { + createTaskHeadFence, + readTaskHeadFence, +} from '../runtime/task-head-store.js'; +import { + type OpenedV3TaskOperation, + openV3TaskOperation, + serializeTaskAuthority, + taskEntityKey, + taskHeadEntityKey, + writeTaskAuthorityFile, +} from '../runtime/task-operation.js'; +import { readSharedActorProfile } from '../team/actor.js'; +import { createAuthorizationBasis } from '../team/authorization.js'; +import { capabilitiesFromProjectConfig } from '../team/transport.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, + taskAggregateDigest, +} from './aggregate.js'; +import { digestCanonicalJson } from './canonical.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { assertSharedTextSafe } from './privacy.js'; +import { + type PromotionFromQuarantinePlanV1, + type QuarantineArtifact, + type QuarantineCandidateV1, + confirmQuarantineCandidate, + createQuarantineCandidate, + markQuarantinePromoted, + preparePromotionFromQuarantine, + previewQuarantineCandidate, + publishStagingDirectory, + scanQuarantineCandidate, + validateQuarantinePaths, +} from './quarantine.js'; +import { + type RequirementsLedgerV1, + parseRequirementsLedger, + requirementsLedgerDigest, +} from './requirements-ledger.js'; +import { + type ReviewLedgerV1, + parseReviewLedger, + reviewLedgerDigest, +} from './review-ledger.js'; +import { type StoredTaskSnapshot, V3ContextStore } from './store.js'; +import { taskRootPath } from './task-locator.js'; +import { assertTaskCodeHeadUnchanged } from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; +import { + type VerificationLedgerV1, + parseVerificationLedger, + verificationLedgerDigest, +} from './verification-ledger.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export type PromotionDestinationMode = 'man' | 'manteam'; + +export interface PromoteV3TaskInput { + projectRoot: string; + sourceTaskRef: TaskRef; + sessionId: Ulid; + expectedSourceRevision: number; + destinationWorkflowMode: PromotionDestinationMode; + /** Explicit acknowledgement that the promoted authority becomes shared. */ + sharedPrivacyConfirmed: boolean; + client: string; + destinationTaskId?: Ulid; + operationId?: Ulid; + now?: Date; +} + +export interface PromotedV3Task { + sourceMetadata: WorkflowMetadataV3; + destinationMetadata: WorkflowMetadataV3; + destinationRequirements: RequirementsLedgerV1; + destinationReview: ReviewLedgerV1; + destinationVerification: VerificationLedgerV1; + destinationAggregate: TaskAggregateManifestV1; + destinationTaskHead: TaskHeadFenceV1; + quarantine: QuarantineCandidateV1; + promotion: PromotionFromQuarantinePlanV1; + operation: OperationJournalV1; +} + +export interface PreviewV3TaskPromotionInput { + projectRoot: string; + sourceTaskRef: TaskRef; + sessionActorId: Ulid; + expectedSourceRevision: number; + destinationWorkflowMode: PromotionDestinationMode; + client: string; + now?: Date; +} + +export interface PreviewedV3TaskPromotion { + sourceMetadata: WorkflowMetadataV3; + destination: { + workflowMode: PromotionDestinationMode; + visibility: 'shared'; + coordination: 'single' | 'team'; + }; + quarantine: QuarantineCandidateV1; +} + +interface PromotionEntities { + sourcePending: WorkflowMetadataV3; + sourceSuperseded: WorkflowMetadataV3; + destinationMetadata: WorkflowMetadataV3; + destinationRequirements: RequirementsLedgerV1; + destinationReview: ReviewLedgerV1; + destinationVerification: VerificationLedgerV1; + destinationPlan: string | null; + destinationAggregate: TaskAggregateManifestV1; + destinationTaskHead: TaskHeadFenceV1; +} + +const INITIAL_WORKFLOW_FILES = [ + 'metadata.json', + 'requirements.json', + 'review-ledger.json', + 'verification-ledger.json', +] as const; + +/** + * Produces a local-only privacy and eligibility preview. Promotion repeats + * every check under its durable operation lock before writing authority. + */ +export async function previewV3TaskPromotion( + input: PreviewV3TaskPromotionInput, +): Promise { + const sourceTaskRef = parseTaskRefValue(input.sourceTaskRef); + if (sourceTaskRef.namespace !== 'local') { + throw new Error('MANCODE_PROMOTION_SOURCE_MUST_BE_LOCAL'); + } + if (!Number.isSafeInteger(input.expectedSourceRevision)) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + assertUlid(input.sessionActorId, 'promotion preview sessionActorId'); + if (typeof input.client !== 'string' || !input.client.trim()) { + throw new Error('MANCODE_CLIENT_INVALID'); + } + if ( + input.destinationWorkflowMode !== 'man' && + input.destinationWorkflowMode !== 'manteam' + ) { + throw new Error('MANCODE_PROMOTION_DESTINATION_MODE_INVALID'); + } + const now = input.now ?? new Date(); + const store = new V3ContextStore(input.projectRoot); + const task = await store.readTaskSnapshot(sourceTaskRef); + const metadata = task.metadata; + if (metadata.revision !== input.expectedSourceRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + if ( + metadata.workflowMode !== 'man' || + metadata.visibility !== 'local' || + metadata.coordination !== 'single' + ) { + throw new Error('MANCODE_PROMOTION_SOURCE_COMBINATION_INVALID'); + } + if ( + metadata.status !== 'in_progress' && + metadata.status !== 'planned' && + metadata.status !== 'blocked' + ) { + throw new Error('MANCODE_PROMOTION_SOURCE_NOT_ACTIVE'); + } + if (metadata.ownerActorId !== input.sessionActorId) { + throw new Error('MANCODE_TASK_OWNER_REQUIRED'); + } + if ( + metadata.governance.planDecision === 'solo_handoff' || + metadata.soloExecution !== null + ) { + throw new Error('MANCODE_PROMOTION_SOLO_HANDOFF_UNSUPPORTED'); + } + if ((await store.listActiveChildTaskRefs(sourceTaskRef)).length > 0) { + throw new Error('MANCODE_PROMOTION_ACTIVE_CHILDREN'); + } + if ( + (await readSharedActorProfile(input.projectRoot, input.sessionActorId)) === + null + ) { + throw new Error('MANCODE_JOIN_REQUIRED'); + } + assertPromotableReferencesForPreview(task); + const contents = promotionPreviewContents(task); + let quarantine = createQuarantineCandidate({ + quarantineId: createUlid(now.getTime()), + purpose: 'publish_promote', + sourceTaskRef, + candidateTaskRef: sourceTaskRef, + artifacts: Object.entries(contents).map(([relativePath, content]) => ({ + relativePath, + classification: relativePath === 'plan.md' ? 'human_view' : 'authority', + includeInPromotion: true, + contentDigest: digestCanonicalJson({ content }), + })), + now, + }); + quarantine = validateQuarantinePaths(quarantine, now); + quarantine = scanQuarantineCandidate( + quarantine, + Object.values(contents), + now, + ); + quarantine = previewQuarantineCandidate(quarantine, now); + return { + sourceMetadata: metadata, + destination: { + workflowMode: input.destinationWorkflowMode, + visibility: 'shared', + coordination: + input.destinationWorkflowMode === 'manteam' ? 'team' : 'single', + }, + quarantine, + }; +} + +/** + * Creates a new shared successor for an active local `man` workflow. The + * source is never rewritten in place: a privacy-screened destination is + * published under a multi-store journal, then the local source becomes an + * immutable superseded predecessor. + */ +export async function promoteV3Task( + input: PromoteV3TaskInput, +): Promise { + const sourceTaskRef = parseTaskRefValue(input.sourceTaskRef); + if (sourceTaskRef.namespace !== 'local') { + throw new Error('MANCODE_PROMOTION_SOURCE_MUST_BE_LOCAL'); + } + if (!Number.isSafeInteger(input.expectedSourceRevision)) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + if (input.sharedPrivacyConfirmed !== true) { + throw new Error('MANCODE_PRIVACY_CONFIRMATION_REQUIRED'); + } + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + const destinationTaskId = + input.destinationTaskId ?? createUlid(now.getTime()); + assertUlid(operationId, 'publish/promote operationId'); + assertUlid(destinationTaskId, 'publish/promote destinationTaskId'); + if (operationId === destinationTaskId) { + throw new Error('MANCODE_PROMOTION_ID_CONFLICT'); + } + if (typeof input.client !== 'string' || !input.client.trim()) { + throw new Error('MANCODE_CLIENT_INVALID'); + } + const destinationTaskRef: TaskRef = { + namespace: 'shared', + taskId: destinationTaskId, + }; + const preflightRuntime = await readProjectRuntimeContext(input.projectRoot); + const destinationHomeStore = resolveTaskEntityHomeStore( + preflightRuntime.entityHomeStoreContext, + destinationTaskRef, + ); + const sourceContext = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef: sourceTaskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedSourceRevision, + operationId, + additionalEntityLockTargets: [ + { + store: destinationHomeStore, + entityLockKeys: destinationEntityLockKeys(destinationTaskRef), + }, + ], + now, + }); + let journal: OperationJournalV1 | null = null; + let stagingDirectory: string | null = null; + let quarantine: QuarantineCandidateV1 | null = null; + try { + await assertDestinationAbsent( + sourceContext.projectRoot, + destinationHomeStore, + destinationTaskRef, + ); + const destinationCoordination = + await sourceContext.store.readCoordinationSnapshot( + destinationTaskRef, + destinationHomeStore, + ); + if (destinationCoordination.pendingOperations.length > 0) { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } + await assertPromotionEligible(sourceContext, input.destinationWorkflowMode); + if ( + (await readSharedActorProfile( + sourceContext.projectRoot, + sourceContext.session.actorId, + )) === null + ) { + throw new Error('MANCODE_JOIN_REQUIRED'); + } + const codeHead = await readCheckoutCodeHead(sourceContext.projectRoot); + if (codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + } + const timestamp = sourceContext.now.toISOString(); + const entities = buildPromotionEntities({ + source: sourceContext, + destinationTaskRef, + destinationWorkflowMode: input.destinationWorkflowMode, + operationId, + client: input.client.trim(), + codeHead, + timestamp, + }); + const stagedQuarantine = prepareQuarantine({ + sourceTaskRef, + destinationTaskRef, + operationId, + actorId: sourceContext.session.actorId, + entities, + now: sourceContext.now, + }); + quarantine = stagedQuarantine.candidate; + await writePromotionQuarantine( + sourceContext.projectRoot, + operationId, + quarantine, + destinationAuthorityContents(entities), + ); + + const recoveryPayload = buildPromotionRecoveryPayload({ + operationId, + primaryStoreId: destinationHomeStore.storeId, + source: sourceContext, + destinationTaskRef, + entities, + }); + const prepared = buildPromotionJournal({ + source: sourceContext, + destinationHomeStore, + destinationTaskRef, + operationId, + timestamp, + recoveryPayloadDigest: operationRecoveryPayloadDigest(recoveryPayload), + }); + assertOperationRecoveryPayloadCoversJournal(prepared, recoveryPayload); + await writeOperationRecoveryPayload(destinationHomeStore, recoveryPayload); + // The shared destination owns the primary journal. Before the local + // reservation is durable no business authority has changed, so recovery + // can safely abort this prepared operation. + journal = prepared; + await prepareOperationStores({ + primaryStore: destinationHomeStore, + journal, + secondaryStores: [sourceContext.homeStore], + now: sourceContext.now, + }); + throwIfOperationCrashInjected('publish_promote', 'prepared'); + + journal = await advancePromotionOperation( + sourceContext, + destinationHomeStore, + journal, + 'validate-privacy-and-paths', + true, + ); + journal = await advancePromotionOperation( + sourceContext, + destinationHomeStore, + journal, + 'stage-destination', + true, + ); + stagingDirectory = await stageDestinationAuthority( + sourceContext.projectRoot, + destinationTaskRef, + operationId, + destinationAuthorityContents(entities), + ); + + journal = await advancePromotionOperation( + sourceContext, + destinationHomeStore, + journal, + 'mark-source-operation-pending', + false, + ); + await writeTaskAuthorityFile( + sourceContext, + 'metadata.json', + serializeTaskAuthority(entities.sourcePending), + ); + + journal = await advancePromotionOperation( + sourceContext, + destinationHomeStore, + journal, + 'publish-destination', + false, + ); + await publishStagedDestination( + sourceContext.projectRoot, + destinationTaskRef, + stagingDirectory, + ); + stagingDirectory = null; + + journal = await advancePromotionOperation( + sourceContext, + destinationHomeStore, + journal, + 'write-source-successor', + false, + ); + await writeTaskAuthorityFile( + sourceContext, + 'metadata.json', + serializeTaskAuthority(entities.sourceSuperseded), + ); + + // V3 uses the shared task directory itself as the task locator. There is + // no second mutable locator entity to publish between the directory and + // its task-head fence. + journal = await advancePromotionOperation( + sourceContext, + destinationHomeStore, + journal, + 'publish-destination-locator', + false, + ); + journal = await advancePromotionOperation( + sourceContext, + destinationHomeStore, + journal, + 'update-task-head-fence', + false, + ); + await assertTaskCodeHeadUnchanged(sourceContext.projectRoot, codeHead); + await createTaskHeadFence( + destinationHomeStore, + entities.destinationTaskHead, + ); + const operation = await commitPromotionOperation( + sourceContext, + destinationHomeStore, + journal, + ); + const promotedQuarantine = markQuarantinePromoted( + quarantine, + stagedQuarantine.promotion, + sourceContext.now, + ); + try { + await rewritePromotionQuarantine( + sourceContext.projectRoot, + operationId, + promotedQuarantine, + ); + } catch { + // The promotion's authority commit is complete. The quarantine manifest + // is a local audit projection and must not roll the durable operation + // back to a repair state. + } + return { + sourceMetadata: entities.sourceSuperseded, + destinationMetadata: entities.destinationMetadata, + destinationRequirements: entities.destinationRequirements, + destinationReview: entities.destinationReview, + destinationVerification: entities.destinationVerification, + destinationAggregate: entities.destinationAggregate, + destinationTaskHead: entities.destinationTaskHead, + quarantine: promotedQuarantine, + promotion: stagedQuarantine.promotion, + operation, + }; + } catch (error) { + if (journal !== null) { + await handlePromotionFailure( + sourceContext, + destinationHomeStore, + journal, + stagingDirectory, + ); + } + throw error; + } finally { + await sourceContext.release(); + } +} + +function buildPromotionEntities(input: { + source: OpenedV3TaskOperation; + destinationTaskRef: TaskRef; + destinationWorkflowMode: PromotionDestinationMode; + operationId: Ulid; + client: string; + codeHead: string; + timestamp: string; +}): PromotionEntities { + assertPromotableReferences(input.source); + const requirementsDraft: RequirementsLedgerV1 = { + ...input.source.task.requirements, + taskRef: input.destinationTaskRef, + revision: 1, + contentDigest: '', + lastOperationId: input.operationId, + updatedAt: input.timestamp, + }; + const destinationRequirements = parseRequirementsLedger({ + ...requirementsDraft, + contentDigest: requirementsLedgerDigest(requirementsDraft), + }); + const reviewDraft: ReviewLedgerV1 = { + ...input.source.task.review, + taskRef: input.destinationTaskRef, + revision: 1, + requirementsDigest: destinationRequirements.contentDigest, + contentDigest: '', + lastOperationId: input.operationId, + updatedAt: input.timestamp, + }; + const destinationReview = parseReviewLedger({ + ...reviewDraft, + contentDigest: reviewLedgerDigest(reviewDraft), + }); + const verificationDraft: VerificationLedgerV1 = { + ...input.source.task.verification, + taskRef: input.destinationTaskRef, + revision: 1, + requirementsDigest: destinationRequirements.contentDigest, + contentDigest: '', + lastOperationId: input.operationId, + updatedAt: input.timestamp, + }; + const destinationVerification = parseVerificationLedger( + { + ...verificationDraft, + contentDigest: verificationLedgerDigest(verificationDraft), + }, + destinationRequirements, + ); + const destinationPlan = input.source.task.plan?.content ?? null; + if (destinationPlan !== null) { + assertSharedPromotionText(destinationPlan, 'plan'); + } + const destinationMetadata = parseWorkflowMetadata({ + ...input.source.task.metadata, + taskRef: input.destinationTaskRef, + workflowMode: input.destinationWorkflowMode, + visibility: 'shared', + coordination: + input.destinationWorkflowMode === 'manteam' ? 'team' : 'single', + revision: 1, + transitionState: 'stable', + lastOperationId: input.operationId, + ownerActorId: input.source.session.actorId, + ownershipEpoch: 1, + participants: [input.source.session.actorId], + createdBy: { + actorId: input.source.session.actorId, + client: input.client, + source: 'actor', + }, + governance: { + ...input.source.task.metadata.governance, + requirementsDigest: destinationRequirements.contentDigest, + reviewStatus: destinationReview.status, + reviewLedgerDigest: destinationReview.contentDigest, + verificationStatus: destinationVerification.status, + verificationLedgerDigest: destinationVerification.contentDigest, + }, + soloExecution: null, + latestCheckpointRef: null, + parent: null, + successorTaskRef: null, + legacyCompatibility: null, + startedAt: input.timestamp, + updatedAt: input.timestamp, + }); + const planDigest = + destinationPlan === null + ? null + : digestCanonicalJson({ + artifactRef: { taskRef: input.destinationTaskRef, kind: 'plan' }, + content: destinationPlan, + }); + const destinationAggregate = buildTaskAggregateManifest({ + metadata: destinationMetadata, + requirements: destinationRequirements, + review: destinationReview, + verification: destinationVerification, + planDigest, + latestCheckpoint: null, + }); + const destinationTaskHead = parseTaskHeadFence({ + schemaVersion: 1, + workspaceId: input.source.runtime.workspaceId, + taskRef: input.destinationTaskRef, + fenceRevision: 1, + taskRevision: destinationMetadata.revision, + aggregateDigest: taskAggregateDigest(destinationAggregate), + ownershipEpoch: destinationMetadata.ownershipEpoch, + codeRef: { head: input.codeHead }, + checkoutId: input.source.runtime.checkoutId, + remoteRevision: null, + lastOperationId: input.operationId, + updatedAt: input.timestamp, + }); + const sourcePending = parseWorkflowMetadata({ + ...input.source.task.metadata, + revision: input.source.task.metadata.revision + 1, + transitionState: 'operation_pending', + lastOperationId: input.operationId, + updatedAt: input.timestamp, + }); + assertWorkflowMetadataTransition( + input.source.task.metadata, + sourcePending, + 'ordinary', + ); + const sourceSuperseded = parseWorkflowMetadata({ + ...sourcePending, + status: 'superseded', + revision: sourcePending.revision + 1, + transitionState: 'stable', + lastOperationId: input.operationId, + successorTaskRef: input.destinationTaskRef, + updatedAt: input.timestamp, + }); + assertWorkflowMetadataTransition( + sourcePending, + sourceSuperseded, + input.destinationWorkflowMode === 'man' ? 'publish' : 'promote', + ); + return { + sourcePending, + sourceSuperseded, + destinationMetadata, + destinationRequirements, + destinationReview, + destinationVerification, + destinationPlan, + destinationAggregate, + destinationTaskHead, + }; +} + +function buildPromotionJournal(input: { + source: OpenedV3TaskOperation; + destinationHomeStore: EntityHomeStore; + destinationTaskRef: TaskRef; + operationId: Ulid; + timestamp: string; + recoveryPayloadDigest: string; +}): OperationJournalV1 { + const destinationLockKeys = destinationEntityLockKeys( + input.destinationTaskRef, + ); + const authorizationBasis = createAuthorizationBasis( + { + action: 'shared_create_publish_promote', + actorId: input.source.session.actorId, + session: { + sessionId: input.source.session.sessionId, + actorId: input.source.session.actorId, + status: input.source.session.status, + }, + joined: true, + sharedWriteGuard: capabilitiesFromProjectConfig( + input.source.project.config, + ).writeGuard, + task: { + ownerActorId: input.source.task.metadata.ownerActorId, + participantActorIds: input.source.task.metadata.participants, + }, + claim: null, + handoff: null, + evidence: null, + profileActorId: null, + conditions: { + expectedRevisionMatches: true, + privacyConfirmed: true, + }, + }, + input.source.now, + ); + const definition = getOperationDefinition('publish_promote'); + const journal: OperationJournalV1 = { + schemaVersion: 1, + operationId: input.operationId, + type: 'publish_promote', + state: 'prepared', + primaryStoreId: input.destinationHomeStore.storeId, + checkoutId: input.source.runtime.checkoutId, + secondaryReservations: [ + { + storeId: input.source.homeStore.storeId, + entityKeys: input.source.entityLocks, + journalDigest: '', + }, + ], + actorId: input.source.session.actorId, + sessionId: input.source.session.sessionId, + authorizationBasis, + recoveryPayloadDigest: input.recoveryPayloadDigest, + entityLocks: uniqueLockKeys([ + ...input.source.entityLocks, + ...destinationLockKeys, + ]), + expectedRevisions: { + [taskEntityKey(input.source.taskRef)]: + input.source.task.metadata.revision, + [taskEntityKey(input.destinationTaskRef)]: 0, + [`locator:shared:${input.destinationTaskRef.taskId}`]: 0, + [taskHeadEntityKey(input.destinationTaskRef)]: 0, + }, + steps: definition.steps.map((step) => ({ id: step.id, state: 'pending' })), + startedAt: input.timestamp, + updatedAt: input.timestamp, + }; + const bound = withOperationReservationDigests(journal); + assertOperationJournalMatchesDefinition(bound); + return bound; +} + +function buildPromotionRecoveryPayload(input: { + operationId: Ulid; + primaryStoreId: string; + source: OpenedV3TaskOperation; + destinationTaskRef: TaskRef; + entities: PromotionEntities; +}) { + const files = destinationAuthorityContents(input.entities); + return parseOperationRecoveryPayload({ + schemaVersion: 1, + operationId: input.operationId, + type: 'publish_promote', + primaryStoreId: input.primaryStoreId, + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-source-operation-pending', + taskRef: input.source.taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(input.source.task.metadata), + targetContent: serializeTaskAuthority(input.entities.sourcePending), + }), + createWorkflowTaskDirectoryRecoveryAction({ + stepId: 'publish-destination', + taskRef: input.destinationTaskRef, + files: [ + ...INITIAL_WORKFLOW_FILES.map((fileName) => ({ + fileName, + content: files[fileName], + })), + ...(input.entities.destinationPlan === null + ? [] + : [ + { + fileName: 'plan.md' as const, + content: input.entities.destinationPlan, + }, + ]), + ], + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'write-source-successor', + taskRef: input.source.taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(input.entities.sourcePending), + targetContent: serializeTaskAuthority(input.entities.sourceSuperseded), + }), + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: null, + fence: input.entities.destinationTaskHead, + }), + ], + noOpStepIds: ['publish-destination-locator'], + }); +} + +function prepareQuarantine(input: { + sourceTaskRef: TaskRef; + destinationTaskRef: TaskRef; + operationId: Ulid; + actorId: Ulid; + entities: PromotionEntities; + now: Date; +}): { + candidate: QuarantineCandidateV1; + promotion: PromotionFromQuarantinePlanV1; +} { + const contents = destinationAuthorityContents(input.entities); + const artifacts: QuarantineArtifact[] = Object.entries(contents).map( + ([relativePath, content]) => ({ + relativePath, + classification: relativePath === 'plan.md' ? 'human_view' : 'authority', + includeInPromotion: true, + contentDigest: digestCanonicalJson({ content }), + }), + ); + let candidate = createQuarantineCandidate({ + quarantineId: input.operationId, + purpose: 'publish_promote', + sourceTaskRef: input.sourceTaskRef, + candidateTaskRef: input.sourceTaskRef, + artifacts, + now: input.now, + }); + candidate = validateQuarantinePaths(candidate, input.now); + candidate = scanQuarantineCandidate( + candidate, + Object.values(contents), + input.now, + ); + candidate = previewQuarantineCandidate(candidate, input.now); + candidate = confirmQuarantineCandidate(candidate, input.actorId, input.now); + const promotion = preparePromotionFromQuarantine( + candidate, + input.destinationTaskRef, + input.operationId, + ); + return { candidate, promotion }; +} + +function destinationAuthorityContents( + entities: PromotionEntities, +): Record<(typeof INITIAL_WORKFLOW_FILES)[number] | 'plan.md', string> { + const contents: Record = { + 'metadata.json': serializeTaskAuthority(entities.destinationMetadata), + 'requirements.json': serializeTaskAuthority( + entities.destinationRequirements, + ), + 'review-ledger.json': serializeTaskAuthority(entities.destinationReview), + 'verification-ledger.json': serializeTaskAuthority( + entities.destinationVerification, + ), + }; + if (entities.destinationPlan !== null) { + contents['plan.md'] = entities.destinationPlan; + } + return contents as Record< + (typeof INITIAL_WORKFLOW_FILES)[number] | 'plan.md', + string + >; +} + +async function stageDestinationAuthority( + projectRoot: string, + destinationTaskRef: TaskRef, + operationId: Ulid, + contents: Record, +): Promise { + const target = taskRootPath(projectRoot, destinationTaskRef); + const parent = await ensureSafeTaskParent(projectRoot, destinationTaskRef); + await assertPathAbsent(target, 'MANCODE_PROMOTION_DESTINATION_EXISTS'); + const staging = path.join( + parent, + `.${destinationTaskRef.taskId}.${operationId}.staging`, + ); + await assertPathAbsent(staging, 'MANCODE_PROMOTION_STAGING_CONFLICT'); + await mkdir(staging); + try { + for (const [fileName, content] of Object.entries(contents)) { + await writeFile(path.join(staging, fileName), content, { + encoding: 'utf8', + flag: 'wx', + }); + } + await assertSafeDirectory(staging); + return staging; + } catch (error) { + await rm(staging, { recursive: true, force: true }); + throw error; + } +} + +async function ensureSafeTaskParent( + projectRoot: string, + taskRef: TaskRef, +): Promise { + await assertSafeDirectory(projectRoot); + let current = projectRoot; + for (const segment of ['.mancode', taskRef.namespace, 'workflows']) { + current = path.join(current, segment); + const existing = await lstatOrNull(current); + if (existing === null) { + try { + await mkdir(current); + continue; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + } + const checked = existing ?? (await lstat(current)); + if (!checked.isDirectory() || checked.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } + return current; +} + +async function publishStagedDestination( + projectRoot: string, + destinationTaskRef: TaskRef, + staging: string, +): Promise { + const target = taskRootPath(projectRoot, destinationTaskRef); + await assertSafeDirectory(staging); + await assertPathAbsent(target, 'MANCODE_PROMOTION_DESTINATION_EXISTS'); + await rename(staging, target); + await assertSafeDirectory(target); +} + +async function assertDestinationAbsent( + projectRoot: string, + destinationHomeStore: EntityHomeStore, + destinationTaskRef: TaskRef, +): Promise { + await assertPathAbsent( + taskRootPath(projectRoot, destinationTaskRef), + 'MANCODE_PROMOTION_DESTINATION_EXISTS', + ); + if ( + (await readTaskHeadFence(destinationHomeStore, destinationTaskRef)) !== null + ) { + throw new Error('MANCODE_PROMOTION_DESTINATION_EXISTS'); + } +} + +async function assertPromotionEligible( + context: OpenedV3TaskOperation, + destinationWorkflowMode: PromotionDestinationMode, +): Promise { + const metadata = context.task.metadata; + if ( + metadata.workflowMode !== 'man' || + metadata.visibility !== 'local' || + metadata.coordination !== 'single' + ) { + throw new Error('MANCODE_PROMOTION_SOURCE_COMBINATION_INVALID'); + } + if ( + metadata.status !== 'in_progress' && + metadata.status !== 'planned' && + metadata.status !== 'blocked' + ) { + throw new Error('MANCODE_PROMOTION_SOURCE_NOT_ACTIVE'); + } + if (metadata.ownerActorId !== context.session.actorId) { + throw new Error('MANCODE_TASK_OWNER_REQUIRED'); + } + if ( + metadata.governance.planDecision === 'solo_handoff' || + metadata.soloExecution !== null + ) { + throw new Error('MANCODE_PROMOTION_SOLO_HANDOFF_UNSUPPORTED'); + } + const activeChildren = await context.store.listActiveChildTaskRefs( + context.taskRef, + ); + if (activeChildren.length > 0) { + throw new Error('MANCODE_PROMOTION_ACTIVE_CHILDREN'); + } + if ( + destinationWorkflowMode !== 'man' && + destinationWorkflowMode !== 'manteam' + ) { + throw new Error('MANCODE_PROMOTION_DESTINATION_MODE_INVALID'); + } +} + +function assertPromotableReferences(context: OpenedV3TaskOperation): void { + if ( + context.task.review.domains.some((domain) => domain.reportRef !== null) || + context.task.verification.checks.some( + (check) => + check.automated?.artifactRef !== null || + check.manual?.artifactRef !== null, + ) + ) { + throw new Error('MANCODE_RAW_ARTIFACT_CANNOT_BE_PROMOTED'); + } +} + +function assertPromotableReferencesForPreview(task: StoredTaskSnapshot): void { + if ( + task.review.domains.some((domain) => domain.reportRef !== null) || + task.verification.checks.some( + (check) => + check.automated?.artifactRef !== null || + check.manual?.artifactRef !== null, + ) + ) { + throw new Error('MANCODE_RAW_ARTIFACT_CANNOT_BE_PROMOTED'); + } +} + +function promotionPreviewContents( + task: StoredTaskSnapshot, +): Record<(typeof INITIAL_WORKFLOW_FILES)[number] | 'plan.md', string> { + const contents: Record = { + 'metadata.json': serializeTaskAuthority(task.metadata), + 'requirements.json': serializeTaskAuthority(task.requirements), + 'review-ledger.json': serializeTaskAuthority(task.review), + 'verification-ledger.json': serializeTaskAuthority(task.verification), + }; + if (task.plan !== null) { + assertSharedPromotionText(task.plan.content, 'plan'); + contents['plan.md'] = task.plan.content; + } + return contents as Record< + (typeof INITIAL_WORKFLOW_FILES)[number] | 'plan.md', + string + >; +} + +function assertSharedPromotionText(value: string, label: string): void { + if (!value.trim() || value.includes('\0')) { + throw new Error(`MANCODE_PROMOTION_${label.toUpperCase()}_INVALID`); + } + assertSharedTextSafe(value, `promotion ${label}`); +} + +function destinationEntityLockKeys(taskRef: TaskRef): string[] { + return [ + taskEntityKey(taskRef), + `locator:shared:${taskRef.taskId}`, + taskHeadEntityKey(taskRef), + ]; +} + +async function advancePromotionOperation( + source: OpenedV3TaskOperation, + primaryStore: EntityHomeStore, + previous: OperationJournalV1, + stepId: string, + canAbort: boolean, +): Promise { + throwIfDeferredOperationCrashInjected(previous.type); + await renewPromotionLocks(source); + const advanced = await updateOperationJournal( + primaryStore, + { + ...previous, + state: 'applying', + steps: completeOperationStep(previous.steps, stepId), + updatedAt: source.now.toISOString(), + }, + { canAbort }, + ); + injectAfterPromotionStep(previous.type, stepId); + return advanced; +} + +async function commitPromotionOperation( + source: OpenedV3TaskOperation, + primaryStore: EntityHomeStore, + previous: OperationJournalV1, +): Promise { + throwIfDeferredOperationCrashInjected(previous.type); + await renewPromotionLocks(source); + const committed = await updateOperationJournal( + primaryStore, + { + ...previous, + state: 'committed', + steps: completeOperationStep(previous.steps, 'commit'), + updatedAt: source.now.toISOString(), + }, + { canAbort: false }, + ); + throwIfOperationCrashInjected(previous.type, 'commit'); + return committed; +} + +function injectAfterPromotionStep( + operationType: OperationJournalV1['type'], + stepId: string, +): void { + const step = getOperationDefinition(operationType).steps.find( + (candidate) => candidate.id === stepId, + ); + if (step?.visibility === 'business_write') { + armOperationCrashAfterVisibleWrite(operationType, stepId); + return; + } + throwIfOperationCrashInjected(operationType, stepId); +} + +async function handlePromotionFailure( + source: OpenedV3TaskOperation, + primaryStore: EntityHomeStore, + journal: OperationJournalV1, + stagingDirectory: string | null, +): Promise { + if (journal.state === 'committed' || journal.state === 'aborted') return; + if (hasBusinessWriteIntent(journal)) { + try { + await renewPromotionLocks(source); + await updateOperationJournal( + primaryStore, + { + ...journal, + state: 'repair_required', + updatedAt: source.now.toISOString(), + }, + { canAbort: false }, + ); + } catch { + // The durable business write intent remains a resolver-visible blocker. + } + return; + } + try { + await renewPromotionLocks(source); + const aborted = await updateOperationJournal( + primaryStore, + { + ...journal, + state: 'aborted', + updatedAt: source.now.toISOString(), + }, + { canAbort: true }, + ); + await removeOperationReservation( + source.homeStore, + aborted.operationId, + aborted.primaryStoreId, + ); + } catch { + // Preserve the command's original failure. The prepared journal is still + // available to the doctor if compensation itself could not finish. + } finally { + if (stagingDirectory !== null) { + await rm(stagingDirectory, { recursive: true, force: true }); + } + } +} + +async function renewPromotionLocks( + source: OpenedV3TaskOperation, +): Promise { + await source.renewLocks(); +} + +async function writePromotionQuarantine( + projectRoot: string, + operationId: Ulid, + candidate: QuarantineCandidateV1, + contents: Record, +): Promise { + const directory = publishStagingDirectory(projectRoot, operationId); + const parent = path.dirname(directory); + await mkdir(parent, { recursive: true }); + await assertSafeDirectory(parent); + await assertPathAbsent(directory, 'MANCODE_PROMOTION_QUARANTINE_CONFLICT'); + await mkdir(directory); + try { + await writeFile( + path.join(directory, 'candidate.json'), + serializeTaskAuthority(candidate), + { encoding: 'utf8', flag: 'wx' }, + ); + for (const [fileName, content] of Object.entries(contents)) { + await writeFile(path.join(directory, fileName), content, { + encoding: 'utf8', + flag: 'wx', + }); + } + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } +} + +async function rewritePromotionQuarantine( + projectRoot: string, + operationId: Ulid, + candidate: QuarantineCandidateV1, +): Promise { + const directory = publishStagingDirectory(projectRoot, operationId); + await assertSafeDirectory(directory); + const target = path.join(directory, 'candidate.json'); + const temporary = path.join( + directory, + `.candidate.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, serializeTaskAuthority(candidate), { + encoding: 'utf8', + flag: 'wx', + }); + await rename(temporary, target); +} + +function completeOperationStep( + steps: OperationJournalV1['steps'], + stepId: string, +): OperationJournalV1['steps'] { + const index = steps.findIndex((step) => step.id === stepId); + if (index < 0) throw new Error('MANCODE_OPERATION_STEP_INVALID'); + if (steps[index]?.state === 'completed') { + throw new Error('MANCODE_OPERATION_STEP_ALREADY_COMPLETED'); + } + if (steps.slice(0, index).some((step) => step.state !== 'completed')) { + throw new Error('MANCODE_OPERATION_STEP_ORDER_INVALID'); + } + return steps.map((step, currentIndex) => + currentIndex === index ? { ...step, state: 'completed' as const } : step, + ); +} + +function hasBusinessWriteIntent(journal: OperationJournalV1): boolean { + const definition = getOperationDefinition(journal.type); + return journal.steps.some( + (step, index) => + step.state === 'completed' && + definition.steps[index]?.visibility === 'business_write', + ); +} + +function uniqueLockKeys(keys: string[]): string[] { + return [...new Set(keys)].sort((left, right) => + Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')), + ); +} + +async function assertPathAbsent(target: string, code: string): Promise { + const entry = await lstatOrNull(target); + if (entry === null) return; + if (entry.isSymbolicLink()) throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + throw new Error(code); +} + +async function assertSafeDirectory(target: string): Promise { + const entry = await lstat(target); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } +} + +async function lstatOrNull(target: string) { + try { + return await lstat(target); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} diff --git a/src/context/quarantine.ts b/src/context/quarantine.ts new file mode 100644 index 0000000..590ed9d --- /dev/null +++ b/src/context/quarantine.ts @@ -0,0 +1,646 @@ +import path from 'node:path'; +import { digestCanonicalJson } from './canonical.js'; +import { type Ulid, assertUlid } from './ids.js'; +import { + type SharedPrivacyFindingKind, + assertSafeSharedRelativePath, + scanSharedText, +} from './privacy.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export type QuarantinePurpose = + | 'legacy_migration' + | 'publish_promote' + | 'transport_pull'; +export type QuarantineStage = + | 'staged' + | 'path_validated' + | 'privacy_scanned' + | 'privacy_blocked' + | 'previewed' + | 'confirmed' + | 'promoted'; +export type QuarantineArtifactClass = + | 'authority' + | 'human_view' + | 'raw_evidence' + | 'session' + | 'overlay'; + +export interface QuarantineArtifact { + relativePath: string; + classification: QuarantineArtifactClass; + includeInPromotion: boolean; + contentDigest: string | null; +} + +export interface QuarantinePrivacySummary { + status: 'pending' | 'passed' | 'blocked'; + findings: Array<{ kind: SharedPrivacyFindingKind; count: number }>; +} + +/** + * A local-only candidate. It never has a shared destination TaskRef, so a + * migration or promote flow cannot accidentally skip privacy preflight. + */ +export interface QuarantineCandidateV1 { + schemaVersion: 1; + quarantineId: Ulid; + purpose: QuarantinePurpose; + sourceTaskRef: TaskRef | null; + candidateTaskRef: TaskRef; + stage: QuarantineStage; + artifacts: QuarantineArtifact[]; + privacy: QuarantinePrivacySummary; + previewDigest: string | null; + confirmedByActorId: Ulid | null; + confirmedAt: string | null; + promotionOperationId: Ulid | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateQuarantineCandidateInput { + quarantineId: Ulid; + purpose: QuarantinePurpose; + sourceTaskRef: TaskRef | null; + candidateTaskRef: TaskRef; + artifacts: QuarantineArtifact[]; + now?: Date; +} + +export interface PromotionFromQuarantinePlanV1 { + schemaVersion: 1; + operationId: Ulid; + quarantineId: Ulid; + sourceTaskRef: TaskRef; + destinationTaskRef: TaskRef; + previewDigest: string; + confirmedByActorId: Ulid; + includedArtifacts: QuarantineArtifact[]; + omittedArtifacts: QuarantineArtifact[]; +} + +const PURPOSES = new Set([ + 'legacy_migration', + 'publish_promote', + 'transport_pull', +]); +const STAGES = new Set([ + 'staged', + 'path_validated', + 'privacy_scanned', + 'privacy_blocked', + 'previewed', + 'confirmed', + 'promoted', +]); +const ARTIFACT_CLASSES = new Set([ + 'authority', + 'human_view', + 'raw_evidence', + 'session', + 'overlay', +]); +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function createQuarantineCandidate( + input: CreateQuarantineCandidateInput, +): QuarantineCandidateV1 { + assertUlid(input.quarantineId, 'quarantineId'); + const candidateTaskRef = parseTaskRefValue(input.candidateTaskRef); + if (candidateTaskRef.namespace !== 'local') { + throw new Error('MANCODE_QUARANTINE_CANDIDATE_MUST_BE_LOCAL'); + } + const sourceTaskRef = + input.sourceTaskRef === null + ? null + : parseTaskRefValue(input.sourceTaskRef); + assertSourcePlacement(input.purpose, sourceTaskRef, candidateTaskRef); + const now = (input.now ?? new Date()).toISOString(); + return parseQuarantineCandidate({ + schemaVersion: 1, + quarantineId: input.quarantineId, + purpose: input.purpose, + sourceTaskRef, + candidateTaskRef, + stage: 'staged', + artifacts: input.artifacts, + privacy: { status: 'pending', findings: [] }, + previewDigest: null, + confirmedByActorId: null, + confirmedAt: null, + promotionOperationId: null, + createdAt: now, + updatedAt: now, + }); +} + +export function validateQuarantinePaths( + candidate: QuarantineCandidateV1, + now: Date = new Date(), +): QuarantineCandidateV1 { + const parsed = parseQuarantineCandidate(candidate); + requireStage(parsed, 'staged'); + return parseQuarantineCandidate({ + ...parsed, + stage: 'path_validated', + updatedAt: now.toISOString(), + }); +} + +/** + * Inspects candidate content without retaining any raw text in the manifest. + * A blocked scan cannot be silently redacted and advanced; the user must + * change the local candidate and start a new scan. + */ +export function scanQuarantineCandidate( + candidate: QuarantineCandidateV1, + content: readonly string[], + now: Date = new Date(), +): QuarantineCandidateV1 { + const parsed = parseQuarantineCandidate(candidate); + requireStage(parsed, 'path_validated'); + if ( + !Array.isArray(content) || + content.some((item) => typeof item !== 'string') + ) { + throw new Error('quarantine scan content must be strings'); + } + const findings = summarizePrivacyFindings(content); + return parseQuarantineCandidate({ + ...parsed, + stage: findings.length === 0 ? 'privacy_scanned' : 'privacy_blocked', + privacy: { + status: findings.length === 0 ? 'passed' : 'blocked', + findings, + }, + updatedAt: now.toISOString(), + }); +} + +export function previewQuarantineCandidate( + candidate: QuarantineCandidateV1, + now: Date = new Date(), +): QuarantineCandidateV1 { + const parsed = parseQuarantineCandidate(candidate); + if (parsed.stage === 'privacy_blocked') { + throw new Error('MANCODE_PRIVACY_BLOCKED'); + } + requireStage(parsed, 'privacy_scanned'); + const previewDigest = digestCanonicalJson({ + candidateTaskRef: parsed.candidateTaskRef, + artifacts: parsed.artifacts, + }); + return parseQuarantineCandidate({ + ...parsed, + stage: 'previewed', + previewDigest, + updatedAt: now.toISOString(), + }); +} + +export function confirmQuarantineCandidate( + candidate: QuarantineCandidateV1, + actorId: Ulid, + now: Date = new Date(), +): QuarantineCandidateV1 { + const parsed = parseQuarantineCandidate(candidate); + requireStage(parsed, 'previewed'); + assertUlid(actorId, 'quarantine confirmation actorId'); + return parseQuarantineCandidate({ + ...parsed, + stage: 'confirmed', + confirmedByActorId: actorId, + confirmedAt: now.toISOString(), + updatedAt: now.toISOString(), + }); +} + +/** + * Produces the journal input after user confirmation. It does not promote or + * mutate the candidate yet; the caller must use the returned operationId as + * the durable operation journal identity. + */ +export function preparePromotionFromQuarantine( + candidate: QuarantineCandidateV1, + destinationTaskRef: TaskRef, + operationId: Ulid, +): PromotionFromQuarantinePlanV1 { + const parsed = parseQuarantineCandidate(candidate); + if (parsed.stage === 'privacy_blocked') + throw new Error('MANCODE_PRIVACY_BLOCKED'); + requireStage(parsed, 'confirmed'); + const destination = parseTaskRefValue(destinationTaskRef); + assertUlid(operationId, 'promotion operationId'); + if (destination.namespace !== 'shared') { + throw new Error('MANCODE_PROMOTION_DESTINATION_MUST_BE_SHARED'); + } + if ( + parsed.previewDigest === null || + parsed.confirmedByActorId === null || + parsed.confirmedAt === null + ) { + throw new Error('MANCODE_PROMOTION_CONFIRMATION_REQUIRED'); + } + const includedArtifacts = parsed.artifacts.filter( + (artifact) => artifact.includeInPromotion, + ); + const omittedArtifacts = parsed.artifacts.filter( + (artifact) => !artifact.includeInPromotion, + ); + return { + schemaVersion: 1, + operationId, + quarantineId: parsed.quarantineId, + sourceTaskRef: parsed.candidateTaskRef, + destinationTaskRef: destination, + previewDigest: parsed.previewDigest, + confirmedByActorId: parsed.confirmedByActorId, + includedArtifacts, + omittedArtifacts, + }; +} + +export function markQuarantinePromoted( + candidate: QuarantineCandidateV1, + promotion: PromotionFromQuarantinePlanV1, + now: Date = new Date(), +): QuarantineCandidateV1 { + const parsed = parseQuarantineCandidate(candidate); + requireStage(parsed, 'confirmed'); + assertUlid(promotion.operationId, 'promotion operationId'); + const sourceTaskRef = parseTaskRefValue(promotion.sourceTaskRef); + const destinationTaskRef = parseTaskRefValue(promotion.destinationTaskRef); + if ( + promotion.quarantineId !== parsed.quarantineId || + !sameTaskRef(sourceTaskRef, parsed.candidateTaskRef) || + destinationTaskRef.namespace !== 'shared' || + promotion.previewDigest !== parsed.previewDigest || + promotion.confirmedByActorId !== parsed.confirmedByActorId || + promotion.confirmedByActorId === null + ) { + throw new Error('MANCODE_PROMOTION_PLAN_MISMATCH'); + } + return parseQuarantineCandidate({ + ...parsed, + stage: 'promoted', + promotionOperationId: promotion.operationId, + updatedAt: now.toISOString(), + }); +} + +export function quarantineDirectory( + projectRoot: string, + quarantineId: string, +): string { + assertUlid(quarantineId, 'quarantineId'); + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'quarantine', + quarantineId, + ); +} + +export function publishStagingDirectory( + projectRoot: string, + operationId: string, +): string { + assertUlid(operationId, 'promotion operationId'); + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'publish', + operationId, + ); +} + +export function parseQuarantineCandidate( + value: unknown, +): QuarantineCandidateV1 { + assertRecord(value, 'quarantine candidate'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'quarantineId', + 'purpose', + 'sourceTaskRef', + 'candidateTaskRef', + 'stage', + 'artifacts', + 'privacy', + 'previewDigest', + 'confirmedByActorId', + 'confirmedAt', + 'promotionOperationId', + 'createdAt', + 'updatedAt', + ], + 'quarantine candidate', + ); + if (value.schemaVersion !== 1) { + throw new Error('quarantine candidate schemaVersion must be 1'); + } + assertUlid(value.quarantineId, 'quarantine candidate quarantineId'); + if ( + typeof value.purpose !== 'string' || + !PURPOSES.has(value.purpose as QuarantinePurpose) + ) { + throw new Error('quarantine candidate purpose is invalid'); + } + if ( + typeof value.stage !== 'string' || + !STAGES.has(value.stage as QuarantineStage) + ) { + throw new Error('quarantine candidate stage is invalid'); + } + const candidate: QuarantineCandidateV1 = { + schemaVersion: 1, + quarantineId: value.quarantineId, + purpose: value.purpose as QuarantinePurpose, + sourceTaskRef: + value.sourceTaskRef === null + ? null + : parseTaskRefValue(value.sourceTaskRef), + candidateTaskRef: parseTaskRefValue(value.candidateTaskRef), + stage: value.stage as QuarantineStage, + artifacts: parseArtifacts(value.artifacts), + privacy: parsePrivacy(value.privacy), + previewDigest: parseDigestOrNull( + value.previewDigest, + 'quarantine candidate previewDigest', + ), + confirmedByActorId: parseUlidOrNull( + value.confirmedByActorId, + 'quarantine candidate confirmedByActorId', + ), + confirmedAt: parseTimestampOrNull( + value.confirmedAt, + 'quarantine candidate confirmedAt', + ), + promotionOperationId: parseUlidOrNull( + value.promotionOperationId, + 'quarantine candidate promotionOperationId', + ), + createdAt: parseTimestamp( + value.createdAt, + 'quarantine candidate createdAt', + ), + updatedAt: parseTimestamp( + value.updatedAt, + 'quarantine candidate updatedAt', + ), + }; + if (candidate.candidateTaskRef.namespace !== 'local') { + throw new Error('MANCODE_QUARANTINE_CANDIDATE_MUST_BE_LOCAL'); + } + assertSourcePlacement( + candidate.purpose, + candidate.sourceTaskRef, + candidate.candidateTaskRef, + ); + assertCandidateShape(candidate); + return candidate; +} + +function assertSourcePlacement( + purpose: QuarantinePurpose, + sourceTaskRef: TaskRef | null, + candidateTaskRef: TaskRef, +): void { + if (purpose === 'legacy_migration' && sourceTaskRef !== null) { + throw new Error( + 'legacy migration quarantine cannot claim a V3 source TaskRef', + ); + } + if (purpose === 'publish_promote') { + if ( + sourceTaskRef === null || + sourceTaskRef.namespace !== 'local' || + sourceTaskRef.taskId !== candidateTaskRef.taskId + ) { + throw new Error( + 'publish quarantine must stage its matching local TaskRef', + ); + } + } + if (purpose === 'transport_pull' && sourceTaskRef?.namespace !== 'shared') { + throw new Error( + 'transport pull quarantine must identify a shared source TaskRef', + ); + } +} + +function parseArtifacts(value: unknown): QuarantineArtifact[] { + if (!Array.isArray(value)) + throw new Error('quarantine artifacts must be an array'); + const paths = new Set(); + return value.map((item) => { + assertRecord(item, 'quarantine artifact'); + assertKnownKeys( + item, + ['relativePath', 'classification', 'includeInPromotion', 'contentDigest'], + 'quarantine artifact', + ); + const relativePath = assertSafeSharedRelativePath( + item.relativePath as string, + ); + if (paths.has(relativePath)) { + throw new Error('quarantine artifacts must not repeat a relativePath'); + } + paths.add(relativePath); + if ( + typeof item.classification !== 'string' || + !ARTIFACT_CLASSES.has(item.classification as QuarantineArtifactClass) + ) { + throw new Error('quarantine artifact classification is invalid'); + } + if (typeof item.includeInPromotion !== 'boolean') { + throw new Error('quarantine artifact includeInPromotion must be boolean'); + } + const classification = item.classification as QuarantineArtifactClass; + if ( + (classification === 'raw_evidence' || + classification === 'session' || + classification === 'overlay') && + item.includeInPromotion + ) { + throw new Error('MANCODE_RAW_ARTIFACT_CANNOT_BE_PROMOTED'); + } + return { + relativePath, + classification, + includeInPromotion: item.includeInPromotion, + contentDigest: parseDigestOrNull( + item.contentDigest, + 'quarantine artifact contentDigest', + ), + }; + }); +} + +function parsePrivacy(value: unknown): QuarantinePrivacySummary { + assertRecord(value, 'quarantine privacy'); + assertKnownKeys(value, ['status', 'findings'], 'quarantine privacy'); + if ( + value.status !== 'pending' && + value.status !== 'passed' && + value.status !== 'blocked' + ) { + throw new Error('quarantine privacy status is invalid'); + } + if (!Array.isArray(value.findings)) { + throw new Error('quarantine privacy findings must be an array'); + } + const kinds = new Set(); + const findings = value.findings.map((finding) => { + assertRecord(finding, 'quarantine privacy finding'); + assertKnownKeys(finding, ['kind', 'count'], 'quarantine privacy finding'); + if (!isPrivacyFindingKind(finding.kind) || kinds.has(finding.kind)) { + throw new Error('quarantine privacy finding kind is invalid'); + } + if ( + typeof finding.count !== 'number' || + !Number.isSafeInteger(finding.count) || + finding.count < 1 + ) { + throw new Error('quarantine privacy finding count is invalid'); + } + kinds.add(finding.kind); + return { kind: finding.kind, count: finding.count }; + }); + return { status: value.status, findings }; +} + +function assertCandidateShape(candidate: QuarantineCandidateV1): void { + const isPending = + candidate.stage === 'staged' || candidate.stage === 'path_validated'; + if ( + isPending && + (candidate.privacy.status !== 'pending' || + candidate.privacy.findings.length > 0) + ) { + throw new Error( + 'pending quarantine stages require an empty pending privacy scan', + ); + } + if ( + candidate.stage === 'privacy_scanned' && + (candidate.privacy.status !== 'passed' || + candidate.privacy.findings.length > 0) + ) { + throw new Error('privacy_scanned quarantine requires a clean scan'); + } + if ( + candidate.stage === 'privacy_blocked' && + (candidate.privacy.status !== 'blocked' || + candidate.privacy.findings.length === 0) + ) { + throw new Error('privacy_blocked quarantine requires findings'); + } + const needsPreview = + candidate.stage === 'previewed' || + candidate.stage === 'confirmed' || + candidate.stage === 'promoted'; + if ( + needsPreview && + (candidate.privacy.status !== 'passed' || candidate.previewDigest === null) + ) { + throw new Error('previewed quarantine requires a clean preview digest'); + } + if (!needsPreview && candidate.previewDigest !== null) { + throw new Error('unpreviewed quarantine cannot carry a preview digest'); + } + const needsConfirmation = + candidate.stage === 'confirmed' || candidate.stage === 'promoted'; + if ( + needsConfirmation && + (candidate.confirmedByActorId === null || candidate.confirmedAt === null) + ) { + throw new Error('confirmed quarantine requires actor confirmation'); + } + if ( + candidate.stage === 'promoted' && + candidate.promotionOperationId === null + ) { + throw new Error('promoted quarantine requires promotionOperationId'); + } + if (!needsConfirmation && candidate.confirmedByActorId !== null) { + throw new Error('unconfirmed quarantine cannot carry confirmedByActorId'); + } + if (!needsConfirmation && candidate.confirmedAt !== null) { + throw new Error('unconfirmed quarantine cannot carry confirmedAt'); + } + if ( + candidate.stage !== 'promoted' && + candidate.promotionOperationId !== null + ) { + throw new Error('unpromoted quarantine cannot carry promotionOperationId'); + } +} + +function requireStage( + candidate: QuarantineCandidateV1, + expected: QuarantineStage, +): void { + if (candidate.stage !== expected) { + throw new Error(`MANCODE_QUARANTINE_STAGE_INVALID: expected ${expected}`); + } +} + +function summarizePrivacyFindings( + content: readonly string[], +): QuarantinePrivacySummary['findings'] { + const counts = new Map(); + for (const value of content) { + for (const finding of scanSharedText(value)) { + counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1); + } + } + return [...counts.entries()] + .sort(([left], [right]) => left.localeCompare(right, 'en')) + .map(([kind, count]) => ({ kind, count })); +} + +function isPrivacyFindingKind( + value: unknown, +): value is SharedPrivacyFindingKind { + return ( + value === 'authorization' || + value === 'cookie' || + value === 'private_key' || + value === 'secret' || + value === 'absolute_path' || + value === 'email' + ); +} + +function parseDigestOrNull(value: unknown, label: string): string | null { + if (value === null) return null; + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest or null`); + } + return value; +} + +function parseUlidOrNull(value: unknown, label: string): Ulid | null { + if (value === null) return null; + assertUlid(value, label); + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function parseTimestampOrNull(value: unknown, label: string): string | null { + return value === null ? null : parseTimestamp(value, label); +} diff --git a/src/context/requirements-finalize.ts b/src/context/requirements-finalize.ts new file mode 100644 index 0000000..da50564 --- /dev/null +++ b/src/context/requirements-finalize.ts @@ -0,0 +1,324 @@ +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, +} from '../runtime/operation-recovery-payload.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskHeadEntityKey, + writeTaskAuthorityFile, +} from '../runtime/task-operation.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, +} from './aggregate.js'; +import type { Ulid } from './ids.js'; +import { + type RequirementsLedgerV1, + assertRequirementsLedgerTransition, + parseRequirementsLedger, + requirementsLedgerDigest, +} from './requirements-ledger.js'; +import type { ReviewLedgerV1 } from './review-ledger.js'; +import { + assertTaskCodeHeadUnchanged, + markTaskReviewStale, + markTaskVerificationStale, + nextTaskHeadFence, + taskMutationExpectedRevisions, +} from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import type { VerificationLedgerV1 } from './verification-ledger.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface FinalizeV3RequirementsInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + /** A complete V3 requirements record; control fields are rebuilt at commit. */ + requirements: unknown; + operationId?: Ulid; + now?: Date; +} + +export interface FinalizedV3Requirements { + metadata: WorkflowMetadataV3; + requirements: RequirementsLedgerV1; + review: ReviewLedgerV1; + verification: VerificationLedgerV1; + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1 | null; + operation: OperationJournalV1; +} + +/** + * Finalizes a replacement requirements ledger under one task journal. Review + * and verification intentionally retain their old context references, making + * their explicit stale state provable instead of accidentally reviving old + * evidence against the new requirements digest. + */ +export async function finalizeV3Requirements( + input: FinalizeV3RequirementsInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + const submitted = parseRequirementsLedger(input.requirements); + if (!sameTaskRef(submitted.taskRef, taskRef)) { + throw new Error('MANCODE_REQUIREMENTS_TASK_REF_MISMATCH'); + } + if (submitted.status !== 'confirmed') { + throw new Error('MANCODE_REQUIREMENTS_CONFIRMATION_REQUIRED'); + } + + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId: input.operationId, + extraEntityLocks: + taskRef.namespace === 'shared' ? [taskHeadEntityKey(taskRef)] : [], + now: input.now, + }); + let journal: OperationJournalV1 | null = null; + try { + assertRequirementsFinalizeEligible(context.task.metadata); + const timestamp = context.now.toISOString(); + const requirements = createFinalRequirements( + context.task.requirements, + submitted, + context.operationId, + timestamp, + ); + const review = markTaskReviewStale( + context.task.review, + context.operationId, + timestamp, + ); + const verification = markTaskVerificationStale( + context.task.verification, + context.operationId, + timestamp, + ); + const metadata = updateMetadata( + context.task.metadata, + requirements, + review, + verification, + context.operationId, + timestamp, + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements, + review, + verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: context.task.latestCheckpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + + journal = await createTaskOperationJournal(context, { + type: 'requirements_finalize', + action: + taskRef.namespace === 'shared' + ? 'shared_metadata_plan_mutation' + : 'local_workflow_mutation', + expectedRevisions: taskMutationExpectedRevisions(context, [ + 'requirements', + 'review', + 'verification', + ]), + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'write-requirements', + taskRef, + fileName: 'requirements.json', + beforeContent: serializeTaskAuthority(context.task.requirements), + targetContent: serializeTaskAuthority(requirements), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-review-verification-stale', + taskRef, + fileName: 'review-ledger.json', + beforeContent: serializeTaskAuthority(context.task.review), + targetContent: serializeTaskAuthority(review), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-review-verification-stale', + taskRef, + fileName: 'verification-ledger.json', + beforeContent: serializeTaskAuthority(context.task.verification), + targetContent: serializeTaskAuthority(verification), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'update-metadata', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(metadata), + }), + ...(taskHeadFence === null + ? [] + : [ + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ]), + ], + noOpStepIds: taskHeadFence === null ? ['update-task-head-fence'] : [], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + + journal = await advanceTaskOperation( + context, + journal, + 'write-requirements', + false, + ); + await writeTaskAuthorityFile( + context, + 'requirements.json', + serializeTaskAuthority(requirements), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'mark-review-verification-stale', + false, + ); + await writeTaskAuthorityFile( + context, + 'review-ledger.json', + serializeTaskAuthority(review), + ); + await writeTaskAuthorityFile( + context, + 'verification-ledger.json', + serializeTaskAuthority(verification), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-metadata', + false, + ); + await writeTaskAuthorityFile( + context, + 'metadata.json', + serializeTaskAuthority(metadata), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-task-head-fence', + false, + ); + if (taskHeadFence !== null) { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + } + const operation = await commitTaskOperation(context, journal); + return { + metadata, + requirements, + review, + verification, + aggregate, + taskHeadFence, + operation, + }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A durable journal is already enough to block ordinary writes. + } + } + throw error; + } finally { + await context.release(); + } +} + +function assertRequirementsFinalizeEligible( + metadata: WorkflowMetadataV3, +): void { + if (metadata.status !== 'in_progress') { + throw new Error('MANCODE_REQUIREMENTS_WORKFLOW_NOT_ACTIVE'); + } + if (metadata.currentStep > 2) { + throw new Error('MANCODE_REQUIREMENTS_REVISION_AFTER_PLANNING'); + } +} + +function createFinalRequirements( + previous: RequirementsLedgerV1, + submitted: RequirementsLedgerV1, + operationId: Ulid, + updatedAt: string, +): RequirementsLedgerV1 { + const draft: RequirementsLedgerV1 = { + ...submitted, + taskRef: previous.taskRef, + revision: previous.revision + 1, + status: 'confirmed', + contentDigest: '', + lastOperationId: operationId, + updatedAt, + }; + const next = parseRequirementsLedger({ + ...draft, + contentDigest: requirementsLedgerDigest(draft), + }); + assertRequirementsLedgerTransition(previous, next); + return next; +} + +function updateMetadata( + previous: WorkflowMetadataV3, + requirements: RequirementsLedgerV1, + review: ReviewLedgerV1, + verification: VerificationLedgerV1, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + currentStep: Math.max(previous.currentStep, 2), + revision: previous.revision + 1, + transitionState: 'stable', + lastOperationId: operationId, + governance: { + ...previous.governance, + requirementsStatus: 'ready', + requirementsDigest: requirements.contentDigest, + reviewStatus: review.status, + reviewLedgerDigest: review.contentDigest, + verificationStatus: verification.status, + verificationLedgerDigest: verification.contentDigest, + }, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} diff --git a/src/context/requirements-ledger.ts b/src/context/requirements-ledger.ts new file mode 100644 index 0000000..a5c32f4 --- /dev/null +++ b/src/context/requirements-ledger.ts @@ -0,0 +1,682 @@ +import { digestCanonicalJson, sortUtf8StringSet } from './canonical.js'; +import { type Ulid, assertUlid } from './ids.js'; +import { assertSharedTextSafe } from './privacy.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export type VerificationRequirement = 'automated' | 'manual' | 'hybrid'; +export type RequirementCoverageStatus = + | 'confirmed' + | 'defaulted' + | 'not_applicable'; +export type RequirementDimension = + | 'platform' + | 'core_scope' + | 'technical_stack' + | 'data_and_persistence' + | 'performance' + | 'compatibility' + | 'security'; + +export interface ItemIdentity { + displayId: string; + legacyId: string | null; +} + +export interface RequirementsLedgerV1 { + schemaVersion: 1; + canonicalizationVersion: 'mancode-jcs-v1'; + taskRef: TaskRef; + revision: number; + status: 'draft' | 'confirmed'; + goal: string; + functionalScope: { + inScope: string[]; + outOfScope: string[]; + }; + technicalDecisions: Array< + ItemIdentity & { decisionId: Ulid; statement: string } + >; + defaults: Array; + coverage: Array<{ + coverageId: Ulid; + dimension: RequirementDimension; + status: RequirementCoverageStatus; + rationale: string; + }>; + requirements: Array< + ItemIdentity & { + requirementId: Ulid; + statement: string; + priority: 'must' | 'should' | 'could'; + } + >; + acceptanceCriteria: Array< + ItemIdentity & { + criterionId: Ulid; + requirementIds: Ulid[]; + statement: string; + required: boolean; + verificationRequirement: VerificationRequirement; + } + >; + blockingUnknowns: Array< + ItemIdentity & { + unknownId: Ulid; + statement: string; + status: 'open' | 'resolved' | 'accepted_risk'; + } + >; + legacySource: { + sourceSchema: 'requirements-v1'; + sourceDigest: string; + fieldMapVersion: 1; + } | null; + contentDigest: string; + lastOperationId: Ulid | null; + updatedAt: string; +} + +export const REQUIREMENT_DIMENSIONS: RequirementDimension[] = [ + 'platform', + 'core_scope', + 'technical_stack', + 'data_and_persistence', + 'performance', + 'compatibility', + 'security', +]; + +const COVERAGE_STATUSES = new Set([ + 'confirmed', + 'defaulted', + 'not_applicable', +]); +const VERIFICATION_REQUIREMENTS = new Set([ + 'automated', + 'manual', + 'hybrid', +]); +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function parseRequirementsLedger(value: unknown): RequirementsLedgerV1 { + assertRecord(value, 'requirements ledger'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'canonicalizationVersion', + 'taskRef', + 'revision', + 'status', + 'goal', + 'functionalScope', + 'technicalDecisions', + 'defaults', + 'coverage', + 'requirements', + 'acceptanceCriteria', + 'blockingUnknowns', + 'legacySource', + 'contentDigest', + 'lastOperationId', + 'updatedAt', + ], + 'requirements ledger', + ); + if (value.schemaVersion !== 1) { + throw new Error('requirements ledger schemaVersion must be 1'); + } + if (value.canonicalizationVersion !== 'mancode-jcs-v1') { + throw new Error('requirements ledger canonicalizationVersion is invalid'); + } + if (value.status !== 'draft' && value.status !== 'confirmed') { + throw new Error('requirements ledger status is invalid'); + } + const ledger: RequirementsLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: parseTaskRefValue(value.taskRef), + revision: parsePositiveInteger( + value.revision, + 'requirements ledger revision', + ), + status: value.status, + goal: parseNonEmptyString(value.goal, 'requirements ledger goal'), + functionalScope: parseFunctionalScope(value.functionalScope), + technicalDecisions: parseTechnicalDecisions(value.technicalDecisions), + defaults: parseDefaults(value.defaults), + coverage: parseCoverage(value.coverage), + requirements: parseRequirements(value.requirements), + acceptanceCriteria: parseAcceptanceCriteria(value.acceptanceCriteria), + blockingUnknowns: parseBlockingUnknowns(value.blockingUnknowns), + legacySource: parseLegacySource(value.legacySource), + contentDigest: parseDigest( + value.contentDigest, + 'requirements ledger contentDigest', + ), + lastOperationId: parseUlidOrNull( + value.lastOperationId, + 'requirements ledger lastOperationId', + ), + updatedAt: parseTimestamp(value.updatedAt, 'requirements ledger updatedAt'), + }; + assertUniqueDisplayIds(ledger); + assertAcceptanceRequirementRefs(ledger); + assertRequirementsPrivacy(ledger); + if (ledger.contentDigest !== requirementsLedgerDigest(ledger)) { + throw new Error( + 'requirements ledger contentDigest does not match canonical content', + ); + } + if (ledger.status === 'confirmed') assertRequirementsReady(ledger); + return ledger; +} + +export function requirementsLedgerDigest(ledger: RequirementsLedgerV1): string { + return digestCanonicalJson({ + schemaVersion: ledger.schemaVersion, + canonicalizationVersion: ledger.canonicalizationVersion, + taskRef: ledger.taskRef, + status: ledger.status, + goal: ledger.goal, + functionalScope: ledger.functionalScope, + technicalDecisions: ledger.technicalDecisions, + defaults: ledger.defaults, + coverage: ledger.coverage, + requirements: ledger.requirements, + acceptanceCriteria: ledger.acceptanceCriteria, + blockingUnknowns: ledger.blockingUnknowns, + legacySource: ledger.legacySource, + }); +} + +/** + * Requirements are revised as a whole authority record. A confirmed record + * may deliberately return to draft when a later edit reopens clarification, + * so the invariant here is revision and identity, not a one-way status. + */ +export function assertRequirementsLedgerTransition( + previous: RequirementsLedgerV1, + next: RequirementsLedgerV1, +): void { + if (next.revision !== previous.revision + 1) { + throw new Error( + 'requirements ledger revision must increase exactly once per mutation', + ); + } + if ( + previous.schemaVersion !== next.schemaVersion || + previous.canonicalizationVersion !== next.canonicalizationVersion || + !sameTaskRef(previous.taskRef, next.taskRef) + ) { + throw new Error('requirements ledger schema and TaskRef are immutable'); + } + if (previous.legacySource === null && next.legacySource !== null) { + throw new Error('requirements ledger cannot introduce a legacy source'); + } +} + +export function requirementsAreReady(ledger: RequirementsLedgerV1): boolean { + try { + assertRequirementsReady(ledger); + return true; + } catch { + return false; + } +} + +export function assertRequirementsReady(ledger: RequirementsLedgerV1): void { + if (!ledger.goal.trim()) + throw new Error('requirements ledger goal is required'); + if (ledger.functionalScope.inScope.length === 0) { + throw new Error('requirements ledger requires at least one in-scope item'); + } + if (ledger.coverage.length !== REQUIREMENT_DIMENSIONS.length) { + throw new Error( + 'requirements ledger must cover every requirement dimension', + ); + } + const dimensions = new Set(ledger.coverage.map((item) => item.dimension)); + if (dimensions.size !== REQUIREMENT_DIMENSIONS.length) { + throw new Error('requirements ledger coverage dimensions must be unique'); + } + for (const dimension of REQUIREMENT_DIMENSIONS) { + if (!dimensions.has(dimension)) { + throw new Error(`requirements ledger coverage is missing ${dimension}`); + } + } + const technicalStack = ledger.coverage.find( + (item) => item.dimension === 'technical_stack', + ); + if ( + technicalStack?.status !== 'not_applicable' && + ledger.technicalDecisions.length === 0 + ) { + throw new Error( + 'requirements ledger technicalDecisions are required when technical_stack applies', + ); + } + if (ledger.acceptanceCriteria.length === 0) { + throw new Error('requirements ledger requires acceptance criteria'); + } + if (!ledger.acceptanceCriteria.some((criterion) => criterion.required)) { + throw new Error( + 'requirements ledger requires at least one required acceptance criterion', + ); + } + if (ledger.blockingUnknowns.some((unknown) => unknown.status === 'open')) { + throw new Error('requirements ledger has unresolved blocking unknowns'); + } +} + +function assertRequirementsPrivacy(ledger: RequirementsLedgerV1): void { + if (ledger.taskRef.namespace !== 'shared') return; + const values = [ + ['goal', ledger.goal], + ...ledger.functionalScope.inScope.map( + (value, index) => [`functionalScope.inScope[${index}]`, value] as const, + ), + ...ledger.functionalScope.outOfScope.map( + (value, index) => + [`functionalScope.outOfScope[${index}]`, value] as const, + ), + ...ledger.technicalDecisions.map( + (item, index) => + [`technicalDecisions[${index}]`, item.statement] as const, + ), + ...ledger.defaults.map( + (item, index) => [`defaults[${index}]`, item.statement] as const, + ), + ...ledger.coverage.map( + (item, index) => [`coverage[${index}]`, item.rationale] as const, + ), + ...ledger.requirements.map( + (item, index) => [`requirements[${index}]`, item.statement] as const, + ), + ...ledger.acceptanceCriteria.map( + (item, index) => + [`acceptanceCriteria[${index}]`, item.statement] as const, + ), + ...ledger.blockingUnknowns.map( + (item, index) => [`blockingUnknowns[${index}]`, item.statement] as const, + ), + ]; + for (const [label, value] of values) { + assertSharedTextSafe(value, `requirements ledger ${label}`); + } +} + +function parseFunctionalScope( + value: unknown, +): RequirementsLedgerV1['functionalScope'] { + assertRecord(value, 'requirements ledger functionalScope'); + assertKnownKeys( + value, + ['inScope', 'outOfScope'], + 'requirements ledger functionalScope', + ); + return { + inScope: parseStringList( + value.inScope, + 'requirements ledger functionalScope inScope', + ), + outOfScope: parseStringList( + value.outOfScope, + 'requirements ledger functionalScope outOfScope', + ), + }; +} + +function parseTechnicalDecisions( + value: unknown, +): RequirementsLedgerV1['technicalDecisions'] { + if (!Array.isArray(value)) { + throw new Error('requirements ledger technicalDecisions must be an array'); + } + return value.map((item) => { + const identity = parseItemIdentity( + item, + 'requirements ledger technical decision', + ); + assertRecord(item, 'requirements ledger technical decision'); + assertKnownKeys( + item, + ['displayId', 'legacyId', 'decisionId', 'statement'], + 'requirements ledger technical decision', + ); + assertUlid(item.decisionId, 'requirements ledger decisionId'); + return { + ...identity, + decisionId: item.decisionId, + statement: parseNonEmptyString( + item.statement, + 'requirements ledger technical decision statement', + ), + }; + }); +} + +function parseDefaults(value: unknown): RequirementsLedgerV1['defaults'] { + if (!Array.isArray(value)) + throw new Error('requirements ledger defaults must be an array'); + return value.map((item) => { + const identity = parseItemIdentity(item, 'requirements ledger default'); + assertRecord(item, 'requirements ledger default'); + assertKnownKeys( + item, + ['displayId', 'legacyId', 'defaultId', 'statement'], + 'requirements ledger default', + ); + assertUlid(item.defaultId, 'requirements ledger defaultId'); + return { + ...identity, + defaultId: item.defaultId, + statement: parseNonEmptyString( + item.statement, + 'requirements ledger default statement', + ), + }; + }); +} + +function parseCoverage(value: unknown): RequirementsLedgerV1['coverage'] { + if (!Array.isArray(value)) + throw new Error('requirements ledger coverage must be an array'); + const dimensions = new Set(); + return value.map((item) => { + assertRecord(item, 'requirements ledger coverage item'); + assertKnownKeys( + item, + ['coverageId', 'dimension', 'status', 'rationale'], + 'requirements ledger coverage item', + ); + assertUlid(item.coverageId, 'requirements ledger coverageId'); + if (!isRequirementDimension(item.dimension)) { + throw new Error('requirements ledger coverage dimension is invalid'); + } + if (dimensions.has(item.dimension)) { + throw new Error('requirements ledger coverage dimensions must be unique'); + } + dimensions.add(item.dimension); + if ( + typeof item.status !== 'string' || + !COVERAGE_STATUSES.has(item.status as RequirementCoverageStatus) + ) { + throw new Error('requirements ledger coverage status is invalid'); + } + return { + coverageId: item.coverageId, + dimension: item.dimension, + status: item.status as RequirementCoverageStatus, + rationale: parseNonEmptyString( + item.rationale, + 'requirements ledger coverage rationale', + ), + }; + }); +} + +function parseRequirements( + value: unknown, +): RequirementsLedgerV1['requirements'] { + if (!Array.isArray(value)) + throw new Error('requirements ledger requirements must be an array'); + return value.map((item) => { + const identity = parseItemIdentity(item, 'requirements ledger requirement'); + assertRecord(item, 'requirements ledger requirement'); + assertKnownKeys( + item, + ['displayId', 'legacyId', 'requirementId', 'statement', 'priority'], + 'requirements ledger requirement', + ); + assertUlid(item.requirementId, 'requirements ledger requirementId'); + if ( + item.priority !== 'must' && + item.priority !== 'should' && + item.priority !== 'could' + ) { + throw new Error('requirements ledger requirement priority is invalid'); + } + return { + ...identity, + requirementId: item.requirementId, + statement: parseNonEmptyString( + item.statement, + 'requirements ledger requirement statement', + ), + priority: item.priority, + }; + }); +} + +function parseAcceptanceCriteria( + value: unknown, +): RequirementsLedgerV1['acceptanceCriteria'] { + if (!Array.isArray(value)) { + throw new Error('requirements ledger acceptanceCriteria must be an array'); + } + return value.map((item) => { + const identity = parseItemIdentity( + item, + 'requirements ledger acceptance criterion', + ); + assertRecord(item, 'requirements ledger acceptance criterion'); + assertKnownKeys( + item, + [ + 'displayId', + 'legacyId', + 'criterionId', + 'requirementIds', + 'statement', + 'required', + 'verificationRequirement', + ], + 'requirements ledger acceptance criterion', + ); + assertUlid(item.criterionId, 'requirements ledger criterionId'); + if (typeof item.required !== 'boolean') { + throw new Error('requirements ledger criterion required must be boolean'); + } + if ( + typeof item.verificationRequirement !== 'string' || + !VERIFICATION_REQUIREMENTS.has( + item.verificationRequirement as VerificationRequirement, + ) + ) { + throw new Error( + 'requirements ledger criterion verificationRequirement is invalid', + ); + } + return { + ...identity, + criterionId: item.criterionId, + requirementIds: parseUlidSet( + item.requirementIds, + 'requirements ledger criterion requirementIds', + ), + statement: parseNonEmptyString( + item.statement, + 'requirements ledger criterion statement', + ), + required: item.required, + verificationRequirement: + item.verificationRequirement as VerificationRequirement, + }; + }); +} + +function parseBlockingUnknowns( + value: unknown, +): RequirementsLedgerV1['blockingUnknowns'] { + if (!Array.isArray(value)) { + throw new Error('requirements ledger blockingUnknowns must be an array'); + } + return value.map((item) => { + const identity = parseItemIdentity( + item, + 'requirements ledger blocking unknown', + ); + assertRecord(item, 'requirements ledger blocking unknown'); + assertKnownKeys( + item, + ['displayId', 'legacyId', 'unknownId', 'statement', 'status'], + 'requirements ledger blocking unknown', + ); + assertUlid(item.unknownId, 'requirements ledger unknownId'); + if ( + item.status !== 'open' && + item.status !== 'resolved' && + item.status !== 'accepted_risk' + ) { + throw new Error('requirements ledger blocking unknown status is invalid'); + } + return { + ...identity, + unknownId: item.unknownId, + statement: parseNonEmptyString( + item.statement, + 'requirements ledger unknown statement', + ), + status: item.status, + }; + }); +} + +function parseItemIdentity(value: unknown, label: string): ItemIdentity { + assertRecord(value, label); + if (typeof value.displayId !== 'string' || !value.displayId.trim()) { + throw new Error(`${label} displayId is required`); + } + if ( + value.legacyId !== null && + (typeof value.legacyId !== 'string' || !value.legacyId.trim()) + ) { + throw new Error(`${label} legacyId must be a non-empty string or null`); + } + return { displayId: value.displayId, legacyId: value.legacyId }; +} + +function parseLegacySource( + value: unknown, +): RequirementsLedgerV1['legacySource'] { + if (value === null) return null; + assertRecord(value, 'requirements ledger legacySource'); + assertKnownKeys( + value, + ['sourceSchema', 'sourceDigest', 'fieldMapVersion'], + 'requirements ledger legacySource', + ); + if (value.sourceSchema !== 'requirements-v1' || value.fieldMapVersion !== 1) { + throw new Error('requirements ledger legacySource is invalid'); + } + return { + sourceSchema: 'requirements-v1', + sourceDigest: parseDigest( + value.sourceDigest, + 'requirements ledger legacySource sourceDigest', + ), + fieldMapVersion: 1, + }; +} + +function assertUniqueDisplayIds(ledger: RequirementsLedgerV1): void { + const displayIds = new Set(); + const groups: Array> = [ + ledger.technicalDecisions, + ledger.defaults, + ledger.requirements, + ledger.acceptanceCriteria, + ledger.blockingUnknowns, + ]; + for (const group of groups) { + for (const item of group) { + if (displayIds.has(item.displayId)) { + throw new Error( + 'requirements ledger displayIds must be unique within a task', + ); + } + displayIds.add(item.displayId); + } + } +} + +function assertAcceptanceRequirementRefs(ledger: RequirementsLedgerV1): void { + const requirementIds = new Set( + ledger.requirements.map((item) => item.requirementId), + ); + for (const criterion of ledger.acceptanceCriteria) { + for (const requirementId of criterion.requirementIds) { + if (!requirementIds.has(requirementId)) { + throw new Error( + 'requirements ledger criterion references an unknown requirementId', + ); + } + } + } +} + +function parseStringList(value: unknown, label: string): string[] { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== 'string' || !item.trim()) + ) { + throw new Error(`${label} must be an array of non-empty strings`); + } + const seen = new Set(); + for (const item of value) { + if (seen.has(item)) throw new Error(`${label} must not contain duplicates`); + seen.add(item); + } + return [...value] as string[]; +} + +function parseUlidSet(value: unknown, label: string): Ulid[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + for (const item of value) assertUlid(item, label); + const normalized = sortUtf8StringSet(value); + if (normalized.length !== value.length) + throw new Error(`${label} must not contain duplicates`); + return normalized as Ulid[]; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseUlidOrNull(value: unknown, label: string): Ulid | null { + if (value === null) return null; + assertUlid(value, label); + return value; +} + +function parseNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim()) + throw new Error(`${label} is required`); + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function isRequirementDimension(value: unknown): value is RequirementDimension { + return ( + typeof value === 'string' && + REQUIREMENT_DIMENSIONS.includes(value as RequirementDimension) + ); +} diff --git a/src/context/resolver.ts b/src/context/resolver.ts new file mode 100644 index 0000000..4a6b85f --- /dev/null +++ b/src/context/resolver.ts @@ -0,0 +1,1022 @@ +import { + type EntityHomeStoreContext, + resolveTaskEntityHomeStore, +} from '../runtime/entity-home-store.js'; +import { type SessionStateV1, parseSessionState } from '../runtime/session.js'; +import { checkpointDigest } from '../team/checkpoints.js'; +import type { ClaimV1 } from '../team/claims.js'; +import { + type CoordinationCapabilitiesV1, + capabilitiesFromProjectConfig, + parseCoordinationCapabilities, +} from '../team/transport.js'; +import type { TaskAggregateManifestV1 } from './aggregate.js'; +import { digestCanonicalJson } from './canonical.js'; +import { + type CompatibilityFailureCode, + evaluateCompatibilityGate, +} from './compatibility.js'; +import { + type ContextLevel, + type ContextPackSectionInput, + type ContextPackV2, + type ContextPurpose, + buildContextPack, + defaultContextPackBudget, +} from './context-pack.js'; +import type { + ContextPackSectionPointer, + ProvenanceEntry, +} from './context-pack.js'; +import { type LegacyAuthorityScan, scanLegacyAuthority } from './layout.js'; +import type { ManagedAdapter } from './manifest.js'; +import { + type PendingOperationRecord, + type StoredCoordinationSnapshot, + type StoredParentSnapshot, + type StoredProjectSnapshot, + type StoredTaskSnapshot, + V3ContextStore, +} from './store.js'; +import { type TaskRef, parseTaskRef, parseTaskRefValue } from './task-ref.js'; +import { assertParentWorkflowRelation } from './workflow-metadata.js'; +import { workflowMetadataDigest } from './workflow-metadata.js'; + +export type ContextResolutionIntent = 'read' | 'mutate'; + +export type ContextRepairCode = + | 'MANCODE_OPERATION_REPAIR_REQUIRED' + | 'MANCODE_TASK_TRANSITION_PENDING' + | 'MANCODE_TASK_AGGREGATE_INCONSISTENT' + | 'MANCODE_TASK_HEAD_FENCE_MISSING' + | 'MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE' + | 'MANCODE_TASK_HEAD_FENCE_MISMATCH' + | 'MANCODE_CONFIG_POLICY_INCONSISTENT' + | 'MANCODE_WORKSPACE_BINDING_MISMATCH' + | 'MANCODE_PARENT_RELATION_INVALID'; + +export type ContextWriteBlockerCode = + | CompatibilityFailureCode + | 'MANCODE_SESSION_REQUIRED' + | 'MANCODE_PARENT_UNAVAILABLE' + | 'MANCODE_PARENT_STALE' + | 'MANCODE_TRANSPORT_NOT_FRESH' + | 'MANCODE_FOREIGN_ACTIVE_CLAIM' + | ContextRepairCode; + +export interface ContextResolverCompatibility { + expectedSchemaEpoch: string; + readerVersion: string; + writerVersion: string; + adapterVersions: Record; +} + +export interface ContextResolveRequest { + /** + * A parsed session identity; its task pointer is convenience-only. Read-only + * callers may omit it when they supply an explicit TaskRef. + */ + session: SessionStateV1 | null; + taskRef?: TaskRef | string; + level: ContextLevel; + purpose: ContextPurpose; + intent?: ContextResolutionIntent; + compatibility: ContextResolverCompatibility; + /** The current checkout HEAD, mandatory for a stable shared-task fence. */ + codeHead?: string | null; + capabilities?: CoordinationCapabilitiesV1; + budgetLimit?: number; + generatedAt?: Date; + maxReadAttempts?: number; +} + +export interface ContextResolverOptions { + projectRoot: string; + entityHomeStoreContext: EntityHomeStoreContext; + store?: V3ContextStore; + now?: () => Date; +} + +export interface ContextResolutionIssue { + code: ContextRepairCode | ContextWriteBlockerCode; + operationIds: string[]; +} + +export interface ContextRepairEnvelope { + state: 'repair_required'; + taskRef: TaskRef; + issues: ContextResolutionIssue[]; + partialFields: Array< + | 'session' + | 'taskRef' + | 'schemaEpoch' + | 'capabilities' + | 'transportFreshness' + >; +} + +export interface ContextResolution { + taskRef: TaskRef; + session: SessionStateV1 | null; + aggregate: TaskAggregateManifestV1 | null; + metadata: StoredTaskSnapshot['metadata'] | null; + repair: ContextRepairEnvelope | null; + writeBlockers: ContextResolutionIssue[]; + mutatingAllowed: boolean; + pack: ContextPackV2; +} + +interface ResolverReadSnapshot { + project: StoredProjectSnapshot; + task: StoredTaskSnapshot; + coordination: StoredCoordinationSnapshot; + parent: StoredParentSnapshot | null; + parentReadError: string | null; + legacy: LegacyAuthorityScan; + capabilities: CoordinationCapabilitiesV1; + codeHead: string | null; + fingerprint: string; +} + +const PURPOSE_REQUIRED_SECTIONS: Record< + ContextPurpose, + ReadonlySet +> = { + orient: new Set(), + plan: new Set(['/project', '/governance/requirements', '/parentFreshness']), + implement: new Set([ + '/governance/requirements', + '/latestCheckpoint', + '/claims', + ]), + review: new Set([ + '/governance/requirements', + '/governance/review', + '/governance/verification', + ]), + verify: new Set([ + '/governance/requirements', + '/governance/review', + '/governance/verification', + ]), + handoff: new Set([ + '/collaboration', + '/governance/requirements', + '/governance/review', + '/governance/verification', + '/latestCheckpoint', + '/latestHandoff', + '/claims', + ]), +}; + +/** + * Resolves one read-stable V3 tuple. A resolver never composes a Context Pack + * from entities read at different revisions: it rereads the complete relevant + * tuple and retries when a fingerprint changes. + */ +export class ContextResolver { + private readonly store: V3ContextStore; + private readonly now: () => Date; + + constructor(private readonly options: ContextResolverOptions) { + this.store = options.store ?? new V3ContextStore(options.projectRoot); + this.now = options.now ?? (() => new Date()); + } + + async resolve(request: ContextResolveRequest): Promise { + const session = + request.session === null ? null : parseSessionState(request.session); + if (session !== null && session.status !== 'active') { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + const requestedTask = resolveRequestedTask(request.taskRef, session); + const taskRef = (await this.store.locateTask(requestedTask)).taskRef; + const intent = request.intent ?? 'read'; + const maxReadAttempts = parseMaxReadAttempts(request.maxReadAttempts); + const snapshot = await this.readStableSnapshot( + taskRef, + request, + maxReadAttempts, + ); + const compatibility = evaluateCompatibilityGate({ + manifest: snapshot.project.manifest, + expectedSchemaEpoch: request.compatibility.expectedSchemaEpoch, + readerVersion: request.compatibility.readerVersion, + writerVersion: request.compatibility.writerVersion, + adapterVersions: request.compatibility.adapterVersions, + currentLegacyBaseline: snapshot.legacy.baseline, + legacyAuthorityPresent: snapshot.legacy.authorityPresent, + operation: intent === 'read' ? 'read' : 'v3_business_write', + }); + if (!compatibility.readAllowed) { + throw new Error( + compatibility.failures[0] ?? 'MANCODE_CONTEXT_READ_BLOCKED', + ); + } + + const repairIssues = collectRepairIssues(snapshot); + const writeBlockers = collectWriteBlockers( + snapshot, + compatibility.failures, + repairIssues, + session?.actorId ?? null, + ); + const repair = + repairIssues.length === 0 + ? null + : createRepairEnvelope(taskRef, repairIssues); + const mutatingAllowed = repair === null && writeBlockers.length === 0; + const pack = + repair === null + ? buildStablePack(snapshot, session, request, writeBlockers, this.now()) + : buildRepairPack(snapshot, session, request, repair, this.now()); + const resolution: ContextResolution = { + taskRef, + session, + aggregate: repair === null ? snapshot.task.aggregate : null, + metadata: repair === null ? snapshot.task.metadata : null, + repair, + writeBlockers, + mutatingAllowed, + pack, + }; + if (intent === 'mutate' && !mutatingAllowed) { + throw new Error( + repair?.issues[0]?.code ?? + writeBlockers[0]?.code ?? + 'MANCODE_CONTEXT_WRITE_BLOCKED', + ); + } + return resolution; + } + + private async readStableSnapshot( + taskRef: TaskRef, + request: ContextResolveRequest, + maxReadAttempts: number, + ): Promise { + for (let attempt = 0; attempt < maxReadAttempts; attempt += 1) { + const first = await this.readSnapshot(taskRef, request); + const second = await this.readSnapshot(taskRef, request); + if (first.fingerprint === second.fingerprint) return first; + } + throw new Error('MANCODE_CONTEXT_READ_UNSTABLE'); + } + + private async readSnapshot( + taskRef: TaskRef, + request: ContextResolveRequest, + ): Promise { + const homeStore = resolveTaskEntityHomeStore( + this.options.entityHomeStoreContext, + taskRef, + ); + const [project, task, coordination, legacy] = await Promise.all([ + this.store.readProjectSnapshot(), + this.store.readTaskSnapshot(taskRef), + this.store.readCoordinationSnapshot(taskRef, homeStore), + scanLegacyAuthority(this.store.projectRoot), + ]); + const parent = await readParentForResolver(this.store, task); + const capabilities = parseCoordinationCapabilities( + request.capabilities ?? capabilitiesFromProjectConfig(project.config), + ); + const codeHead = parseCodeHeadOrNull(request.codeHead); + return { + project, + task, + coordination, + parent: parent.snapshot, + parentReadError: parent.error, + legacy, + capabilities, + codeHead, + fingerprint: digestCanonicalJson({ + project: project.fingerprint, + task: task.fingerprint, + coordination: coordination.fingerprint, + parent: parent.snapshot?.fingerprint ?? null, + parentReadError: parent.error, + legacy: { + baseline: legacy.baseline, + authorityPresent: legacy.authorityPresent, + unsafePaths: legacy.unsafePaths, + }, + capabilities, + codeHead, + }), + }; + } +} + +function resolveRequestedTask( + explicit: ContextResolveRequest['taskRef'], + session: SessionStateV1 | null, +): TaskRef | string { + if (explicit !== undefined) { + return typeof explicit === 'string' + ? explicit.includes(':') + ? parseTaskRef(explicit) + : explicit + : parseTaskRefValue(explicit); + } + if (session === null || session.activeTaskRef === null) { + throw new Error('MANCODE_TASK_REQUIRED'); + } + return session.activeTaskRef; +} + +function parseMaxReadAttempts(value: number | undefined): number { + if (value === undefined) return 3; + if (!Number.isSafeInteger(value) || value < 1 || value > 10) { + throw new Error( + 'context resolver maxReadAttempts must be between 1 and 10', + ); + } + return value; +} + +function parseCodeHeadOrNull(value: string | null | undefined): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error('context resolver codeHead is invalid'); + } + return value; +} + +async function readParentForResolver( + store: V3ContextStore, + task: StoredTaskSnapshot, +): Promise<{ snapshot: StoredParentSnapshot | null; error: string | null }> { + try { + return { + snapshot: await store.readParentSnapshot(task.metadata), + error: null, + }; + } catch (error) { + return { + snapshot: null, + error: + error instanceof Error + ? normalizeParentReadError(error.message) + : 'MANCODE_PARENT_UNAVAILABLE', + }; + } +} + +function normalizeParentReadError(message: string): string { + return message.startsWith('MANCODE_') + ? 'MANCODE_PARENT_UNAVAILABLE' + : 'MANCODE_PARENT_UNAVAILABLE'; +} + +function collectRepairIssues( + snapshot: ResolverReadSnapshot, +): ContextResolutionIssue[] { + const issues: ContextResolutionIssue[] = []; + if (snapshot.task.aggregate === null) { + issues.push({ + code: 'MANCODE_TASK_AGGREGATE_INCONSISTENT', + operationIds: [], + }); + } + if (snapshot.task.metadata.transitionState !== 'stable') { + issues.push({ code: 'MANCODE_TASK_TRANSITION_PENDING', operationIds: [] }); + } + if (snapshot.coordination.pendingOperations.length > 0) { + issues.push({ + code: 'MANCODE_OPERATION_REPAIR_REQUIRED', + operationIds: operationIds(snapshot.coordination.pendingOperations), + }); + } + if ( + snapshot.project.config.workspaceId !== snapshot.project.policy.workspaceId + ) { + issues.push({ + code: 'MANCODE_CONFIG_POLICY_INCONSISTENT', + operationIds: [], + }); + } + if ( + snapshot.project.config.workspaceId !== + snapshot.coordination.homeStore.workspaceId + ) { + issues.push({ + code: 'MANCODE_WORKSPACE_BINDING_MISMATCH', + operationIds: [], + }); + } + if (snapshot.task.metadata.parent !== null) { + if (snapshot.parent !== null) { + try { + assertParentWorkflowRelation( + snapshot.task.metadata, + snapshot.parent.metadata, + ); + } catch { + issues.push({ + code: 'MANCODE_PARENT_RELATION_INVALID', + operationIds: [], + }); + } + } + } + if (snapshot.task.metadata.visibility === 'shared') { + const aggregate = snapshot.task.aggregate; + const fence = snapshot.coordination.taskHeadFence; + if (fence === null) { + issues.push({ + code: 'MANCODE_TASK_HEAD_FENCE_MISSING', + operationIds: [], + }); + } else if (snapshot.codeHead === null) { + issues.push({ + code: 'MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE', + operationIds: [], + }); + } else if ( + aggregate === null || + !fenceMatchesAggregate(fence, aggregate, snapshot.codeHead) + ) { + issues.push({ + code: 'MANCODE_TASK_HEAD_FENCE_MISMATCH', + operationIds: [], + }); + } + } + return dedupeIssues(issues); +} + +function collectWriteBlockers( + snapshot: ResolverReadSnapshot, + compatibilityFailures: CompatibilityFailureCode[], + repairIssues: ContextResolutionIssue[], + actorId: string | null, +): ContextResolutionIssue[] { + const blockers: ContextResolutionIssue[] = [ + ...repairIssues, + ...compatibilityFailures.map((code) => ({ code, operationIds: [] })), + ]; + if (snapshot.task.metadata.parent !== null) { + if (snapshot.parentReadError !== null) { + blockers.push({ code: 'MANCODE_PARENT_UNAVAILABLE', operationIds: [] }); + } else if (snapshot.parent?.staleReasons.length) { + blockers.push({ code: 'MANCODE_PARENT_STALE', operationIds: [] }); + } + } + if ( + snapshot.capabilities.transport === 'git-ref' && + snapshot.capabilities.transportFreshness !== 'fresh' + ) { + blockers.push({ code: 'MANCODE_TRANSPORT_NOT_FRESH', operationIds: [] }); + } + if (actorId === null) { + blockers.push({ code: 'MANCODE_SESSION_REQUIRED', operationIds: [] }); + } else if (hasForeignActiveClaim(snapshot.coordination.claims, actorId)) { + blockers.push({ code: 'MANCODE_FOREIGN_ACTIVE_CLAIM', operationIds: [] }); + } + return dedupeIssues(blockers); +} + +function fenceMatchesAggregate( + fence: NonNullable, + aggregate: TaskAggregateManifestV1, + codeHead: string, +): boolean { + return ( + fence.taskRef.namespace === aggregate.taskRef.namespace && + fence.taskRef.taskId === aggregate.taskRef.taskId && + fence.taskRevision === aggregate.taskRevision && + fence.ownershipEpoch === aggregate.ownershipEpoch && + fence.aggregateDigest === digestCanonicalJson(aggregate) && + fence.codeRef.head === codeHead + ); +} + +function createRepairEnvelope( + taskRef: TaskRef, + issues: ContextResolutionIssue[], +): ContextRepairEnvelope { + return { + state: 'repair_required', + taskRef, + issues, + partialFields: [ + 'session', + 'taskRef', + 'schemaEpoch', + 'capabilities', + 'transportFreshness', + ], + }; +} + +function buildStablePack( + snapshot: ResolverReadSnapshot, + session: SessionStateV1 | null, + request: ContextResolveRequest, + writeBlockers: ContextResolutionIssue[], + now: Date, +): ContextPackV2 { + const { task, project, coordination, capabilities } = snapshot; + const sections: ContextPackSectionInput[] = [ + runtimeSection('/session', sessionProjection(session), true), + activeTaskSection(task), + derivedSection( + '/conflicts', + writeBlockers, + [task.fingerprint, coordination.fingerprint, project.fingerprint], + true, + ), + runtimeSection('/capabilities', capabilities, true), + runtimeSection( + '/transportFreshness', + transportFreshnessProjection(capabilities), + true, + ), + ]; + if (request.level !== 'bootstrap' && request.level !== 'full') { + const optionalSections: Array = [ + runtimeSection('/actor', { actorId: session?.actorId ?? null }), + projectSection(project), + entitySection( + '/collaboration', + collaborationProjection(task), + task.metadata.taskRef, + 'workflow', + task.metadata.revision, + workflowMetadataDigest(task.metadata), + ), + entitySection( + '/governance/requirements', + task.requirements, + task.metadata.taskRef, + 'requirements-ledger', + task.requirements.revision, + task.requirements.contentDigest, + ), + entitySection( + '/governance/review', + task.review, + task.metadata.taskRef, + 'review-ledger', + task.review.revision, + task.review.contentDigest, + ), + entitySection( + '/governance/verification', + task.verification, + task.metadata.taskRef, + 'verification-ledger', + task.verification.revision, + task.verification.contentDigest, + ), + parentFreshnessSection(snapshot), + latestCheckpointSection(task), + latestHandoffSection(coordination), + claimsSection(coordination.claims), + ]; + for (const section of optionalSections) { + if (section !== null) { + sections.push(markPurposeRequired(section, request.purpose)); + } + } + } + return buildContextPack({ + generatedAt: (request.generatedAt ?? now).toISOString(), + level: request.level, + purpose: request.purpose, + snapshot: { + schemaEpoch: project.manifest.epoch, + taskRevision: task.metadata.revision, + requirementsDigest: task.requirements.contentDigest, + reviewDigest: task.review.contentDigest, + verificationDigest: task.verification.contentDigest, + ownershipEpoch: task.metadata.ownershipEpoch, + coordinationRevision: + capabilities.remoteRevision ?? project.config.revision, + }, + budgetLimit: resolveBudgetLimit(request), + sections, + }); +} + +function buildRepairPack( + snapshot: ResolverReadSnapshot, + session: SessionStateV1 | null, + request: ContextResolveRequest, + repair: ContextRepairEnvelope, + now: Date, +): ContextPackV2 { + const capabilities = snapshot.capabilities; + return buildContextPack({ + generatedAt: (request.generatedAt ?? now).toISOString(), + level: request.level, + purpose: request.purpose, + snapshot: { + schemaEpoch: snapshot.project.manifest.epoch, + taskRevision: null, + requirementsDigest: null, + reviewDigest: null, + verificationDigest: null, + ownershipEpoch: null, + coordinationRevision: + capabilities.remoteRevision ?? snapshot.project.config.revision, + }, + budgetLimit: resolveBudgetLimit(request), + sections: [ + runtimeSection('/session', sessionProjection(session), true), + runtimeSection( + '/activeTask', + { taskRef: repair.taskRef, state: repair.state }, + true, + ), + derivedSection('/conflicts', repair.issues, [snapshot.fingerprint], true), + runtimeSection('/capabilities', capabilities, true), + runtimeSection( + '/transportFreshness', + transportFreshnessProjection(capabilities), + true, + ), + ], + }); +} + +function resolveBudgetLimit(request: ContextResolveRequest): number { + if (request.budgetLimit !== undefined) { + if (!Number.isSafeInteger(request.budgetLimit) || request.budgetLimit < 0) { + throw new Error('context resolver budgetLimit must be non-negative'); + } + return request.budgetLimit; + } + if (request.level === 'full') return Number.MAX_SAFE_INTEGER; + return defaultContextPackBudget(request.level); +} + +function sessionProjection(session: SessionStateV1 | null) { + if (session === null) { + return { + sessionId: null, + actorId: null, + client: null, + identitySource: null, + activeTaskRef: null, + activeMode: null, + }; + } + return { + sessionId: session.sessionId, + actorId: session.actorId, + client: session.client, + identitySource: session.identitySource, + activeTaskRef: session.activeTaskRef, + activeMode: session.activeMode, + }; +} + +function activeTaskProjection(task: StoredTaskSnapshot) { + return { + taskRef: task.metadata.taskRef, + workflowMode: task.metadata.workflowMode, + visibility: task.metadata.visibility, + coordination: task.metadata.coordination, + status: task.metadata.status, + revision: task.metadata.revision, + currentStep: task.metadata.currentStep, + ownerActorId: task.metadata.ownerActorId, + implementationScope: task.metadata.implementationScope, + governance: task.metadata.governance, + plan: task.plan?.artifactRef ?? null, + latestCheckpointRef: task.metadata.latestCheckpointRef, + }; +} + +function activeTaskSection(task: StoredTaskSnapshot): ContextPackSectionInput { + const targetJsonPointer = '/activeTask'; + const provenance: ProvenanceEntry[] = [ + entityProvenance( + targetJsonPointer, + task.metadata.taskRef, + 'workflow', + task.metadata.revision, + workflowMetadataDigest(task.metadata), + ), + ]; + if (task.plan !== null) { + provenance.push({ + targetJsonPointer, + sourceKind: 'artifact', + taskRef: task.metadata.taskRef, + artifactRef: task.plan.artifactRef, + entityKey: null, + sourceRevision: null, + sourceDigest: task.plan.digest, + selectedJsonPointers: ['/plan'], + redactions: [], + }); + } + return { + targetJsonPointer, + value: activeTaskProjection(task), + required: true, + provenance, + }; +} + +function collaborationProjection(task: StoredTaskSnapshot) { + return { + coordination: task.metadata.coordination, + ownerActorId: task.metadata.ownerActorId, + ownershipEpoch: task.metadata.ownershipEpoch, + participants: task.metadata.participants, + implementationScope: task.metadata.implementationScope, + }; +} + +function projectProjection(project: StoredProjectSnapshot) { + return { + workspaceId: project.config.workspaceId, + configRevision: project.config.revision, + policyRevision: project.policy.revision, + transport: project.config.transport, + teamPolicy: project.policy.policy, + defaultVisibility: project.policy.defaultVisibility, + schemaEpoch: project.manifest.epoch, + activationState: project.manifest.activationState, + facts: project.projectFacts, + confirmedDecisions: project.confirmedDecisions, + }; +} + +function projectSection( + project: StoredProjectSnapshot, +): ContextPackSectionInput { + const targetJsonPointer = '/project'; + const provenance: ProvenanceEntry[] = [ + entityProvenance( + targetJsonPointer, + null, + 'project-config', + project.config.revision, + digestCanonicalJson(project.config), + ), + entityProvenance( + targetJsonPointer, + null, + 'team-policy', + project.policy.revision, + digestCanonicalJson(project.policy), + ), + entityProvenance( + targetJsonPointer, + null, + 'schema-manifest', + 0, + digestCanonicalJson(project.manifest), + ), + ]; + if (project.projectFacts !== null) { + provenance.push( + entityProvenance( + targetJsonPointer, + null, + 'project-facts', + project.projectFacts.revision, + digestCanonicalJson(project.projectFacts), + ), + ); + } + for (const decision of project.confirmedDecisions) { + provenance.push( + entityProvenance( + targetJsonPointer, + decision.taskRef, + `confirmed-decision:${decision.decisionId}`, + 1, + digestCanonicalJson(decision), + ), + ); + } + return { + targetJsonPointer, + value: projectProjection(project), + provenance, + }; +} + +function transportFreshnessProjection( + capabilities: CoordinationCapabilitiesV1, +) { + return { + transport: capabilities.transport, + freshness: capabilities.transportFreshness, + lastSuccessfulSyncAt: capabilities.lastSuccessfulSyncAt, + remoteRevision: capabilities.remoteRevision, + }; +} + +function parentFreshnessSection( + snapshot: ResolverReadSnapshot, +): ContextPackSectionInput | null { + if (snapshot.task.metadata.parent === null) return null; + const value = + snapshot.parentReadError !== null + ? { state: 'unavailable', reasons: ['parent_unavailable'] } + : { + state: snapshot.parent?.staleReasons.length === 0 ? 'fresh' : 'stale', + reasons: snapshot.parent?.staleReasons ?? [], + }; + return derivedSection('/parentFreshness', value, [ + snapshot.task.fingerprint, + snapshot.parent?.fingerprint ?? 'parent:unavailable', + ]); +} + +function latestCheckpointSection( + task: StoredTaskSnapshot, +): ContextPackSectionInput | null { + if (task.latestCheckpoint === null) return null; + return entitySection( + '/latestCheckpoint', + task.latestCheckpoint, + task.latestCheckpoint.taskRef, + 'checkpoint', + task.latestCheckpoint.taskRevision, + checkpointDigest(task.latestCheckpoint), + ); +} + +function latestHandoffSection( + coordination: StoredCoordinationSnapshot, +): ContextPackSectionInput | null { + const handoff = coordination.handoffs[0]; + if (handoff === undefined) return null; + return entitySection( + '/latestHandoff', + handoff, + handoff.taskRef, + 'handoff', + handoff.revision, + digestCanonicalJson(handoff), + ); +} + +function claimsSection(claims: ClaimV1[]): ContextPackSectionInput | null { + const activeClaims = claims.filter((claim) => claim.state === 'active'); + if (activeClaims.length === 0) return null; + return { + targetJsonPointer: '/claims', + value: activeClaims, + provenance: activeClaims.map((claim) => + entityProvenance( + '/claims', + claim.taskRef, + `claim:${claim.claimId}`, + claim.revision, + digestCanonicalJson(claim), + ), + ), + }; +} + +function runtimeSection( + targetJsonPointer: ContextPackSectionPointer, + value: unknown, + required = false, +): ContextPackSectionInput { + return { + targetJsonPointer, + value, + required, + provenance: [ + { + targetJsonPointer, + sourceKind: 'runtime', + taskRef: null, + artifactRef: null, + entityKey: null, + sourceRevision: null, + sourceDigest: digestCanonicalJson(value), + selectedJsonPointers: [''], + redactions: [], + }, + ], + }; +} + +function entitySection( + targetJsonPointer: ContextPackSectionPointer, + value: unknown, + taskRef: TaskRef | null, + entityKey: string, + sourceRevision: number, + sourceDigest: string, + required = false, +): ContextPackSectionInput { + return { + targetJsonPointer, + value, + required, + provenance: [ + entityProvenance( + targetJsonPointer, + taskRef, + entityKey, + sourceRevision, + sourceDigest, + ), + ], + }; +} + +function entityProvenance( + targetJsonPointer: ContextPackSectionPointer, + taskRef: TaskRef | null, + entityKey: string, + sourceRevision: number, + sourceDigest: string, +): ProvenanceEntry { + return { + targetJsonPointer, + sourceKind: 'entity', + taskRef, + artifactRef: null, + entityKey, + sourceRevision, + sourceDigest, + selectedJsonPointers: [''], + redactions: [], + }; +} + +function derivedSection( + targetJsonPointer: ContextPackSectionPointer, + value: unknown, + sourceDigests: string[], + required = false, +): ContextPackSectionInput { + return { + targetJsonPointer, + value, + required, + provenance: [...new Set(sourceDigests)] + .sort(compareUtf8) + .map((sourceDigest) => ({ + targetJsonPointer, + sourceKind: 'derived', + taskRef: null, + artifactRef: null, + entityKey: null, + sourceRevision: null, + sourceDigest: /^sha256:[a-f0-9]{64}$/.test(sourceDigest) + ? sourceDigest + : digestCanonicalJson({ sourceDigest }), + selectedJsonPointers: [''], + redactions: [], + })), + }; +} + +function markPurposeRequired( + section: ContextPackSectionInput, + purpose: ContextPurpose, +): ContextPackSectionInput { + return PURPOSE_REQUIRED_SECTIONS[purpose].has(section.targetJsonPointer) + ? { ...section, required: true } + : section; +} + +function operationIds(records: PendingOperationRecord[]): string[] { + return [...new Set(records.map((record) => record.operationId))].sort( + compareUtf8, + ); +} + +function hasForeignActiveClaim(claims: ClaimV1[], actorId: string): boolean { + return claims.some( + (claim) => claim.state === 'active' && claim.ownerActorId !== actorId, + ); +} + +function dedupeIssues( + issues: ContextResolutionIssue[], +): ContextResolutionIssue[] { + const byCode = new Map(); + for (const issue of issues) { + const previous = byCode.get(issue.code); + byCode.set(issue.code, { + code: issue.code, + operationIds: [ + ...new Set([...(previous?.operationIds ?? []), ...issue.operationIds]), + ].sort(compareUtf8), + }); + } + return [...byCode.values()].sort((left, right) => + left.code.localeCompare(right.code, 'en'), + ); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} diff --git a/src/context/review-ledger.ts b/src/context/review-ledger.ts new file mode 100644 index 0000000..43d51a0 --- /dev/null +++ b/src/context/review-ledger.ts @@ -0,0 +1,740 @@ +import { + type ArtifactRef, + assertReferenceNamespace, + parseArtifactRef, +} from './artifact-ref.js'; +import { digestCanonicalJson, sortUtf8StringSet } from './canonical.js'; +import { type Ulid, assertUlid } from './ids.js'; +import { assertSharedTextSafe } from './privacy.js'; +import type { ItemIdentity } from './requirements-ledger.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export type ReviewDomain = 'quality' | 'security'; +export type ReviewLedgerStatus = + | 'pending' + | 'in_review' + | 'passed' + | 'blocked' + | 'skipped' + | 'stale'; +export type ReviewDomainStatus = + | 'pending' + | 'passed' + | 'blocked' + | 'manual_required' + | 'skipped'; +export type ReviewBlockerSeverity = 'p0' | 'p1' | 'p2' | 'legacy_unknown'; +export type ReviewBlockerStatus = 'open' | 'resolved' | 'waived'; + +export interface ReviewLedgerContext { + requirementsDigest: string; + planVersion: number; +} + +export interface ReviewLedgerV1 { + schemaVersion: 1; + canonicalizationVersion: 'mancode-jcs-v1'; + taskRef: TaskRef; + revision: number; + status: ReviewLedgerStatus; + depth: 'targeted' | 'full'; + requirementsDigest: string | null; + planVersion: number | null; + requiredDomains: ReviewDomain[]; + domains: Array<{ + domain: ReviewDomain; + status: ReviewDomainStatus; + reportRef: ArtifactRef | null; + }>; + blockers: Array< + ItemIdentity & { + blockerId: Ulid; + domain: ReviewDomain; + severity: ReviewBlockerSeverity; + status: ReviewBlockerStatus; + summary: string | null; + waiver: { + reason: string; + approvedByActorId: Ulid; + approvedAt: string; + } | null; + } + >; + remediationRound: number; + skip: { + reason: string; + approvedByActorId: Ulid | null; + approvedAt: string; + source: 'actor' | 'legacy_migration'; + } | null; + legacySource: { + sourceSchema: 'review-ledger-1.0'; + sourceDigest: string; + sourceRequirementsDigest: string | null; + fieldMapVersion: 1; + } | null; + contentDigest: string; + lastOperationId: Ulid | null; + updatedAt: string; +} + +const REVIEW_DOMAINS = new Set(['quality', 'security']); +const REVIEW_STATUSES = new Set([ + 'pending', + 'in_review', + 'passed', + 'blocked', + 'skipped', + 'stale', +]); +const DOMAIN_STATUSES = new Set([ + 'pending', + 'passed', + 'blocked', + 'manual_required', + 'skipped', +]); +const BLOCKER_SEVERITIES = new Set([ + 'p0', + 'p1', + 'p2', + 'legacy_unknown', +]); +const BLOCKER_STATUSES = new Set([ + 'open', + 'resolved', + 'waived', +]); +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function parseReviewLedger(value: unknown): ReviewLedgerV1 { + assertRecord(value, 'review ledger'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'canonicalizationVersion', + 'taskRef', + 'revision', + 'status', + 'depth', + 'requirementsDigest', + 'planVersion', + 'requiredDomains', + 'domains', + 'blockers', + 'remediationRound', + 'skip', + 'legacySource', + 'contentDigest', + 'lastOperationId', + 'updatedAt', + ], + 'review ledger', + ); + if (value.schemaVersion !== 1) { + throw new Error('review ledger schemaVersion must be 1'); + } + if (value.canonicalizationVersion !== 'mancode-jcs-v1') { + throw new Error('review ledger canonicalizationVersion is invalid'); + } + if (value.depth !== 'targeted' && value.depth !== 'full') { + throw new Error('review ledger depth is invalid'); + } + const taskRef = parseTaskRefValue(value.taskRef); + const ledger: ReviewLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef, + revision: parsePositiveInteger(value.revision, 'review ledger revision'), + status: parseReviewStatus(value.status), + depth: value.depth, + requirementsDigest: parseDigestOrNull( + value.requirementsDigest, + 'review ledger requirementsDigest', + ), + planVersion: parsePositiveIntegerOrNull( + value.planVersion, + 'review ledger planVersion', + ), + requiredDomains: parseReviewDomainSet( + value.requiredDomains, + 'review ledger requiredDomains', + ), + domains: parseDomains(value.domains, taskRef), + blockers: parseBlockers(value.blockers, taskRef), + remediationRound: parseNonNegativeInteger( + value.remediationRound, + 'review ledger remediationRound', + ), + skip: parseSkip(value.skip, taskRef), + legacySource: parseLegacySource(value.legacySource), + contentDigest: parseDigest( + value.contentDigest, + 'review ledger contentDigest', + ), + lastOperationId: parseUlidOrNull( + value.lastOperationId, + 'review ledger lastOperationId', + ), + updatedAt: parseTimestamp(value.updatedAt, 'review ledger updatedAt'), + }; + assertReviewLedgerShape(ledger); + if (ledger.contentDigest !== reviewLedgerDigest(ledger)) { + throw new Error( + 'review ledger contentDigest does not match canonical content', + ); + } + assertStoredReviewStatus(ledger); + return ledger; +} + +export function reviewLedgerDigest(ledger: ReviewLedgerV1): string { + return digestCanonicalJson({ + schemaVersion: ledger.schemaVersion, + canonicalizationVersion: ledger.canonicalizationVersion, + taskRef: ledger.taskRef, + status: ledger.status, + depth: ledger.depth, + requirementsDigest: ledger.requirementsDigest, + planVersion: ledger.planVersion, + requiredDomains: ledger.requiredDomains, + domains: ledger.domains, + blockers: ledger.blockers, + remediationRound: ledger.remediationRound, + skip: ledger.skip, + legacySource: ledger.legacySource, + }); +} + +export function deriveReviewLedgerStatus( + ledger: ReviewLedgerV1, + context?: ReviewLedgerContext, +): ReviewLedgerStatus { + if (context !== undefined && isReviewStale(ledger, context)) return 'stale'; + if (ledger.skip !== null) return 'skipped'; + if ( + ledger.blockers.some((blocker) => blocker.status === 'open') || + ledger.domains.some((domain) => domain.status === 'blocked') + ) { + return 'blocked'; + } + if (ledger.domains.every((domain) => domain.status === 'passed')) { + return 'passed'; + } + if (ledger.domains.every((domain) => domain.status === 'pending')) { + return 'pending'; + } + return 'in_review'; +} + +export function assertReviewLedgerAgainstContext( + ledger: ReviewLedgerV1, + context: ReviewLedgerContext, +): void { + parseDigest( + context.requirementsDigest, + 'review ledger context requirementsDigest', + ); + parsePositiveInteger( + context.planVersion, + 'review ledger context planVersion', + ); + if (ledger.legacySource !== null && hasLegacyCompatibilityGap(ledger)) { + throw new Error( + 'legacy review ledger with missing requirementsDigest or planVersion requires the legacy compatibility gate', + ); + } + const expectedStatus = deriveReviewLedgerStatus(ledger, context); + if (ledger.status !== expectedStatus) { + throw new Error( + `review ledger status must be ${expectedStatus} for the current aggregate`, + ); + } +} + +export function assertReviewLedgerTransition( + previous: ReviewLedgerV1, + next: ReviewLedgerV1, +): void { + if (next.revision !== previous.revision + 1) { + throw new Error( + 'review ledger revision must increase exactly once per mutation', + ); + } + if ( + previous.schemaVersion !== next.schemaVersion || + previous.canonicalizationVersion !== next.canonicalizationVersion || + !sameTaskRef(previous.taskRef, next.taskRef) + ) { + throw new Error('review ledger schema and TaskRef are immutable'); + } + if (next.remediationRound < previous.remediationRound) { + throw new Error('review ledger remediationRound cannot decrease'); + } + if (next.remediationRound > previous.remediationRound + 1) { + throw new Error( + 'review ledger remediationRound can increase by at most one', + ); + } + if (previous.legacySource === null && next.legacySource !== null) { + throw new Error('review ledger cannot introduce a legacy source'); + } + if (!allowedReviewTransitions(previous.status).has(next.status)) { + throw new Error( + `invalid review ledger status transition: ${previous.status} -> ${next.status}`, + ); + } +} + +function assertReviewLedgerShape(ledger: ReviewLedgerV1): void { + assertUniqueBlockerIdentity(ledger.blockers); + if (ledger.legacySource === null) { + if (ledger.requirementsDigest === null || ledger.planVersion === null) { + throw new Error( + 'native review ledger requires requirementsDigest and planVersion', + ); + } + } + if (ledger.skip !== null) { + if ( + ledger.requiredDomains.length !== 0 || + ledger.domains.length !== 0 || + ledger.blockers.length !== 0 || + ledger.remediationRound !== 0 + ) { + throw new Error( + 'a skipped review cannot have required domains, reports, blockers, or remediation', + ); + } + return; + } + const expectedDomains = ledger.depth === 'targeted' ? 1 : REVIEW_DOMAINS.size; + if (ledger.requiredDomains.length !== expectedDomains) { + throw new Error( + `review ledger ${ledger.depth} depth has an invalid required domain count`, + ); + } + if (ledger.domains.length !== ledger.requiredDomains.length) { + throw new Error( + 'review ledger must contain exactly one domain record per required domain', + ); + } + const domainNames = new Set(ledger.domains.map((domain) => domain.domain)); + for (const requiredDomain of ledger.requiredDomains) { + if (!domainNames.has(requiredDomain)) { + throw new Error('review ledger is missing a required domain record'); + } + } + for (const blocker of ledger.blockers) { + if (!domainNames.has(blocker.domain)) { + throw new Error('review ledger blocker must target a required domain'); + } + } +} + +function assertStoredReviewStatus(ledger: ReviewLedgerV1): void { + if (ledger.status === 'stale') return; + const expectedStatus = deriveReviewLedgerStatus(ledger); + if (ledger.status !== expectedStatus) { + throw new Error( + `review ledger status must be derived as ${expectedStatus} from its content`, + ); + } +} + +function isReviewStale( + ledger: ReviewLedgerV1, + context: ReviewLedgerContext, +): boolean { + return ( + ledger.requirementsDigest !== context.requirementsDigest || + ledger.planVersion !== context.planVersion + ); +} + +function hasLegacyCompatibilityGap(ledger: ReviewLedgerV1): boolean { + return ledger.requirementsDigest === null || ledger.planVersion === null; +} + +function parseReviewStatus(value: unknown): ReviewLedgerStatus { + if ( + typeof value !== 'string' || + !REVIEW_STATUSES.has(value as ReviewLedgerStatus) + ) { + throw new Error('review ledger status is invalid'); + } + return value as ReviewLedgerStatus; +} + +function parseReviewDomainSet(value: unknown, label: string): ReviewDomain[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + for (const item of value) { + if (typeof item !== 'string' || !REVIEW_DOMAINS.has(item as ReviewDomain)) { + throw new Error(`${label} contains an invalid review domain`); + } + } + const normalized = sortUtf8StringSet(value); + if (normalized.length !== value.length) { + throw new Error(`${label} must not contain duplicates`); + } + return normalized as ReviewDomain[]; +} + +function parseDomains( + value: unknown, + taskRef: TaskRef, +): ReviewLedgerV1['domains'] { + if (!Array.isArray(value)) { + throw new Error('review ledger domains must be an array'); + } + const domains = new Set(); + return value.map((item) => { + assertRecord(item, 'review ledger domain'); + assertKnownKeys( + item, + ['domain', 'status', 'reportRef'], + 'review ledger domain', + ); + if ( + typeof item.domain !== 'string' || + !REVIEW_DOMAINS.has(item.domain as ReviewDomain) + ) { + throw new Error('review ledger domain is invalid'); + } + const domain = item.domain as ReviewDomain; + if (domains.has(domain)) { + throw new Error('review ledger domain records must be unique'); + } + domains.add(domain); + if ( + typeof item.status !== 'string' || + !DOMAIN_STATUSES.has(item.status as ReviewDomainStatus) + ) { + throw new Error('review ledger domain status is invalid'); + } + const reportRef = + item.reportRef === null + ? null + : parseReviewReportRef(item.reportRef, taskRef); + if (item.status === 'pending' && reportRef !== null) { + throw new Error('pending review domains cannot have a reportRef'); + } + return { + domain, + status: item.status as ReviewDomainStatus, + reportRef, + }; + }); +} + +function parseReviewReportRef(value: unknown, taskRef: TaskRef): ArtifactRef { + const artifactRef = parseArtifactRef(value); + assertReferenceNamespace(taskRef.namespace, artifactRef); + if ( + artifactRef.kind !== 'review_report' || + !sameTaskRef(artifactRef.taskRef, taskRef) + ) { + throw new Error( + 'review ledger reportRef must be a review_report for the same task', + ); + } + return artifactRef; +} + +function parseBlockers( + value: unknown, + taskRef: TaskRef, +): ReviewLedgerV1['blockers'] { + if (!Array.isArray(value)) { + throw new Error('review ledger blockers must be an array'); + } + return value.map((item) => { + const identity = parseItemIdentity(item, 'review ledger blocker'); + assertRecord(item, 'review ledger blocker'); + assertKnownKeys( + item, + [ + 'displayId', + 'legacyId', + 'blockerId', + 'domain', + 'severity', + 'status', + 'summary', + 'waiver', + ], + 'review ledger blocker', + ); + assertUlid(item.blockerId, 'review ledger blockerId'); + if ( + typeof item.domain !== 'string' || + !REVIEW_DOMAINS.has(item.domain as ReviewDomain) + ) { + throw new Error('review ledger blocker domain is invalid'); + } + if ( + typeof item.severity !== 'string' || + !BLOCKER_SEVERITIES.has(item.severity as ReviewBlockerSeverity) + ) { + throw new Error('review ledger blocker severity is invalid'); + } + if ( + typeof item.status !== 'string' || + !BLOCKER_STATUSES.has(item.status as ReviewBlockerStatus) + ) { + throw new Error('review ledger blocker status is invalid'); + } + const waiver = parseWaiver(item.waiver); + const status = item.status as ReviewBlockerStatus; + const severity = item.severity as ReviewBlockerSeverity; + if (status === 'waived') { + if (waiver === null) { + throw new Error('waived review blockers require a waiver'); + } + if (severity === 'p0' || severity === 'legacy_unknown') { + throw new Error( + 'p0 and legacy_unknown review blockers cannot be waived', + ); + } + } else if (waiver !== null) { + throw new Error('only waived review blockers may contain a waiver'); + } + const summary = parseNonEmptyStringOrNull( + item.summary, + 'review ledger blocker summary', + ); + if (taskRef.namespace === 'shared' && summary !== null) { + assertSharedTextSafe(summary, 'review ledger blocker summary'); + } + if (taskRef.namespace === 'shared' && waiver !== null) { + assertSharedTextSafe( + waiver.reason, + 'review ledger blocker waiver reason', + ); + } + return { + ...identity, + blockerId: item.blockerId, + domain: item.domain as ReviewDomain, + severity, + status, + summary, + waiver, + }; + }); +} + +function parseWaiver( + value: unknown, +): ReviewLedgerV1['blockers'][number]['waiver'] { + if (value === null) return null; + assertRecord(value, 'review ledger blocker waiver'); + assertKnownKeys( + value, + ['reason', 'approvedByActorId', 'approvedAt'], + 'review ledger blocker waiver', + ); + assertUlid(value.approvedByActorId, 'review ledger waiver approvedByActorId'); + return { + reason: parseNonEmptyString(value.reason, 'review ledger waiver reason'), + approvedByActorId: value.approvedByActorId, + approvedAt: parseTimestamp( + value.approvedAt, + 'review ledger waiver approvedAt', + ), + }; +} + +function parseSkip(value: unknown, taskRef: TaskRef): ReviewLedgerV1['skip'] { + if (value === null) return null; + assertRecord(value, 'review ledger skip'); + assertKnownKeys( + value, + ['reason', 'approvedByActorId', 'approvedAt', 'source'], + 'review ledger skip', + ); + if (value.source !== 'actor' && value.source !== 'legacy_migration') { + throw new Error('review ledger skip source is invalid'); + } + const approvedByActorId = parseUlidOrNull( + value.approvedByActorId, + 'review ledger skip approvedByActorId', + ); + if (value.source === 'actor' && approvedByActorId === null) { + throw new Error('actor review skips require an approving actor'); + } + const reason = parseNonEmptyString(value.reason, 'review ledger skip reason'); + if (taskRef.namespace === 'shared') { + assertSharedTextSafe(reason, 'review ledger skip reason'); + } + return { + reason, + approvedByActorId, + approvedAt: parseTimestamp( + value.approvedAt, + 'review ledger skip approvedAt', + ), + source: value.source, + }; +} + +function parseLegacySource(value: unknown): ReviewLedgerV1['legacySource'] { + if (value === null) return null; + assertRecord(value, 'review ledger legacySource'); + assertKnownKeys( + value, + [ + 'sourceSchema', + 'sourceDigest', + 'sourceRequirementsDigest', + 'fieldMapVersion', + ], + 'review ledger legacySource', + ); + if ( + value.sourceSchema !== 'review-ledger-1.0' || + value.fieldMapVersion !== 1 + ) { + throw new Error('review ledger legacySource is invalid'); + } + return { + sourceSchema: 'review-ledger-1.0', + sourceDigest: parseDigest( + value.sourceDigest, + 'review ledger legacySource sourceDigest', + ), + sourceRequirementsDigest: parseDigestOrNull( + value.sourceRequirementsDigest, + 'review ledger legacySource sourceRequirementsDigest', + ), + fieldMapVersion: 1, + }; +} + +function assertUniqueBlockerIdentity( + blockers: ReviewLedgerV1['blockers'], +): void { + const displayIds = new Set(); + const blockerIds = new Set(); + for (const blocker of blockers) { + if (displayIds.has(blocker.displayId)) { + throw new Error('review ledger blocker displayIds must be unique'); + } + if (blockerIds.has(blocker.blockerId)) { + throw new Error('review ledger blockerIds must be unique'); + } + displayIds.add(blocker.displayId); + blockerIds.add(blocker.blockerId); + } +} + +function allowedReviewTransitions( + status: ReviewLedgerStatus, +): Set { + switch (status) { + case 'pending': + return new Set([ + 'pending', + 'in_review', + 'passed', + 'blocked', + 'skipped', + 'stale', + ]); + case 'in_review': + return new Set(['in_review', 'passed', 'blocked', 'skipped', 'stale']); + case 'passed': + return new Set(['passed', 'stale']); + case 'blocked': + return new Set(['blocked', 'in_review', 'passed', 'skipped', 'stale']); + case 'skipped': + return new Set(['skipped', 'pending', 'in_review', 'stale']); + case 'stale': + return new Set([ + 'stale', + 'pending', + 'in_review', + 'passed', + 'blocked', + 'skipped', + ]); + } +} + +function parseItemIdentity(value: unknown, label: string): ItemIdentity { + assertRecord(value, label); + if (typeof value.displayId !== 'string' || !value.displayId.trim()) { + throw new Error(`${label} displayId is required`); + } + if ( + value.legacyId !== null && + (typeof value.legacyId !== 'string' || !value.legacyId.trim()) + ) { + throw new Error(`${label} legacyId must be a non-empty string or null`); + } + return { displayId: value.displayId, legacyId: value.legacyId }; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parsePositiveIntegerOrNull( + value: unknown, + label: string, +): number | null { + if (value === null) return null; + return parsePositiveInteger(value, label); +} + +function parseNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseDigestOrNull(value: unknown, label: string): string | null { + if (value === null) return null; + return parseDigest(value, label); +} + +function parseUlidOrNull(value: unknown, label: string): Ulid | null { + if (value === null) return null; + assertUlid(value, label); + return value; +} + +function parseNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} is required`); + } + return value; +} + +function parseNonEmptyStringOrNull( + value: unknown, + label: string, +): string | null { + if (value === null) return null; + return parseNonEmptyString(value, label); +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} diff --git a/src/context/review-remediation.ts b/src/context/review-remediation.ts new file mode 100644 index 0000000..636cf3d --- /dev/null +++ b/src/context/review-remediation.ts @@ -0,0 +1,375 @@ +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, +} from '../runtime/operation-recovery-payload.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskHeadEntityKey, + writeTaskAuthorityFile, +} from '../runtime/task-operation.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, +} from './aggregate.js'; +import type { Ulid } from './ids.js'; +import { + type ReviewLedgerV1, + assertReviewLedgerAgainstContext, + assertReviewLedgerTransition, + parseReviewLedger, + reviewLedgerDigest, +} from './review-ledger.js'; +import { + assertTaskCodeHeadUnchanged, + markTaskVerificationStale, + nextTaskHeadFence, + taskMutationExpectedRevisions, +} from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import type { VerificationLedgerV1 } from './verification-ledger.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface ApplyV3ReviewLedgerInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + /** A complete review ledger evaluated against the current requirements/plan. */ + review: unknown; + operationId?: Ulid; + now?: Date; +} + +export interface AppliedV3ReviewLedger { + metadata: WorkflowMetadataV3; + review: ReviewLedgerV1; + verification: VerificationLedgerV1; + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1 | null; + operation: OperationJournalV1; +} + +/** + * Applies a current review result and invalidates all prior verification + * evidence. Review input is a complete ledger so the persisted digest covers + * the exact domains, blockers, reports, skips, and remediation round audited. + */ +export async function applyV3ReviewLedger( + input: ApplyV3ReviewLedgerInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + const submitted = parseReviewLedger(input.review); + if (!sameTaskRef(submitted.taskRef, taskRef)) { + throw new Error('MANCODE_REVIEW_TASK_REF_MISMATCH'); + } + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId: input.operationId, + extraEntityLocks: + taskRef.namespace === 'shared' ? [taskHeadEntityKey(taskRef)] : [], + now: input.now, + }); + let journal: OperationJournalV1 | null = null; + try { + assertReviewEligible(context.task.metadata, context.task.plan !== null); + const timestamp = context.now.toISOString(); + const review = createCurrentReview( + context.task.review, + submitted, + context.task.metadata, + context.operationId, + timestamp, + ); + const verification = markTaskVerificationStale( + context.task.verification, + context.operationId, + timestamp, + ); + const metadata = updateMetadata( + context.task.metadata, + review, + verification, + context.operationId, + timestamp, + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review, + verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: context.task.latestCheckpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + const authorization = reviewAuthorization(context, review); + + journal = await createTaskOperationJournal(context, { + type: 'review_remediation', + action: authorization.action, + evidence: authorization.evidence, + conditions: authorization.conditions, + expectedRevisions: taskMutationExpectedRevisions(context, [ + 'review', + 'verification', + ]), + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'write-review-ledger', + taskRef, + fileName: 'review-ledger.json', + beforeContent: serializeTaskAuthority(context.task.review), + targetContent: serializeTaskAuthority(review), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-verification-stale', + taskRef, + fileName: 'verification-ledger.json', + beforeContent: serializeTaskAuthority(context.task.verification), + targetContent: serializeTaskAuthority(verification), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'update-metadata', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(metadata), + }), + ...(taskHeadFence === null + ? [] + : [ + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ]), + ], + noOpStepIds: taskHeadFence === null ? ['update-task-head-fence'] : [], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + + journal = await advanceTaskOperation( + context, + journal, + 'write-review-ledger', + false, + ); + await writeTaskAuthorityFile( + context, + 'review-ledger.json', + serializeTaskAuthority(review), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'mark-verification-stale', + false, + ); + await writeTaskAuthorityFile( + context, + 'verification-ledger.json', + serializeTaskAuthority(verification), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-metadata', + false, + ); + await writeTaskAuthorityFile( + context, + 'metadata.json', + serializeTaskAuthority(metadata), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-task-head-fence', + false, + ); + if (taskHeadFence !== null) { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + } + const operation = await commitTaskOperation(context, journal); + return { + metadata, + review, + verification, + aggregate, + taskHeadFence, + operation, + }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A durable journal is already enough to block ordinary writes. + } + } + throw error; + } finally { + await context.release(); + } +} + +function assertReviewEligible( + metadata: WorkflowMetadataV3, + hasPlan: boolean, +): void { + if (metadata.workflowMode !== 'man' && metadata.workflowMode !== 'manteam') { + throw new Error('MANCODE_REVIEW_WORKFLOW_MODE_INVALID'); + } + if (metadata.status !== 'in_progress' && metadata.status !== 'blocked') { + throw new Error('MANCODE_REVIEW_WORKFLOW_NOT_ACTIVE'); + } + if ( + metadata.currentStep < 5 || + metadata.governance.planDecision !== 'governed_execution' || + !hasPlan + ) { + throw new Error('MANCODE_REVIEW_PLAN_GATE_REQUIRED'); + } +} + +function createCurrentReview( + previous: ReviewLedgerV1, + submitted: ReviewLedgerV1, + metadata: WorkflowMetadataV3, + operationId: Ulid, + updatedAt: string, +): ReviewLedgerV1 { + if ( + submitted.requirementsDigest !== metadata.governance.requirementsDigest || + submitted.planVersion !== metadata.governance.planVersion + ) { + throw new Error('MANCODE_REVIEW_CONTEXT_STALE'); + } + const draft: ReviewLedgerV1 = { + ...submitted, + taskRef: previous.taskRef, + revision: previous.revision + 1, + contentDigest: '', + lastOperationId: operationId, + updatedAt, + }; + const next = parseReviewLedger({ + ...draft, + contentDigest: reviewLedgerDigest(draft), + }); + assertReviewLedgerTransition(previous, next); + assertReviewLedgerAgainstContext(next, { + requirementsDigest: metadata.governance.requirementsDigest, + planVersion: metadata.governance.planVersion, + }); + return next; +} + +function updateMetadata( + previous: WorkflowMetadataV3, + review: ReviewLedgerV1, + verification: VerificationLedgerV1, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'stable', + lastOperationId: operationId, + governance: { + ...previous.governance, + reviewStatus: review.status, + reviewLedgerDigest: review.contentDigest, + verificationStatus: verification.status, + verificationLedgerDigest: verification.contentDigest, + }, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function reviewAuthorization( + context: Awaited>, + next: ReviewLedgerV1, +): { + action: + | 'local_workflow_mutation' + | 'shared_ledger_evidence' + | 'review_skip_or_waiver'; + evidence?: { + assignedToActor: boolean; + restrictsWriteToAssignedItem: boolean; + }; + conditions?: { + reviewAction?: 'skip' | 'waiver'; + reviewSeverity?: 'p0' | 'p1' | 'p2' | 'legacy_unknown'; + reason?: string | null; + }; +} { + if (context.taskRef.namespace === 'local') { + return { action: 'local_workflow_mutation' }; + } + if (next.skip !== null) { + return { + action: 'review_skip_or_waiver', + conditions: { reviewAction: 'skip', reason: next.skip.reason }, + }; + } + const waivers = next.blockers.filter( + (blocker) => blocker.status === 'waived', + ); + if (waivers.length > 0) { + for (const blocker of waivers) { + if ( + blocker.severity === 'p0' || + blocker.severity === 'legacy_unknown' || + blocker.waiver === null + ) { + throw new Error('MANCODE_WAIVER_FORBIDDEN'); + } + } + const first = waivers[0]; + if (first?.waiver === null || first === undefined) { + throw new Error('MANCODE_WAIVER_FORBIDDEN'); + } + return { + action: 'review_skip_or_waiver', + conditions: { + reviewAction: 'waiver', + reviewSeverity: first.severity, + reason: first.waiver.reason, + }, + }; + } + return { + action: 'shared_ledger_evidence', + evidence: { + assignedToActor: + context.task.metadata.ownerActorId === context.session.actorId, + restrictsWriteToAssignedItem: true, + }, + }; +} diff --git a/src/context/schema.ts b/src/context/schema.ts new file mode 100644 index 0000000..069ae59 --- /dev/null +++ b/src/context/schema.ts @@ -0,0 +1,255 @@ +import { + type TaskNamespace, + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export type WorkflowMode = 'man' | 'manba' | 'manteam'; +export type Coordination = 'single' | 'team'; +export type WorkflowStatus = + | 'in_progress' + | 'planned' + | 'blocked' + | 'completed' + | 'abandoned' + | 'superseded'; +export type WorkflowTransitionOperation = 'ordinary' | 'publish' | 'promote'; + +export interface ParentWorkflowDescriptor { + taskRef: TaskRef; + workflowMode: WorkflowMode; + visibility: TaskNamespace; + coordination: Coordination; +} + +export interface WorkflowDescriptor { + workflowMode: WorkflowMode; + visibility: TaskNamespace; + coordination: Coordination; + parent: ParentWorkflowDescriptor | null; +} + +export interface WorkflowStatusTransition { + sourceTaskRef: TaskRef; + from: WorkflowStatus; + to: WorkflowStatus; + operation: WorkflowTransitionOperation; + successorTaskRef?: TaskRef | null; +} + +const WORKFLOW_MODES = new Set(['man', 'manba', 'manteam']); +const WORKFLOW_STATUSES = new Set([ + 'in_progress', + 'planned', + 'blocked', + 'completed', + 'abandoned', + 'superseded', +]); + +export function parseWorkflowMode(value: unknown): WorkflowMode { + if (typeof value !== 'string' || !WORKFLOW_MODES.has(value as WorkflowMode)) { + throw new Error('workflowMode must be man, manba, or manteam'); + } + return value as WorkflowMode; +} + +export function parseWorkflowStatus(value: unknown): WorkflowStatus { + if ( + typeof value !== 'string' || + !WORKFLOW_STATUSES.has(value as WorkflowStatus) + ) { + throw new Error('workflow status is invalid'); + } + return value as WorkflowStatus; +} + +/** Legacy readers may normalize mamba, but V3 writers must never emit it. */ +export function normalizeLegacyWorkflowMode( + value: unknown, +): WorkflowMode | null { + if (value === 'mamba') return 'manba'; + return typeof value === 'string' && WORKFLOW_MODES.has(value as WorkflowMode) + ? (value as WorkflowMode) + : null; +} + +export function parseWorkflowDescriptor(value: unknown): WorkflowDescriptor { + assertRecord(value, 'workflow descriptor'); + assertKnownKeys( + value, + ['workflowMode', 'visibility', 'coordination', 'parent'], + 'workflow descriptor', + ); + if (value.visibility !== 'local' && value.visibility !== 'shared') { + throw new Error('workflow visibility must be local or shared'); + } + if (value.coordination !== 'single' && value.coordination !== 'team') { + throw new Error('workflow coordination must be single or team'); + } + const descriptor: WorkflowDescriptor = { + workflowMode: parseWorkflowMode(value.workflowMode), + visibility: value.visibility, + coordination: value.coordination, + parent: + value.parent === null + ? null + : parseParentWorkflowDescriptor(value.parent), + }; + assertWorkflowDescriptor(descriptor); + return descriptor; +} + +export function assertWorkflowDescriptor(value: WorkflowDescriptor): void { + const { workflowMode, visibility, coordination, parent } = value; + if (workflowMode === 'man') { + if (coordination !== 'single' || parent !== null) { + throw new Error('man requires single coordination and no parent'); + } + return; + } + if (workflowMode === 'manteam') { + if (visibility !== 'shared' || coordination !== 'team' || parent !== null) { + throw new Error( + 'manteam requires shared visibility, team coordination, and no parent', + ); + } + return; + } + + if (visibility === 'local' && coordination === 'single') { + if (parent !== null) assertMatchingParent(parent, 'man', 'local', 'single'); + return; + } + if (visibility === 'shared' && coordination === 'single') { + if (parent === null) { + throw new Error('shared single manba requires a shared man parent'); + } + assertMatchingParent(parent, 'man', 'shared', 'single'); + return; + } + if (visibility === 'shared' && coordination === 'team') { + if (parent === null) { + throw new Error('shared team manba requires a shared manteam parent'); + } + assertMatchingParent(parent, 'manteam', 'shared', 'team'); + return; + } + throw new Error('local team manba is not a legal workflow combination'); +} + +export function assertWorkflowStatusTransition( + transition: WorkflowStatusTransition, +): void { + assertWorkflowStatus(transition.from); + assertWorkflowStatus(transition.to); + const successor = transition.successorTaskRef ?? null; + if (transition.from === transition.to) { + if (successor !== null) { + throw new Error('unchanged workflow status must not carry a successor'); + } + return; + } + if (!allowedTransitions(transition.from).has(transition.to)) { + throw new Error( + `invalid workflow status transition: ${transition.from} -> ${transition.to}`, + ); + } + if (transition.to !== 'superseded') { + if (successor !== null) { + throw new Error('only superseded workflows may carry a successor'); + } + return; + } + if ( + (transition.operation !== 'publish' && + transition.operation !== 'promote') || + successor === null + ) { + throw new Error( + 'superseded status requires a successful publish or promote successor', + ); + } + if ( + transition.sourceTaskRef.namespace !== 'local' || + successor.namespace !== 'shared' || + sameTaskRef(transition.sourceTaskRef, successor) + ) { + throw new Error( + 'publish/promote must supersede a local task with a new shared TaskRef', + ); + } +} + +function parseParentWorkflowDescriptor( + value: unknown, +): ParentWorkflowDescriptor { + assertRecord(value, 'workflow parent'); + assertKnownKeys( + value, + ['taskRef', 'workflowMode', 'visibility', 'coordination'], + 'workflow parent', + ); + if (value.visibility !== 'local' && value.visibility !== 'shared') { + throw new Error('workflow parent visibility must be local or shared'); + } + if (value.coordination !== 'single' && value.coordination !== 'team') { + throw new Error('workflow parent coordination must be single or team'); + } + const taskRef = parseTaskRefValue(value.taskRef); + if (taskRef.namespace !== value.visibility) { + throw new Error( + 'workflow parent TaskRef namespace must match parent visibility', + ); + } + return { + taskRef, + workflowMode: parseWorkflowMode(value.workflowMode), + visibility: value.visibility, + coordination: value.coordination, + }; +} + +function assertMatchingParent( + parent: ParentWorkflowDescriptor, + workflowMode: WorkflowMode, + visibility: TaskNamespace, + coordination: Coordination, +): void { + if ( + parent.workflowMode !== workflowMode || + parent.visibility !== visibility || + parent.coordination !== coordination + ) { + throw new Error( + 'workflow parent does not match the required inheritance contract', + ); + } +} + +function assertWorkflowStatus(value: unknown): asserts value is WorkflowStatus { + parseWorkflowStatus(value); +} + +function allowedTransitions(from: WorkflowStatus): Set { + switch (from) { + case 'in_progress': + return new Set([ + 'planned', + 'blocked', + 'completed', + 'abandoned', + 'superseded', + ]); + case 'planned': + return new Set(['in_progress', 'completed', 'abandoned', 'superseded']); + case 'blocked': + return new Set(['in_progress', 'abandoned', 'superseded']); + case 'completed': + case 'abandoned': + case 'superseded': + return new Set(); + } +} diff --git a/src/context/scope-change.ts b/src/context/scope-change.ts new file mode 100644 index 0000000..862fa0d --- /dev/null +++ b/src/context/scope-change.ts @@ -0,0 +1,811 @@ +import { createHash } from 'node:crypto'; +import { createClaim, updateClaim } from '../runtime/claim-store.js'; +import { resolveTaskEntityHomeStore } from '../runtime/entity-home-store.js'; +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + createCheckpointRecoveryAction, + createClaimRecoveryAction, + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, +} from '../runtime/operation-recovery-payload.js'; +import { + readCheckoutBranch, + readProjectRuntimeContext, +} from '../runtime/project-runtime.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + type OpenedV3TaskOperation, + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskEntityKey, + taskHeadEntityKey, + writeTaskAuthorityFile, + writeTaskCheckpoint, +} from '../runtime/task-operation.js'; +import { type CheckpointV1, parseCheckpoint } from '../team/checkpoints.js'; +import { + type ClaimV1, + assertClaimTransition, + parseClaim, +} from '../team/claims.js'; +import { + deriveClaimValidity, + evaluateClaimScopeSubset, +} from '../team/conflicts.js'; +import { capabilitiesFromProjectConfig } from '../team/transport.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, +} from './aggregate.js'; +import { digestCanonicalJson, sortUtf8StringSet } from './canonical.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { assertSharedTextSafe } from './privacy.js'; +import { V3ContextStore } from './store.js'; +import { + assertTaskCodeHeadUnchanged, + nextTaskHeadFence, +} from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface ChangeV3WorkflowScopeInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + /** JSON object with the complete replacement { include, exclude, modules }. */ + scope: unknown; + checkpointSummary?: string; + checkpointNextAction?: string; + checkpointId?: Ulid; + /** Optional explicit successor IDs, ordered by predecessor claim ID. */ + successorClaimIds?: Ulid[]; + operationId?: Ulid; + now?: Date; +} + +export interface ChangedV3WorkflowScope { + metadata: WorkflowMetadataV3; + checkpoint: CheckpointV1; + terminatedClaims: ClaimV1[]; + successorClaims: ClaimV1[]; + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1; + operation: OperationJournalV1; +} + +/** + * Replaces a shared team task's implementation scope without ever mutating a + * claim's immutable acquisition snapshot. Claims still contained by the new + * scope receive a fresh successor identity; other active claims are released. + */ +export async function changeV3WorkflowScope( + input: ChangeV3WorkflowScopeInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_SCOPE_CHANGE_REQUIRES_SHARED_TASK'); + } + const scope = normalizeImplementationScope(input.scope); + const now = input.now ?? new Date(); + const timestamp = now.toISOString(); + const checkpointId = input.checkpointId ?? createUlid(now.getTime()); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(checkpointId, 'scope change checkpointId'); + assertUlid(operationId, 'scope change operationId'); + + const opened = await openScopeChangeContext({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + scope, + checkpointId, + successorClaimIds: input.successorClaimIds, + operationId, + now, + }); + const { context, activeClaims, successorClaimIds, successorPredecessors } = + opened; + let journal: OperationJournalV1 | null = null; + try { + assertScopeChangeEligible(context, scope); + assertClaimsFreshForScopeChange(context, activeClaims); + const pendingMetadata = markScopeChangeOperationPending( + context.task.metadata, + context.operationId, + timestamp, + ); + const branch = (await readCheckoutBranch(context.projectRoot)) ?? 'HEAD'; + const checkpoint = buildScopeChangedCheckpoint( + context, + pendingMetadata, + checkpointId, + input.checkpointSummary, + input.checkpointNextAction, + branch, + timestamp, + ); + const metadata = completeScopeChangeMetadata( + pendingMetadata, + scope, + checkpoint, + context.operationId, + timestamp, + ); + const pendingSuccessorClaims = buildPendingSuccessorClaims( + successorPredecessors, + successorClaimIds, + metadata, + branch, + requireCodeHead(context), + context.operationId, + timestamp, + capabilitiesFromProjectConfig(context.project.config), + ); + const successorByPredecessor = new Map( + successorPredecessors.map((claim, index) => { + const successorClaimId = successorClaimIds[index]; + if (successorClaimId === undefined) { + throw new Error('MANCODE_SCOPE_SUCCESSOR_CLAIM_COUNT_INVALID'); + } + return [claim.claimId, successorClaimId]; + }), + ); + const terminatedClaims = activeClaims.map((claim) => + terminateClaimForScopeChange( + claim, + successorByPredecessor.get(claim.claimId) ?? null, + context.operationId, + timestamp, + ), + ); + const activeSuccessorClaims = pendingSuccessorClaims.map((claim) => + activateSuccessorClaim(claim, context.operationId, timestamp), + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: checkpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + if (taskHeadFence === null) { + throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + } + + journal = await createTaskOperationJournal(context, { + type: 'scope_change_reclaim', + action: 'task_complete_scope_change_child_merge', + expectedRevisions: scopeChangeExpectedRevisions( + context, + checkpointId, + activeClaims, + successorClaimIds, + ), + conditions: { completionGateSatisfied: true }, + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-task-operation-pending', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(pendingMetadata), + }), + createCheckpointRecoveryAction({ + stepId: 'write-scope-changed-checkpoint', + before: null, + checkpoint, + }), + ...pendingSuccessorClaims.map((claim) => + createClaimRecoveryAction({ + stepId: 'create-pending-successor-claims', + before: null, + claim, + }), + ), + ...terminatedClaims.map((claim, index) => { + const before = activeClaims[index]; + if (before === undefined) { + throw new Error('MANCODE_CLAIM_SET_CHANGED'); + } + return createClaimRecoveryAction({ + stepId: 'terminate-old-claims', + before, + claim, + }); + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'update-metadata-scope', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(pendingMetadata), + targetContent: serializeTaskAuthority(metadata), + }), + ...activeSuccessorClaims.map((claim, index) => { + const before = pendingSuccessorClaims[index]; + if (before === undefined) { + throw new Error('MANCODE_SCOPE_SUCCESSOR_CLAIM_COUNT_INVALID'); + } + return createClaimRecoveryAction({ + stepId: 'activate-successor-claims', + before, + claim, + }); + }), + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ], + noOpStepIds: [ + ...(pendingSuccessorClaims.length === 0 + ? ['create-pending-successor-claims'] + : []), + ...(terminatedClaims.length === 0 ? ['terminate-old-claims'] : []), + ...(activeSuccessorClaims.length === 0 + ? ['activate-successor-claims'] + : []), + ], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + + journal = await advanceTaskOperation( + context, + journal, + 'mark-task-operation-pending', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await writeTaskAuthorityFile( + context, + 'metadata.json', + `${JSON.stringify(pendingMetadata, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'write-scope-changed-checkpoint', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await writeTaskCheckpoint(context, checkpoint); + + journal = await advanceTaskOperation( + context, + journal, + 'create-pending-successor-claims', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + for (const claim of pendingSuccessorClaims) { + await createClaim(context.homeStore, claim); + } + + journal = await advanceTaskOperation( + context, + journal, + 'terminate-old-claims', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + for (const [index, claim] of terminatedClaims.entries()) { + const previous = activeClaims[index]; + if (previous === undefined) { + throw new Error('MANCODE_CLAIM_SET_CHANGED'); + } + await updateClaim(context.homeStore, claim, previous.revision); + } + + journal = await advanceTaskOperation( + context, + journal, + 'update-metadata-scope', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await writeTaskAuthorityFile( + context, + 'metadata.json', + `${JSON.stringify(metadata, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'activate-successor-claims', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + for (const claim of activeSuccessorClaims) { + await updateClaim(context.homeStore, claim, 1); + } + + journal = await advanceTaskOperation( + context, + journal, + 'update-task-head-fence', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + const operation = await commitTaskOperation(context, journal); + return { + metadata, + checkpoint, + terminatedClaims, + successorClaims: activeSuccessorClaims, + aggregate, + taskHeadFence, + operation, + }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A durable write intent leaves this task in the repair envelope. + } + } + throw error; + } finally { + await context.release(); + } +} + +interface ScopeChangeContextInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + scope: WorkflowMetadataV3['implementationScope']; + checkpointId: Ulid; + successorClaimIds: Ulid[] | undefined; + operationId: Ulid; + now: Date; +} + +interface OpenedScopeChangeContext { + context: OpenedV3TaskOperation; + activeClaims: ClaimV1[]; + successorPredecessors: ClaimV1[]; + successorClaimIds: Ulid[]; +} + +async function openScopeChangeContext( + input: ScopeChangeContextInput, +): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + const runtime = await readProjectRuntimeContext(input.projectRoot); + const store = new V3ContextStore(input.projectRoot); + const homeStore = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + input.taskRef, + ); + const preflight = await store.readCoordinationSnapshot( + input.taskRef, + homeStore, + ); + const preflightClaims = activeClaims(preflight.claims); + const preflightSuccessors = claimsWithinScope(preflightClaims, input.scope); + const preflightSuccessorIds = resolveSuccessorClaimIds( + preflightSuccessors, + input.successorClaimIds, + input.operationId, + input.now.toISOString(), + ); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef: input.taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId: input.operationId, + extraEntityLocks: [ + `checkpoint:${input.checkpointId}`, + taskHeadEntityKey(input.taskRef), + ...preflightClaims.map((claim) => `claim:${claim.claimId}`), + ...preflightSuccessorIds.map((claimId) => `claim:${claimId}`), + ], + now: input.now, + }); + const lockedClaims = activeClaims(context.coordination.claims); + if (sameClaimIdSet(preflightClaims, lockedClaims)) { + const successors = claimsWithinScope(lockedClaims, input.scope); + const successorIds = resolveSuccessorClaimIds( + successors, + input.successorClaimIds, + input.operationId, + input.now.toISOString(), + ); + return { + context, + activeClaims: lockedClaims, + successorPredecessors: successors, + successorClaimIds: successorIds, + }; + } + await context.release(); + } + throw new Error('MANCODE_CLAIM_SET_CHANGED'); +} + +function assertScopeChangeEligible( + context: OpenedV3TaskOperation, + scope: WorkflowMetadataV3['implementationScope'], +): void { + const metadata = context.task.metadata; + if (context.project.config.transport.mode !== 'local') { + throw new Error('MANCODE_GIT_REF_TRANSPORT_NOT_IMPLEMENTED'); + } + if (metadata.workflowMode !== 'manteam') { + throw new Error('MANCODE_SCOPE_CHANGE_WORKFLOW_MODE_INVALID'); + } + if (metadata.status !== 'in_progress') { + throw new Error('MANCODE_SCOPE_CHANGE_WORKFLOW_NOT_ACTIVE'); + } + if (metadata.ownerActorId !== context.session.actorId) { + throw new Error('MANCODE_TASK_OWNER_REQUIRED'); + } + if (metadata.parent !== null) { + throw new Error('MANCODE_PARENT_SCOPE_INHERITANCE_REQUIRED'); + } + if (metadata.implementationScope.digest === scope.digest) { + throw new Error('MANCODE_SCOPE_CHANGE_NOOP'); + } +} + +function assertClaimsFreshForScopeChange( + context: OpenedV3TaskOperation, + claims: ClaimV1[], +): void { + const codeHead = requireCodeHead(context); + for (const claim of claims) { + if (claim.authority.mode !== 'local') { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } + const validity = deriveClaimValidity(claim, { + taskRef: context.taskRef, + taskRevision: context.task.metadata.revision, + implementationScopeDigest: + context.task.metadata.implementationScope.digest, + ownershipEpoch: context.task.metadata.ownershipEpoch, + codeRefHead: codeHead, + now: context.now, + transportFreshness: 'fresh', + }); + if (validity !== 'fresh') { + throw new Error('MANCODE_CLAIM_REVALIDATION_REQUIRED'); + } + } +} + +function normalizeImplementationScope( + value: unknown, +): WorkflowMetadataV3['implementationScope'] { + assertRecord(value, 'workflow scope change'); + assertKnownKeys( + value, + ['include', 'exclude', 'modules'], + 'workflow scope change', + ); + const scope = { + source: 'explicit' as const, + include: normalizeScopeValues(value.include, 'include', true), + exclude: normalizeScopeValues(value.exclude, 'exclude', true), + modules: normalizeScopeValues(value.modules, 'modules', false), + }; + return { ...scope, digest: digestCanonicalJson(scope) }; +} + +function normalizeScopeValues( + value: unknown, + label: string, + paths: boolean, +): string[] { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== 'string' || !item.trim()) + ) { + throw new Error(`MANCODE_SCOPE_${label.toUpperCase()}_INVALID`); + } + const values = value.map((item) => (item as string).trim()); + const normalized = sortUtf8StringSet(values); + if (normalized.length !== values.length) { + throw new Error(`MANCODE_SCOPE_${label.toUpperCase()}_DUPLICATE`); + } + for (const item of normalized) { + assertSharedTextSafe(item, `workflow scope ${label}`); + if (paths) assertScopePath(item); + } + return normalized; +} + +function assertScopePath(value: string): void { + if ( + value.startsWith('/') || + value.startsWith('~') || + /^[A-Za-z]:/.test(value) || + value.includes('\\') || + value + .split('/') + .some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw new Error('MANCODE_SCOPE_PATH_INVALID'); + } +} + +function markScopeChangeOperationPending( + previous: WorkflowMetadataV3, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'operation_pending', + lastOperationId: operationId, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function completeScopeChangeMetadata( + previous: WorkflowMetadataV3, + scope: WorkflowMetadataV3['implementationScope'], + checkpoint: CheckpointV1, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'stable', + implementationScope: scope, + latestCheckpointRef: { + taskRef: checkpoint.taskRef, + kind: 'checkpoint', + artifactId: checkpoint.checkpointId, + }, + lastOperationId: operationId, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function buildScopeChangedCheckpoint( + context: OpenedV3TaskOperation, + pendingMetadata: WorkflowMetadataV3, + checkpointId: Ulid, + summary: string | undefined, + nextAction: string | undefined, + branch: string, + timestamp: string, +): CheckpointV1 { + return parseCheckpoint({ + schemaVersion: 1, + checkpointId, + operationId: context.operationId, + taskRef: context.taskRef, + taskRevision: pendingMetadata.revision, + ownershipEpochAtOffer: pendingMetadata.ownershipEpoch, + kind: 'scope_changed', + git: { + branch, + head: requireCodeHead(context), + base: pendingMetadata.base?.head ?? null, + }, + summary: + summary ?? + 'Changed the implementation scope and replaced affected coordination claims.', + governance: { + requirementsDigest: context.task.requirements.contentDigest, + planVersion: pendingMetadata.governance.planVersion, + reviewLedgerDigest: context.task.review.contentDigest, + verificationLedgerDigest: context.task.verification.contentDigest, + }, + nextAction: + nextAction ?? + 'Re-read the updated scope and continue only with the successor claim.', + createdBy: { + actorId: context.session.actorId, + client: context.session.client, + }, + createdAt: timestamp, + }); +} + +function buildPendingSuccessorClaims( + predecessors: ClaimV1[], + successorClaimIds: Ulid[], + metadata: WorkflowMetadataV3, + branch: string, + codeHead: string, + operationId: Ulid, + timestamp: string, + capabilities: ReturnType, +): ClaimV1[] { + return predecessors.map((previous, index) => { + const claimId = successorClaimIds[index]; + if (claimId === undefined) { + throw new Error('MANCODE_SCOPE_SUCCESSOR_CLAIM_COUNT_INVALID'); + } + return parseClaim({ + ...previous, + claimId, + authority: { mode: 'local', remoteRevision: null }, + taskRevisionAtAcquire: metadata.revision, + lastValidatedTaskRevision: metadata.revision, + implementationScopeDigest: metadata.implementationScope.digest, + ownershipEpochAtAcquire: metadata.ownershipEpoch, + state: 'pending', + revision: 1, + codeRefAtAcquire: { branch, head: codeHead }, + lastValidatedCodeRef: { branch, head: codeHead }, + acquisitionEnforcement: capabilities.claimAcquisition, + writeGuard: capabilities.writeGuard, + predecessorClaimId: previous.claimId, + successorClaimId: null, + lastOperationId: operationId, + createdAt: timestamp, + updatedAt: timestamp, + }); + }); +} + +function terminateClaimForScopeChange( + previous: ClaimV1, + successorClaimId: Ulid | null, + operationId: Ulid, + timestamp: string, +): ClaimV1 { + const next = parseClaim({ + ...previous, + state: successorClaimId === null ? 'released' : 'transferred', + revision: previous.revision + 1, + successorClaimId, + lastOperationId: operationId, + updatedAt: timestamp, + }); + assertClaimTransition(previous, next); + return next; +} + +function activateSuccessorClaim( + previous: ClaimV1, + operationId: Ulid, + timestamp: string, +): ClaimV1 { + const next = parseClaim({ + ...previous, + state: 'active', + revision: previous.revision + 1, + lastOperationId: operationId, + updatedAt: timestamp, + }); + assertClaimTransition(previous, next); + return next; +} + +function scopeChangeExpectedRevisions( + context: OpenedV3TaskOperation, + checkpointId: Ulid, + predecessors: ClaimV1[], + successorClaimIds: Ulid[], +): Record { + const expected: Record = { + [taskEntityKey(context.taskRef)]: context.task.metadata.revision, + [`checkpoint:${checkpointId}`]: 0, + }; + for (const claim of predecessors) { + expected[`claim:${claim.claimId}`] = claim.revision; + } + for (const claimId of successorClaimIds) { + expected[`claim:${claimId}`] = 0; + } + const fence = context.coordination.taskHeadFence; + if (fence === null) throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + expected[taskHeadEntityKey(context.taskRef)] = fence.fenceRevision; + return expected; +} + +function activeClaims(claims: readonly ClaimV1[]): ClaimV1[] { + return claims + .filter((claim) => claim.state === 'active') + .sort((left, right) => compareUtf8(left.claimId, right.claimId)); +} + +function claimsWithinScope( + claims: ClaimV1[], + scope: WorkflowMetadataV3['implementationScope'], +): ClaimV1[] { + return claims.filter( + (claim) => + evaluateClaimScopeSubset(claim.scope, { + source: scope.source, + include: scope.include, + exclude: scope.exclude, + modules: scope.modules, + }).allowed, + ); +} + +function resolveSuccessorClaimIds( + predecessors: ClaimV1[], + requested: Ulid[] | undefined, + operationId: Ulid, + timestamp: string, +): Ulid[] { + const ids = + requested ?? + predecessors.map((claim) => + scopeSuccessorClaimId(operationId, claim.claimId, timestamp), + ); + if (ids.length !== predecessors.length) { + throw new Error('MANCODE_SCOPE_SUCCESSOR_CLAIM_COUNT_INVALID'); + } + const predecessorIds = new Set(predecessors.map((claim) => claim.claimId)); + const seen = new Set(); + for (const claimId of ids) { + assertUlid(claimId, 'scope successor claimId'); + if (predecessorIds.has(claimId) || seen.has(claimId)) { + throw new Error('MANCODE_SCOPE_SUCCESSOR_CLAIM_INVALID'); + } + seen.add(claimId); + } + return ids; +} + +/** Stable successor identity lets forward repair reconstruct the same claim. */ +export function scopeSuccessorClaimId( + operationId: Ulid, + predecessorClaimId: Ulid, + timestamp: string, +): Ulid { + assertUlid(operationId, 'scope change operationId'); + assertUlid(predecessorClaimId, 'scope predecessor claimId'); + const milliseconds = Date.parse(timestamp); + if (Number.isNaN(milliseconds)) { + throw new Error('MANCODE_SCOPE_TIMESTAMP_INVALID'); + } + const entropy = createHash('sha256') + .update(`scope-change:${operationId}:${predecessorClaimId}`, 'utf8') + .digest() + .subarray(0, 10); + return createUlid(milliseconds, entropy); +} + +function requireCodeHead(context: OpenedV3TaskOperation): string { + if (context.codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + } + return context.codeHead; +} + +function sameClaimIdSet(left: ClaimV1[], right: ClaimV1[]): boolean { + return ( + left.length === right.length && + left.every((claim, index) => claim.claimId === right[index]?.claimId) + ); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} diff --git a/src/context/solo-handoff.ts b/src/context/solo-handoff.ts new file mode 100644 index 0000000..2171d6a --- /dev/null +++ b/src/context/solo-handoff.ts @@ -0,0 +1,477 @@ +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { createTaskAuthorityFileRecoveryAction } from '../runtime/operation-recovery-payload.js'; +import { + completeProjectionIntent, + enqueueSessionPointerProjection, +} from '../runtime/projection-outbox.js'; +import { clearSessionTaskPointer, resumeSession } from '../runtime/session.js'; +import { + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskEntityKey, + writeTaskAuthorityFile, +} from '../runtime/task-operation.js'; +import { + type TaskAggregateManifestV1, + assertTaskCompletionGate, + buildTaskAggregateManifest, +} from './aggregate.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { requirementsAreReady } from './requirements-ledger.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface StartV3SoloHandoffInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + operationId?: Ulid; + now?: Date; +} + +export interface CompleteV3SoloHandoffInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + operationId?: Ulid; + now?: Date; +} + +export interface V3SoloHandoffResult { + metadata: WorkflowMetadataV3; + aggregate: TaskAggregateManifestV1; + operation: OperationJournalV1; + /** False means authority committed; only the local convenience projection failed. */ + sessionPointerUpdated: boolean; +} + +/** + * Makes a local single-owner man plan an explicitly assigned solo execution. + * The workflow assignment is durable authority; the session pointer is only a + * compensable local projection and never controls completion eligibility. + */ +export async function startV3SoloHandoff( + input: StartV3SoloHandoffInput, +): Promise { + const taskRef = assertLocalTaskRef(input.taskRef); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'solo handoff operationId'); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId, + now, + }); + let journal: OperationJournalV1 | null = null; + try { + assertSoloStartEligible(context); + const timestamp = context.now.toISOString(); + const metadata = activeSoloMetadata( + context.task.metadata, + context.session.sessionId, + context.operationId, + timestamp, + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: context.task.latestCheckpoint, + }); + const projection = await enqueueSessionPointerProjection( + context.projectRoot, + { + operationId: context.operationId, + action: 'resume', + sessionId: context.session.sessionId, + expectedPreviousTaskRef: context.session.activeTaskRef, + taskRef, + workflowMode: metadata.workflowMode, + taskRevision: metadata.revision, + now: context.now, + }, + ); + journal = await createSoloJournal(context, context.task.metadata, metadata); + journal = await advanceTaskOperation(context, journal, 'validate', true); + journal = await advanceTaskOperation( + context, + journal, + 'write-workflow-assignment', + false, + ); + await writeTaskAuthorityFile( + context, + 'metadata.json', + serializeTaskAuthority(metadata), + ); + journal = await advanceTaskOperation( + context, + journal, + 'update-session-pointer', + false, + ); + const operation = await commitTaskOperation(context, journal); + const sessionPointerUpdated = await resumeSoloSessionPointer( + context.projectRoot, + context.session.sessionId, + taskRef, + metadata, + context.now, + ); + if (sessionPointerUpdated) { + await completeProjectionAfterSuccess( + context.projectRoot, + projection, + context.now, + ); + } + return { metadata, aggregate, operation, sessionPointerUpdated }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A durable assignment intent blocks ordinary mutation until repair. + } + } + throw error; + } finally { + await context.release(); + } +} + +/** + * Re-runs the ordinary completion gate, then terminally records that the + * assigned solo session completed. It cannot bypass review or verification. + */ +export async function completeV3SoloHandoff( + input: CompleteV3SoloHandoffInput, +): Promise { + const taskRef = assertLocalTaskRef(input.taskRef); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'solo handoff operationId'); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId, + now, + }); + let journal: OperationJournalV1 | null = null; + try { + assertSoloCompletionEligible(context); + const timestamp = context.now.toISOString(); + const completionGateMetadata = completedSoloAssignmentMetadata( + context.task.metadata, + timestamp, + ); + const activeChildren = await context.store.listActiveChildTaskRefs(taskRef); + assertTaskCompletionGate( + { + metadata: completionGateMetadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: context.task.latestCheckpoint, + }, + { + activeChildTaskRefs: activeChildren, + hasPendingRepairOperation: false, + activeClaimCount: 0, + }, + ); + const metadata = completedSoloMetadata( + context.task.metadata, + context.operationId, + timestamp, + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: context.task.latestCheckpoint, + }); + const projection = await enqueueSessionPointerProjection( + context.projectRoot, + { + operationId: context.operationId, + action: 'clear', + sessionId: context.session.sessionId, + expectedPreviousTaskRef: context.session.activeTaskRef, + taskRef, + workflowMode: metadata.workflowMode, + taskRevision: metadata.revision, + now: context.now, + }, + ); + journal = await createSoloJournal(context, context.task.metadata, metadata); + journal = await advanceTaskOperation(context, journal, 'validate', true); + journal = await advanceTaskOperation( + context, + journal, + 'write-workflow-assignment', + false, + ); + await writeTaskAuthorityFile( + context, + 'metadata.json', + serializeTaskAuthority(metadata), + ); + journal = await advanceTaskOperation( + context, + journal, + 'update-session-pointer', + false, + ); + const operation = await commitTaskOperation(context, journal); + const sessionPointerUpdated = await clearSoloSessionPointer( + context.projectRoot, + context.session.sessionId, + taskRef, + context.now, + ); + if (sessionPointerUpdated) { + await completeProjectionAfterSuccess( + context.projectRoot, + projection, + context.now, + ); + } + return { metadata, aggregate, operation, sessionPointerUpdated }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A durable completion intent blocks ordinary mutation until repair. + } + } + throw error; + } finally { + await context.release(); + } +} + +async function completeProjectionAfterSuccess( + projectRoot: string, + projection: Awaited>, + now: Date, +): Promise { + try { + await completeProjectionIntent( + projectRoot, + projection.operationId, + projection.projectionId, + now, + ); + } catch { + // The session is already converged; doctor can close the pending intent. + } +} + +function createSoloJournal( + context: Awaited>, + before: WorkflowMetadataV3, + target: WorkflowMetadataV3, +): Promise { + return createTaskOperationJournal(context, { + type: 'solo_handoff', + action: 'local_workflow_mutation', + expectedRevisions: { + [taskEntityKey(context.taskRef)]: context.task.metadata.revision, + }, + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'write-workflow-assignment', + taskRef: context.taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(before), + targetContent: serializeTaskAuthority(target), + }), + ], + // Session state is a local convenience projection. It never authorizes + // the assignment or completion and can be repaired by `context resume`. + noOpStepIds: ['update-session-pointer'], + }, + }); +} + +function assertLocalTaskRef(taskRef: TaskRef): TaskRef { + const parsed = parseTaskRefValue(taskRef); + if (parsed.namespace !== 'local') { + throw new Error('MANCODE_SOLO_HANDOFF_LOCAL_ONLY'); + } + return parsed; +} + +function assertSoloStartEligible( + context: Awaited>, +): void { + const { metadata, requirements, plan } = context.task; + if ( + metadata.workflowMode !== 'man' || + metadata.coordination !== 'single' || + metadata.status !== 'in_progress' || + metadata.currentStep !== 4 || + metadata.governance.planDecision !== null || + metadata.soloExecution !== null || + metadata.ownerActorId !== context.session.actorId || + plan === null || + requirements.status !== 'confirmed' || + !requirementsAreReady(requirements) || + metadata.governance.requirementsStatus !== 'ready' || + metadata.governance.requirementsDigest !== requirements.contentDigest + ) { + throw new Error('MANCODE_SOLO_HANDOFF_NOT_ELIGIBLE'); + } +} + +function assertSoloCompletionEligible( + context: Awaited>, +): void { + const { metadata } = context.task; + if ( + metadata.workflowMode !== 'man' || + metadata.coordination !== 'single' || + metadata.status !== 'planned' || + metadata.governance.planDecision !== 'solo_handoff' || + metadata.soloExecution?.state !== 'active' || + metadata.soloExecution.assignedSessionId !== context.session.sessionId || + metadata.ownerActorId !== context.session.actorId + ) { + throw new Error('MANCODE_SOLO_HANDOFF_NOT_ACTIVE'); + } +} + +function activeSoloMetadata( + previous: WorkflowMetadataV3, + sessionId: Ulid, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + status: 'planned', + revision: previous.revision + 1, + transitionState: 'stable', + lastOperationId: operationId, + governance: { + ...previous.governance, + planDecision: 'solo_handoff', + }, + soloExecution: { + state: 'active', + planVersion: previous.governance.planVersion, + assignedSessionId: sessionId, + startedAt: updatedAt, + completedAt: null, + }, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function completedSoloMetadata( + previous: WorkflowMetadataV3, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const soloExecution = previous.soloExecution; + if (soloExecution === null || soloExecution.state !== 'active') { + throw new Error('MANCODE_SOLO_HANDOFF_NOT_ACTIVE'); + } + const next = parseWorkflowMetadata({ + ...previous, + status: 'completed', + currentStep: 9, + blockingReason: null, + revision: previous.revision + 1, + transitionState: 'stable', + lastOperationId: operationId, + soloExecution: { + ...soloExecution, + state: 'completed', + completedAt: updatedAt, + }, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function completedSoloAssignmentMetadata( + previous: WorkflowMetadataV3, + completedAt: string, +): WorkflowMetadataV3 { + const soloExecution = previous.soloExecution; + if (soloExecution === null || soloExecution.state !== 'active') { + throw new Error('MANCODE_SOLO_HANDOFF_NOT_ACTIVE'); + } + return parseWorkflowMetadata({ + ...previous, + soloExecution: { + ...soloExecution, + state: 'completed', + completedAt, + }, + }); +} + +async function resumeSoloSessionPointer( + projectRoot: string, + sessionId: Ulid, + taskRef: TaskRef, + metadata: WorkflowMetadataV3, + now: Date, +): Promise { + try { + await resumeSession(projectRoot, sessionId, { + taskRef, + workflowMode: metadata.workflowMode, + taskRevision: metadata.revision, + now, + }); + return true; + } catch { + return false; + } +} + +async function clearSoloSessionPointer( + projectRoot: string, + sessionId: Ulid, + taskRef: TaskRef, + now: Date, +): Promise { + try { + const session = await clearSessionTaskPointer(projectRoot, sessionId, { + expectedTaskRef: taskRef, + now, + }); + return session.activeTaskRef === null; + } catch { + return false; + } +} diff --git a/src/context/store.ts b/src/context/store.ts new file mode 100644 index 0000000..816c00d --- /dev/null +++ b/src/context/store.ts @@ -0,0 +1,745 @@ +import { lstat, readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { + type EntityHomeStore, + claimDirectory, + handoffDirectory, + operationDirectory, + reservationDirectory, + taskHeadDirectory, +} from '../runtime/entity-home-store.js'; +import { + type OperationJournalV1, + parseOperationJournal, +} from '../runtime/operation-journal.js'; +import { parseOperationReservation } from '../runtime/operation-reservation.js'; +import { + type TaskHeadFenceV1, + parseTaskHeadFence, +} from '../runtime/task-head-fence.js'; +import { type CheckpointV1, parseCheckpoint } from '../team/checkpoints.js'; +import { type ClaimV1, parseClaim } from '../team/claims.js'; +import { type HandoffV1, parseHandoff } from '../team/handoff.js'; +import { + type ProjectConfigV1, + type TeamPolicyV1, + parseProjectConfig, + parseTeamPolicy, +} from '../team/policy.js'; +import { + type TaskAggregateInput, + type TaskAggregateManifestV1, + buildTaskAggregateManifest, + taskAggregateDigest, +} from './aggregate.js'; +import type { ArtifactRef } from './artifact-ref.js'; +import { digestCanonicalJson } from './canonical.js'; +import { + type ConfirmedDecisionV1, + listConfirmedDecisions, +} from './confirmed-decision.js'; +import { assertUlid } from './ids.js'; +import { type SchemaManifestV1, parseSchemaManifest } from './manifest.js'; +import { + type ParentSnapshotSource, + parentSnapshotStaleReasons, +} from './parent-snapshot.js'; +import { type ProjectFactsV1, parseProjectFacts } from './project-facts.js'; +import { + type RequirementsLedgerV1, + parseRequirementsLedger, +} from './requirements-ledger.js'; +import { type ReviewLedgerV1, parseReviewLedger } from './review-ledger.js'; +import { type TaskLocation, locateTask, taskRootPath } from './task-locator.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import { + type VerificationLedgerV1, + parseVerificationLedger, +} from './verification-ledger.js'; +import { + type WorkflowMetadataV3, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface StoredArtifactText { + artifactRef: ArtifactRef; + content: string; + digest: string; +} + +/** + * Parsed task entities from one filesystem read pass. `aggregate` is nullable + * because the resolver must be able to return a deliberately partial repair + * envelope when a journal has left otherwise valid individual entities out of + * sync. + */ +export interface StoredTaskSnapshot { + location: TaskLocation; + metadata: WorkflowMetadataV3; + requirements: RequirementsLedgerV1; + review: ReviewLedgerV1; + verification: VerificationLedgerV1; + plan: StoredArtifactText | null; + latestCheckpoint: CheckpointV1 | null; + aggregate: TaskAggregateManifestV1 | null; + aggregateError: string | null; + fingerprint: string; +} + +export interface StoredProjectSnapshot { + manifest: SchemaManifestV1; + config: ProjectConfigV1; + policy: TeamPolicyV1; + projectFacts: ProjectFactsV1 | null; + confirmedDecisions: ConfirmedDecisionV1[]; + fingerprint: string; +} + +export interface PendingOperationRecord { + source: 'primary_journal' | 'secondary_reservation'; + operationId: string; + state: OperationJournalV1['state'] | null; + entityKeys: string[]; +} + +export interface StoredCoordinationSnapshot { + homeStore: EntityHomeStore; + taskHeadFence: TaskHeadFenceV1 | null; + claims: ClaimV1[]; + handoffs: HandoffV1[]; + pendingOperations: PendingOperationRecord[]; + fingerprint: string; +} + +export interface StoredParentSnapshot { + metadata: WorkflowMetadataV3; + staleReasons: ReturnType; + fingerprint: string; +} + +const JSON_SUFFIX = '.json'; + +/** + * File-backed V3 authority reader. Every authority path is fixed by schema; + * this class never accepts an arbitrary task-relative path from callers. + */ +export class V3ContextStore { + readonly projectRoot: string; + + constructor(projectRoot: string) { + if (typeof projectRoot !== 'string' || !projectRoot.trim()) { + throw new Error('context store projectRoot is required'); + } + this.projectRoot = path.resolve(projectRoot); + } + + async locateTask(requested: TaskRef | string): Promise { + const location = await locateTask(this.projectRoot, requested); + await assertSafeDirectoryWithin( + this.projectRoot, + path.relative(this.projectRoot, location.taskRoot), + ); + return location; + } + + async readTaskSnapshot(taskRef: TaskRef): Promise { + const location = await this.locateTask(taskRef); + const expectedTaskRef = parseTaskRefValue(taskRef); + if (!sameTaskRef(location.taskRef, expectedTaskRef)) { + throw new Error('MANCODE_CONTEXT_TASK_LOCATION_MISMATCH'); + } + const taskRoot = location.taskRoot; + const [metadata, requirements, review, verification, plan] = + await Promise.all([ + this.readRequiredJson(taskRoot, 'metadata.json', parseWorkflowMetadata), + this.readRequiredJson( + taskRoot, + 'requirements.json', + parseRequirementsLedger, + ), + this.readRequiredJson( + taskRoot, + 'review-ledger.json', + parseReviewLedger, + ), + this.readRequiredJson( + taskRoot, + 'verification-ledger.json', + parseVerificationLedger, + ), + this.readOptionalPlan(expectedTaskRef, taskRoot), + ]); + assertSnapshotTaskRefs(expectedTaskRef, { + metadata, + requirements, + review, + verification, + }); + const latestCheckpoint = await this.readLatestCheckpoint( + metadata, + taskRoot, + ); + const aggregateResult = tryBuildAggregate({ + metadata, + requirements, + review, + verification, + planDigest: plan?.digest ?? null, + latestCheckpoint, + }); + const fingerprint = digestCanonicalJson({ + taskRef: expectedTaskRef, + metadata, + requirements, + review, + verification, + planDigest: plan?.digest ?? null, + latestCheckpoint, + }); + return { + location, + metadata, + requirements, + review, + verification, + plan, + latestCheckpoint, + aggregate: aggregateResult.aggregate, + aggregateError: aggregateResult.error, + fingerprint, + }; + } + + async readProjectSnapshot(): Promise { + const [manifest, config, policy, projectFacts, confirmedDecisions] = + await Promise.all([ + this.readRequiredJson( + this.mancodeRoot(), + 'schema.json', + parseSchemaManifest, + ), + this.readRequiredJson( + this.mancodeRoot(), + path.join('shared', 'config.json'), + parseProjectConfig, + ), + this.readRequiredJson( + this.mancodeRoot(), + path.join('shared', 'team', 'policy.json'), + parseTeamPolicy, + ), + readOptionalJsonWithin( + this.mancodeRoot(), + path.join('shared', 'context', 'project.json'), + parseProjectFacts, + ), + listConfirmedDecisions(this.projectRoot), + ]); + return { + manifest, + config, + policy, + projectFacts, + confirmedDecisions, + fingerprint: digestCanonicalJson({ + manifest, + config, + policy, + projectFacts, + confirmedDecisions, + }), + }; + } + + async readParentSnapshot( + child: WorkflowMetadataV3, + ): Promise { + if (child.parent === null) return null; + const parentRef = child.parent.taskRef; + const parentRoot = taskRootPath(this.projectRoot, parentRef); + await assertSafeDirectoryWithin( + this.projectRoot, + path.relative(this.projectRoot, parentRoot), + ); + const metadata = await this.readRequiredJson( + parentRoot, + 'metadata.json', + parseWorkflowMetadata, + ); + const source: ParentSnapshotSource = { + taskRef: metadata.taskRef, + revision: metadata.revision, + planVersion: metadata.governance.planVersion, + requirementsDigest: metadata.governance.requirementsDigest, + implementationScopeDigest: metadata.implementationScope.digest, + visibility: metadata.visibility, + coordination: metadata.coordination, + }; + return { + metadata, + staleReasons: parentSnapshotStaleReasons(child.parent, source), + fingerprint: digestCanonicalJson({ metadata }), + }; + } + + async readCoordinationSnapshot( + taskRef: TaskRef, + homeStore: EntityHomeStore, + ): Promise { + const normalizedTaskRef = parseTaskRefValue(taskRef); + if (normalizedTaskRef.namespace === 'local') { + const pendingOperations = await this.readPendingOperations( + normalizedTaskRef, + homeStore, + ); + return { + homeStore, + taskHeadFence: null, + claims: [], + handoffs: [], + pendingOperations, + fingerprint: digestCanonicalJson({ + homeStore: homeStore.storeId, + pendingOperations, + }), + }; + } + const [taskHeadFence, claims, handoffs, pendingOperations] = + await Promise.all([ + this.readTaskHeadFence(homeStore, normalizedTaskRef), + this.readClaims(homeStore, normalizedTaskRef), + this.readHandoffs(homeStore, normalizedTaskRef), + this.readPendingOperations(normalizedTaskRef, homeStore), + ]); + return { + homeStore, + taskHeadFence, + claims, + handoffs, + pendingOperations, + fingerprint: digestCanonicalJson({ + homeStore: homeStore.storeId, + taskHeadFence, + claims, + handoffs, + pendingOperations, + }), + }; + } + + /** + * Lists non-terminal children from canonical metadata only. Callers that + * need a completion gate must hold the parent task lock while invoking it; + * V3 child creation takes that same parent lock before publishing. + */ + async listActiveChildTaskRefs(parentTaskRef: TaskRef): Promise { + const parent = parseTaskRefValue(parentTaskRef); + const children: TaskRef[] = []; + for (const namespace of ['local', 'shared'] as const) { + const directory = path.join('.mancode', namespace, 'workflows'); + const entries = await readDirectoryEntriesWithin( + this.projectRoot, + directory, + ); + for (const taskId of entries.sort(compareUtf8)) { + try { + assertUlid(taskId, 'workflow child directory'); + } catch { + throw new Error('MANCODE_CONTEXT_COLLECTION_ENTRY_INVALID'); + } + const taskRef: TaskRef = { namespace, taskId }; + const taskRoot = taskRootPath(this.projectRoot, taskRef); + await assertSafeDirectoryWithin( + this.projectRoot, + path.relative(this.projectRoot, taskRoot), + ); + const metadata = await this.readRequiredJson( + taskRoot, + 'metadata.json', + parseWorkflowMetadata, + ); + if ( + metadata.parent !== null && + sameTaskRef(metadata.parent.taskRef, parent) && + !isTerminalWorkflowStatus(metadata.status) + ) { + children.push(metadata.taskRef); + } + } + } + return children.sort( + (left, right) => + compareUtf8(left.namespace, right.namespace) || + compareUtf8(left.taskId, right.taskId), + ); + } + + private mancodeRoot(): string { + return path.join(this.projectRoot, '.mancode'); + } + + private async readOptionalPlan( + taskRef: TaskRef, + taskRoot: string, + ): Promise { + const content = await readOptionalTextWithin(taskRoot, 'plan.md'); + if (content === null) return null; + const artifactRef: ArtifactRef = { taskRef, kind: 'plan' }; + return { + artifactRef, + content, + digest: digestCanonicalJson({ artifactRef, content }), + }; + } + + private async readLatestCheckpoint( + metadata: WorkflowMetadataV3, + taskRoot: string, + ): Promise { + const ref = metadata.latestCheckpointRef; + if (ref === null) return null; + if (ref.kind !== 'checkpoint' || ref.artifactId === undefined) { + throw new Error('MANCODE_CONTEXT_CHECKPOINT_REFERENCE_INVALID'); + } + const checkpoint = await this.readRequiredJson( + taskRoot, + path.join('checkpoints', `${ref.artifactId}.json`), + parseCheckpoint, + ); + if (!sameTaskRef(checkpoint.taskRef, metadata.taskRef)) { + throw new Error('MANCODE_CONTEXT_CHECKPOINT_TASK_MISMATCH'); + } + return checkpoint; + } + + private async readTaskHeadFence( + homeStore: EntityHomeStore, + taskRef: TaskRef, + ): Promise { + const value = await readOptionalJsonWithin( + homeStore.root, + path.join( + path.relative(homeStore.root, taskHeadDirectory(homeStore)), + `${taskRef.taskId}.json`, + ), + parseTaskHeadFence, + ); + if (value === null) return null; + if (!sameTaskRef(value.taskRef, taskRef)) { + throw new Error('MANCODE_CONTEXT_TASK_HEAD_TASK_MISMATCH'); + } + return value; + } + + private async readClaims( + homeStore: EntityHomeStore, + taskRef: TaskRef, + ): Promise { + const directory = path.relative(homeStore.root, claimDirectory(homeStore)); + const claims = await readJsonCollectionWithin( + homeStore.root, + directory, + parseClaim, + ); + return claims + .filter((claim) => sameTaskRef(claim.taskRef, taskRef)) + .sort((left, right) => compareUtf8(left.claimId, right.claimId)); + } + + private async readHandoffs( + homeStore: EntityHomeStore, + taskRef: TaskRef, + ): Promise { + const directory = path.relative( + homeStore.root, + handoffDirectory(homeStore), + ); + const handoffs = await readJsonCollectionWithin( + homeStore.root, + directory, + parseHandoff, + ); + return handoffs + .filter((handoff) => sameTaskRef(handoff.taskRef, taskRef)) + .sort( + (left, right) => + Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || + compareUtf8(left.handoffId, right.handoffId), + ); + } + + private async readPendingOperations( + taskRef: TaskRef, + homeStore: EntityHomeStore, + ): Promise { + const taskKey = `task:${taskRef.namespace}:${taskRef.taskId}`; + const [journals, reservations] = await Promise.all([ + readJsonCollectionWithin( + homeStore.root, + path.relative(homeStore.root, operationDirectory(homeStore)), + parseOperationJournal, + ), + readJsonCollectionWithin( + homeStore.root, + path.relative(homeStore.root, reservationDirectory(homeStore)), + parseOperationReservation, + ), + ]); + const primary = journals + .filter( + (journal) => + journal.state !== 'committed' && + journal.state !== 'aborted' && + journal.entityLocks.includes(taskKey), + ) + .map((journal) => ({ + source: 'primary_journal', + operationId: journal.operationId, + state: journal.state, + entityKeys: journal.entityLocks, + })); + const secondary = reservations + .filter((reservation) => reservation.entityKeys.includes(taskKey)) + .map((reservation) => ({ + source: 'secondary_reservation', + operationId: reservation.operationId, + state: null, + entityKeys: reservation.entityKeys, + })); + return [...primary, ...secondary].sort( + (left, right) => + compareUtf8(left.operationId, right.operationId) || + left.source.localeCompare(right.source, 'en'), + ); + } + + private async readRequiredJson( + root: string, + relativePath: string, + parser: (value: unknown) => T, + ): Promise { + try { + return parser(JSON.parse(await readTextWithin(root, relativePath))); + } catch (error) { + if (isNotFound(error)) { + throw new Error(`MANCODE_CONTEXT_ENTITY_UNAVAILABLE: ${relativePath}`); + } + if (error instanceof SyntaxError) { + throw new Error(`MANCODE_CONTEXT_ENTITY_CORRUPT: ${relativePath}`); + } + throw error; + } + } +} + +function tryBuildAggregate(input: TaskAggregateInput): { + aggregate: TaskAggregateManifestV1 | null; + error: string | null; +} { + try { + return { aggregate: buildTaskAggregateManifest(input), error: null }; + } catch (error) { + return { + aggregate: null, + error: + error instanceof Error + ? error.message + : 'MANCODE_CONTEXT_AGGREGATE_INVALID', + }; + } +} + +function assertSnapshotTaskRefs( + expected: TaskRef, + input: Pick< + TaskAggregateInput, + 'metadata' | 'requirements' | 'review' | 'verification' + >, +): void { + const refs = [ + input.metadata.taskRef, + input.requirements.taskRef, + input.review.taskRef, + input.verification.taskRef, + ]; + if (refs.some((ref) => !sameTaskRef(ref, expected))) { + throw new Error('MANCODE_CONTEXT_TASK_REFERENCE_MISMATCH'); + } +} + +async function readOptionalJsonWithin( + root: string, + relativePath: string, + parser: (value: unknown) => T, +): Promise { + try { + return parser(JSON.parse(await readTextWithin(root, relativePath))); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error(`MANCODE_CONTEXT_ENTITY_CORRUPT: ${relativePath}`); + } + throw error; + } +} + +async function readJsonCollectionWithin( + root: string, + relativeDirectory: string, + parser: (value: unknown) => T, +): Promise { + const entries = await readDirectoryEntriesWithin(root, relativeDirectory); + const jsonEntries = entries.filter((entry) => entry.endsWith(JSON_SUFFIX)); + const values: T[] = []; + for (const entry of jsonEntries.sort(compareUtf8)) { + const stem = entry.slice(0, -JSON_SUFFIX.length); + if (!/^[0-7][0-9A-HJKMNPQRSTVWXYZ]{25}$/.test(stem)) { + throw new Error('MANCODE_CONTEXT_COLLECTION_ENTRY_INVALID'); + } + const value = await readOptionalJsonWithin( + root, + path.join(relativeDirectory, entry), + parser, + ); + if (value === null) { + throw new Error('MANCODE_CONTEXT_COLLECTION_CHANGED_DURING_READ'); + } + values.push(value); + } + return values; +} + +async function readOptionalTextWithin( + root: string, + relativePath: string, +): Promise { + try { + return await readTextWithin(root, relativePath); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +/** + * Uses lstat before and after the read and verifies every ancestor. This + * rejects links at every V3 authority boundary and detects replacement races + * that would otherwise let a resolver read outside its intended root. + */ +async function readTextWithin( + root: string, + relativePath: string, +): Promise { + const absoluteRoot = path.resolve(root); + const segments = safeRelativeSegments(relativePath); + await assertSafeDirectoryWithin(absoluteRoot, '.'); + const parentSegments = segments.slice(0, -1); + let current = absoluteRoot; + for (const segment of parentSegments) { + current = path.join(current, segment); + await assertSafeDirectoryAt(current); + } + const target = path.join(absoluteRoot, ...segments); + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_CONTEXT_PATH_UNSAFE'); + } + const content = await readFile(target, 'utf8'); + await assertSafeDirectoryWithin( + absoluteRoot, + parentSegments.length === 0 ? '.' : path.join(...parentSegments), + ); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_CONTEXT_PATH_UNSAFE'); + } + return content; +} + +async function readDirectoryEntriesWithin( + root: string, + relativeDirectory: string, +): Promise { + try { + const directory = await assertSafeDirectoryWithin(root, relativeDirectory); + return await readdir(directory); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } +} + +async function assertSafeDirectoryWithin( + root: string, + relativeDirectory: string, +): Promise { + const absoluteRoot = path.resolve(root); + const segments = + relativeDirectory === '.' ? [] : safeRelativeSegments(relativeDirectory); + await assertSafeDirectoryAt(absoluteRoot); + let current = absoluteRoot; + for (const segment of segments) { + current = path.join(current, segment); + await assertSafeDirectoryAt(current); + } + return current; +} + +async function assertSafeDirectoryAt(target: string): Promise { + const stat = await lstat(target); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('MANCODE_CONTEXT_PATH_UNSAFE'); + } +} + +function safeRelativeSegments(relativePath: string): string[] { + if ( + typeof relativePath !== 'string' || + !relativePath || + relativePath.includes('\0') || + path.isAbsolute(relativePath) + ) { + throw new Error('MANCODE_CONTEXT_PATH_UNSAFE'); + } + const segments = relativePath.split(path.sep); + if ( + segments.some((segment) => !segment || segment === '.' || segment === '..') + ) { + throw new Error('MANCODE_CONTEXT_PATH_UNSAFE'); + } + return segments; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function isTerminalWorkflowStatus( + status: WorkflowMetadataV3['status'], +): boolean { + return ( + status === 'completed' || status === 'abandoned' || status === 'superseded' + ); +} + +export function storedTaskAggregateDigest( + snapshot: StoredTaskSnapshot, +): string | null { + return snapshot.aggregate === null + ? null + : taskAggregateDigest(snapshot.aggregate); +} diff --git a/src/context/task-complete.ts b/src/context/task-complete.ts new file mode 100644 index 0000000..2c666bf --- /dev/null +++ b/src/context/task-complete.ts @@ -0,0 +1,462 @@ +import { updateClaim } from '../runtime/claim-store.js'; +import { resolveTaskEntityHomeStore } from '../runtime/entity-home-store.js'; +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + createClaimRecoveryAction, + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, +} from '../runtime/operation-recovery-payload.js'; +import { readProjectRuntimeContext } from '../runtime/project-runtime.js'; +import { + enqueueCacheInvalidationProjection, + enqueueSessionPointerProjection, + reconcileProjectionIntents, +} from '../runtime/projection-outbox.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + type OpenedV3TaskOperation, + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskEntityKey, + taskHeadEntityKey, + writeTaskAuthorityFile, +} from '../runtime/task-operation.js'; +import { + type ClaimV1, + assertClaimTransition, + parseClaim, +} from '../team/claims.js'; +import { + type TaskAggregateManifestV1, + assertTaskCompletionGate, + buildTaskAggregateManifest, +} from './aggregate.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { V3ContextStore } from './store.js'; +import { + assertTaskCodeHeadUnchanged, + nextTaskHeadFence, +} from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface CompleteV3TaskInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + /** Required only for a terminal manba diagnostic task. */ + outcome?: WorkflowMetadataV3['outcome']; + operationId?: Ulid; + now?: Date; +} + +export interface CompletedV3Task { + metadata: WorkflowMetadataV3; + releasedClaims: ClaimV1[]; + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1 | null; + operation: OperationJournalV1; +} + +/** + * Completes a task only after the full aggregate gate passes. Team claims are + * transitioned to released under the same task journal before the completed + * metadata becomes stable, so a terminal task can never retain an active + * claim after a successful commit. + */ +export async function completeV3Task( + input: CompleteV3TaskInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'task completion operationId'); + const { context, activeClaims } = await openCompletionContext({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId, + now, + }); + let journal: OperationJournalV1 | null = null; + try { + assertCompletionOutcome(context.task.metadata, input.outcome); + if ( + taskRef.namespace === 'shared' && + context.project.config.transport.mode !== 'local' + ) { + // A git-ref claim release must be part of the remote CAS. Until P2 + // supplies that commit protocol, a local completed bit would lie. + throw new Error('MANCODE_GIT_REF_TRANSPORT_NOT_IMPLEMENTED'); + } + const activeChildren = await context.store.listActiveChildTaskRefs(taskRef); + assertTaskCompletionGate( + { + metadata: context.task.metadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: context.task.latestCheckpoint, + }, + { + activeChildTaskRefs: activeChildren, + hasPendingRepairOperation: false, + activeClaimCount: activeClaims.length, + claimsWillReleaseOrTransfer: activeClaims.length > 0, + }, + ); + const timestamp = context.now.toISOString(); + const pendingMetadata = markCompletionOperationPending( + context.task.metadata, + context.operationId, + timestamp, + ); + const releasedClaims = activeClaims.map((claim) => + releaseClaim(claim, context.operationId, timestamp), + ); + const metadata = completedMetadata( + pendingMetadata, + input.outcome, + context.operationId, + timestamp, + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: context.task.latestCheckpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + + await Promise.all([ + enqueueSessionPointerProjection(context.projectRoot, { + operationId: context.operationId, + action: 'clear', + sessionId: context.session.sessionId, + expectedPreviousTaskRef: context.session.activeTaskRef, + taskRef, + workflowMode: metadata.workflowMode, + taskRevision: metadata.revision, + now: context.now, + }), + enqueueCacheInvalidationProjection(context.projectRoot, { + operationId: context.operationId, + cacheKind: 'context_pack', + taskRef, + now: context.now, + }), + enqueueCacheInvalidationProjection(context.projectRoot, { + operationId: context.operationId, + cacheKind: 'status_index', + taskRef, + now: context.now, + }), + ]); + + journal = await createTaskOperationJournal(context, { + type: 'task_complete', + action: + taskRef.namespace === 'shared' + ? 'task_complete_scope_change_child_merge' + : 'local_workflow_mutation', + expectedRevisions: completionExpectedRevisions(context, activeClaims), + conditions: { completionGateSatisfied: true }, + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-operation-pending', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(pendingMetadata), + }), + ...releasedClaims.map((claim) => { + const before = activeClaims.find( + (candidate) => candidate.claimId === claim.claimId, + ); + if (before === undefined) { + throw new Error('MANCODE_CLAIM_SET_CHANGED'); + } + return createClaimRecoveryAction({ + stepId: 'release-or-transfer-claims', + before, + claim, + }); + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'write-completed-metadata', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(pendingMetadata), + targetContent: serializeTaskAuthority(metadata), + }), + ...(taskHeadFence === null + ? [] + : [ + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ]), + ], + noOpStepIds: [ + ...(releasedClaims.length === 0 + ? ['release-or-transfer-claims'] + : []), + ...(taskHeadFence === null ? ['update-task-head-fence'] : []), + ], + }, + }); + journal = await advanceTaskOperation( + context, + journal, + 'validate-completion-gate', + true, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'mark-operation-pending', + false, + ); + await writeTaskAuthorityFile( + context, + 'metadata.json', + `${JSON.stringify(pendingMetadata, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'release-or-transfer-claims', + false, + ); + for (const claim of releasedClaims) { + const previous = activeClaims.find( + (candidate) => candidate.claimId === claim.claimId, + ); + if (previous === undefined) { + throw new Error('MANCODE_CLAIM_SET_CHANGED'); + } + await updateClaim(context.homeStore, claim, previous.revision); + } + + journal = await advanceTaskOperation( + context, + journal, + 'write-completed-metadata', + false, + ); + await writeTaskAuthorityFile( + context, + 'metadata.json', + `${JSON.stringify(metadata, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-task-head-fence', + false, + ); + if (taskHeadFence !== null) { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + } + const operation = await commitTaskOperation(context, journal); + try { + await reconcileProjectionIntents( + context.projectRoot, + context.operationId, + context.now, + ); + } catch { + // Terminal authority is committed; projections remain pending for doctor. + } + return { + metadata, + releasedClaims, + aggregate, + taskHeadFence, + operation, + }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // Completed write intent is sufficient to keep normal writers out. + } + } + throw error; + } finally { + await context.release(); + } +} + +async function openCompletionContext(input: { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + operationId: Ulid; + now: Date; +}): Promise<{ context: OpenedV3TaskOperation; activeClaims: ClaimV1[] }> { + for (let attempt = 0; attempt < 3; attempt += 1) { + const runtime = await readProjectRuntimeContext(input.projectRoot); + const store = new V3ContextStore(input.projectRoot); + const homeStore = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + input.taskRef, + ); + const preflight = await store.readCoordinationSnapshot( + input.taskRef, + homeStore, + ); + const preflightClaimIds = activeClaimIds(preflight.claims); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef: input.taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId: input.operationId, + extraEntityLocks: [ + ...preflightClaimIds.map((claimId) => `claim:${claimId}`), + ...(input.taskRef.namespace === 'shared' + ? [taskHeadEntityKey(input.taskRef)] + : []), + ], + now: input.now, + }); + const activeClaims = context.coordination.claims.filter( + (claim) => claim.state === 'active', + ); + if (sameIdSet(preflightClaimIds, activeClaimIds(activeClaims))) { + return { context, activeClaims }; + } + await context.release(); + } + throw new Error('MANCODE_CLAIM_SET_CHANGED'); +} + +function markCompletionOperationPending( + previous: WorkflowMetadataV3, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'operation_pending', + lastOperationId: operationId, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function releaseClaim( + previous: ClaimV1, + operationId: Ulid, + updatedAt: string, +): ClaimV1 { + const next = parseClaim({ + ...previous, + state: 'released', + revision: previous.revision + 1, + lastOperationId: operationId, + updatedAt, + }); + assertClaimTransition(previous, next); + return next; +} + +function completedMetadata( + previous: WorkflowMetadataV3, + outcome: WorkflowMetadataV3['outcome'] | undefined, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const expectedOutcome = + previous.workflowMode === 'manba' ? (outcome ?? null) : null; + const next = parseWorkflowMetadata({ + ...previous, + status: 'completed', + currentStep: previous.workflowMode === 'manba' ? 5 : 9, + blockingReason: null, + outcome: expectedOutcome, + revision: previous.revision + 1, + transitionState: 'stable', + lastOperationId: operationId, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function assertCompletionOutcome( + metadata: WorkflowMetadataV3, + outcome: WorkflowMetadataV3['outcome'] | undefined, +): void { + if (metadata.workflowMode === 'manba') { + if (outcome === undefined) { + throw new Error('MANCODE_MANBA_OUTCOME_REQUIRED'); + } + return; + } + if (outcome !== undefined) { + throw new Error('MANCODE_WORKFLOW_OUTCOME_INVALID'); + } +} + +function completionExpectedRevisions( + context: OpenedV3TaskOperation, + activeClaims: ClaimV1[], +): Record { + const expected: Record = { + [taskEntityKey(context.taskRef)]: context.task.metadata.revision, + }; + for (const claim of activeClaims) { + expected[`claim:${claim.claimId}`] = claim.revision; + } + if (context.taskRef.namespace === 'shared') { + const fence = context.coordination.taskHeadFence; + if (fence === null) throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + expected[taskHeadEntityKey(context.taskRef)] = fence.fenceRevision; + } + return expected; +} + +function activeClaimIds(claims: ClaimV1[]): Ulid[] { + return claims + .filter((claim) => claim.state === 'active') + .map((claim) => claim.claimId) + .sort(compareUtf8); +} + +function sameIdSet(left: readonly Ulid[], right: readonly Ulid[]): boolean { + return ( + left.length === right.length && + left.every((id, index) => id === right[index]) + ); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} diff --git a/src/context/task-head-reconcile.ts b/src/context/task-head-reconcile.ts new file mode 100644 index 0000000..78d5201 --- /dev/null +++ b/src/context/task-head-reconcile.ts @@ -0,0 +1,463 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { resolveTaskEntityHomeStore } from '../runtime/entity-home-store.js'; +import { + armOperationCrashAfterVisibleWrite, + throwIfDeferredOperationCrashInjected, + throwIfOperationCrashInjected, +} from '../runtime/operation-crash-injection.js'; +import { getOperationDefinition } from '../runtime/operation-definition.js'; +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { createTaskHeadFenceRecoveryAction } from '../runtime/operation-recovery-payload.js'; +import { + readCheckoutCodeHead, + readProjectRuntimeContext, +} from '../runtime/project-runtime.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { + assertTaskHeadFenceTransition, + parseTaskHeadFence, +} from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + taskEntityKey, + taskHeadEntityKey, +} from '../runtime/task-operation.js'; +import type { ClaimV1 } from '../team/claims.js'; +import { deriveClaimValidity } from '../team/conflicts.js'; +import type { HandoffV1 } from '../team/handoff.js'; +import { + type TaskAggregateManifestV1, + taskAggregateDigest, +} from './aggregate.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { type StoredTaskSnapshot, V3ContextStore } from './store.js'; +import { assertTaskCodeHeadUnchanged } from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue } from './task-ref.js'; + +const execFile = promisify(execFileCallback); + +export interface ReconcileV3TaskHeadInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedFenceRevision: number; + /** Explicit confirmation that the checked-out task tuple is Git-sourced. */ + fromGit: boolean; + operationId?: Ulid; + now?: Date; +} + +export interface ReconciledV3TaskHead { + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1; + operation: OperationJournalV1; +} + +export interface PreviewedV3TaskHeadReconcile { + aggregate: TaskAggregateManifestV1; + currentTaskHeadFence: TaskHeadFenceV1; + proposedTaskHeadFence: TaskHeadFenceV1; +} + +export interface PreviewV3TaskHeadReconcileInput { + projectRoot: string; + taskRef: TaskRef; + sessionActorId: Ulid; + expectedFenceRevision: number; + fromGit: boolean; + operationId?: Ulid; + now?: Date; +} + +/** + * Performs the complete non-mutating reconcile preflight. The resulting + * fence is a preview only: the eventual mutation re-reads under its lock. + */ +export async function previewV3TaskHeadReconcile( + input: PreviewV3TaskHeadReconcileInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_TASK_HEAD_RECONCILE_REQUIRES_SHARED_TASK'); + } + if (input.fromGit !== true) { + throw new Error('MANCODE_GIT_SOURCE_CONFIRMATION_REQUIRED'); + } + assertPositiveRevision(input.expectedFenceRevision, 'fence revision'); + assertUlid(input.sessionActorId, 'task-head reconcile sessionActorId'); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'task-head reconcile operationId'); + const store = new V3ContextStore(input.projectRoot); + const task = await store.readTaskSnapshot(taskRef); + const runtime = await readProjectRuntimeContext(input.projectRoot); + const homeStore = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + taskRef, + ); + const coordination = await store.readCoordinationSnapshot(taskRef, homeStore); + const aggregate = task.aggregate; + const previousFence = coordination.taskHeadFence; + const codeHead = await readCheckoutCodeHead(input.projectRoot); + if (aggregate === null || previousFence === null || codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + } + if (previousFence.fenceRevision !== input.expectedFenceRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + if (coordination.pendingOperations.length > 0) { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } + await assertGitSourcedTaskAggregate( + input.projectRoot, + task.location.taskRoot, + ); + assertReconcileEligible({ + task, + aggregate, + previousFence, + codeHead, + actorId: input.sessionActorId, + transportMode: (await store.readProjectSnapshot()).config.transport.mode, + claims: coordination.claims, + handoffs: coordination.handoffs, + now, + }); + return { + aggregate, + currentTaskHeadFence: previousFence, + proposedTaskHeadFence: buildReconciledTaskHeadFence({ + previousFence, + aggregate, + codeHead, + checkoutId: runtime.checkoutId, + operationId, + now, + }), + }; +} + +/** + * Explicitly adopts the complete current-worktree aggregate into the common + * task-head fence. It never merges two aggregates and refuses adoption while + * an active claim or open handoff would become stale. + */ +export async function reconcileV3TaskHead( + input: ReconcileV3TaskHeadInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_TASK_HEAD_RECONCILE_REQUIRES_SHARED_TASK'); + } + if (input.fromGit !== true) { + throw new Error('MANCODE_GIT_SOURCE_CONFIRMATION_REQUIRED'); + } + assertPositiveRevision(input.expectedFenceRevision, 'fence revision'); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'task-head reconcile operationId'); + const store = new V3ContextStore(input.projectRoot); + const preflightTask = await store.readTaskSnapshot(taskRef); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: preflightTask.metadata.revision, + operationId, + extraEntityLocks: [taskHeadEntityKey(taskRef)], + allowTaskHeadFenceMismatch: true, + now, + }); + let journal: OperationJournalV1 | null = null; + try { + const aggregate = context.task.aggregate; + const previousFence = context.coordination.taskHeadFence; + const codeHead = context.codeHead; + if (aggregate === null || previousFence === null || codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + } + if (previousFence.fenceRevision !== input.expectedFenceRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + await assertGitSourcedTaskAggregate( + context.projectRoot, + context.task.location.taskRoot, + ); + const reread = await context.store.readTaskSnapshot(taskRef); + if (reread.fingerprint !== context.task.fingerprint) { + throw new Error('MANCODE_CONTEXT_STALE'); + } + assertReconcileEligible({ + task: context.task, + aggregate, + previousFence, + codeHead, + actorId: context.session.actorId, + transportMode: context.project.config.transport.mode, + claims: context.coordination.claims, + handoffs: context.coordination.handoffs, + now: context.now, + }); + const taskHeadFence = buildReconciledTaskHeadFence({ + previousFence, + aggregate, + codeHead, + checkoutId: context.runtime.checkoutId, + operationId: context.operationId, + now: context.now, + }); + assertTaskHeadFenceTransition(previousFence, taskHeadFence, { + expectedFenceRevision: input.expectedFenceRevision, + allowSameTaskRevision: true, + }); + journal = await createTaskOperationJournal(context, { + type: 'task_head_reconcile', + action: 'task_head_reconcile', + expectedRevisions: { + [taskEntityKey(taskRef)]: context.task.metadata.revision, + [taskHeadEntityKey(taskRef)]: previousFence.fenceRevision, + }, + conditions: { + gitSourceConfirmed: true, + claimHandoffConsistent: true, + }, + recovery: { + actions: [ + createTaskHeadFenceRecoveryAction({ + stepId: 'adopt-task-head-fence', + before: previousFence, + fence: taskHeadFence, + }), + ], + }, + }); + throwIfOperationCrashInjected('task_head_reconcile', 'prepared'); + journal = await advanceTaskOperation( + context, + journal, + 'validate-clean-store-and-git-reachability', + true, + ); + injectAfterReconcileStep('validate-clean-store-and-git-reachability'); + journal = await advanceTaskOperation( + context, + journal, + 'confirm-adoption', + true, + ); + injectAfterReconcileStep('confirm-adoption'); + journal = await advanceTaskOperation( + context, + journal, + 'adopt-task-head-fence', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + throwIfDeferredOperationCrashInjected('task_head_reconcile'); + const operation = await commitTaskOperation(context, journal); + throwIfOperationCrashInjected('task_head_reconcile', 'commit'); + return { aggregate, taskHeadFence, operation }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A fence write intent can only be reconciled forward. + } + } + throw error; + } finally { + await context.release(); + } +} + +function injectAfterReconcileStep(stepId: string): void { + const step = getOperationDefinition('task_head_reconcile').steps.find( + (candidate) => candidate.id === stepId, + ); + if (step?.visibility === 'business_write') { + armOperationCrashAfterVisibleWrite('task_head_reconcile', stepId); + return; + } + throwIfOperationCrashInjected('task_head_reconcile', stepId); +} + +/** + * `--from-git` is a user confirmation, not evidence by itself. Reconcile may + * only adopt a tuple that Git can prove is the clean contents of the current + * reachable HEAD; untracked, staged, or working-tree edits must use a normal + * workflow mutation instead. + */ +async function assertGitSourcedTaskAggregate( + projectRoot: string, + taskRoot: string, +): Promise { + const relativeTaskRoot = path.relative(projectRoot, taskRoot); + if ( + !relativeTaskRoot || + path.isAbsolute(relativeTaskRoot) || + relativeTaskRoot.split(path.sep).some((part) => part === '..') + ) { + throw new Error('MANCODE_TASK_UNAVAILABLE'); + } + const authorityFiles = [ + 'metadata.json', + 'requirements.json', + 'review-ledger.json', + 'verification-ledger.json', + ].map((file) => path.join(relativeTaskRoot, file)); + try { + await execFile('git', ['rev-parse', '--verify', 'HEAD^{commit}'], { + cwd: projectRoot, + encoding: 'utf8', + timeout: 5_000, + maxBuffer: 64 * 1024, + }); + await execFile( + 'git', + ['ls-files', '--error-unmatch', '--', ...authorityFiles], + { + cwd: projectRoot, + encoding: 'utf8', + timeout: 5_000, + maxBuffer: 64 * 1024, + }, + ); + await execFile('git', ['diff', '--quiet', 'HEAD', '--', relativeTaskRoot], { + cwd: projectRoot, + encoding: 'utf8', + timeout: 5_000, + maxBuffer: 64 * 1024, + }); + await execFile( + 'git', + ['diff', '--cached', '--quiet', '--', relativeTaskRoot], + { + cwd: projectRoot, + encoding: 'utf8', + timeout: 5_000, + maxBuffer: 64 * 1024, + }, + ); + const { stdout: status } = await execFile( + 'git', + [ + 'status', + '--porcelain=v1', + '--untracked-files=all', + '--', + relativeTaskRoot, + ], + { + cwd: projectRoot, + encoding: 'utf8', + timeout: 5_000, + maxBuffer: 64 * 1024, + }, + ); + if (status.trim()) throw new Error('task aggregate has Git changes'); + } catch { + throw new Error('MANCODE_TASK_UNAVAILABLE'); + } +} + +function buildReconciledTaskHeadFence(input: { + previousFence: TaskHeadFenceV1; + aggregate: TaskAggregateManifestV1; + codeHead: string; + checkoutId: Ulid; + operationId: Ulid; + now: Date; +}): TaskHeadFenceV1 { + return parseTaskHeadFence({ + ...input.previousFence, + fenceRevision: input.previousFence.fenceRevision + 1, + taskRevision: input.aggregate.taskRevision, + aggregateDigest: taskAggregateDigest(input.aggregate), + ownershipEpoch: input.aggregate.ownershipEpoch, + codeRef: { head: input.codeHead }, + checkoutId: input.checkoutId, + lastOperationId: input.operationId, + updatedAt: input.now.toISOString(), + }); +} + +function assertReconcileEligible(input: { + task: StoredTaskSnapshot; + aggregate: TaskAggregateManifestV1; + previousFence: TaskHeadFenceV1; + codeHead: string; + actorId: Ulid; + transportMode: 'local' | 'git-ref'; + claims: ClaimV1[]; + handoffs: HandoffV1[]; + now: Date; +}): void { + if (input.transportMode !== 'local') { + throw new Error('MANCODE_GIT_REF_TRANSPORT_NOT_IMPLEMENTED'); + } + if (input.task.metadata.ownerActorId !== input.actorId) { + throw new Error('MANCODE_TASK_OWNER_REQUIRED'); + } + if ( + input.previousFence.taskRevision === input.aggregate.taskRevision && + input.previousFence.aggregateDigest === + taskAggregateDigest(input.aggregate) && + input.previousFence.ownershipEpoch === input.aggregate.ownershipEpoch && + input.previousFence.codeRef.head === input.codeHead + ) { + throw new Error('MANCODE_TASK_HEAD_RECONCILE_NOT_REQUIRED'); + } + assertClaimsConsistent(input.claims, input.task, input.codeHead, input.now); + assertHandoffsConsistent(input.handoffs); +} + +function assertClaimsConsistent( + claims: ClaimV1[], + task: StoredTaskSnapshot, + codeHead: string, + now: Date, +): void { + for (const claim of claims) { + if (claim.state !== 'active') continue; + if ( + deriveClaimValidity(claim, { + taskRef: task.metadata.taskRef, + taskRevision: task.metadata.revision, + implementationScopeDigest: task.metadata.implementationScope.digest, + ownershipEpoch: task.metadata.ownershipEpoch, + codeRefHead: codeHead, + now, + transportFreshness: 'fresh', + }) !== 'fresh' + ) { + throw new Error('MANCODE_CLAIM_HANDOFF_INCONSISTENT'); + } + } +} + +function assertHandoffsConsistent(handoffs: HandoffV1[]): void { + if ( + handoffs.some( + (handoff) => handoff.state === 'draft' || handoff.state === 'offered', + ) + ) { + throw new Error('MANCODE_CLAIM_HANDOFF_INCONSISTENT'); + } +} + +function assertPositiveRevision(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error( + `MANCODE_${label.toUpperCase().replaceAll(' ', '_')}_INVALID`, + ); + } +} diff --git a/src/context/task-locator.ts b/src/context/task-locator.ts new file mode 100644 index 0000000..8dce502 --- /dev/null +++ b/src/context/task-locator.ts @@ -0,0 +1,258 @@ +import { lstat, readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { type ArtifactRef, parseArtifactRef } from './artifact-ref.js'; +import { assertUlid } from './ids.js'; +import { type TaskRef, parseTaskRef, parseTaskRefValue } from './task-ref.js'; + +export interface TaskLocation { + taskRef: TaskRef; + taskRoot: string; +} + +export interface ArtifactLocation { + artifactRef: ArtifactRef; + artifactRoot: string; + path: string; +} + +export type TaskLocatorErrorCode = + | 'MANCODE_TASK_NOT_FOUND' + | 'MANCODE_TASK_AMBIGUOUS' + | 'MANCODE_ARTIFACT_NOT_FOUND' + | 'MANCODE_ARTIFACT_PATH_UNSAFE'; + +/** + * Resolves only canonical TaskRefs or bare ULIDs. A bare ID is deliberately + * rejected when it exists in both namespaces; callers must choose explicitly. + */ +export async function locateTask( + projectRoot: string, + requested: TaskRef | string, +): Promise { + const root = path.resolve(projectRoot); + if (typeof requested !== 'string') { + const taskRef = parseTaskRefValue(requested); + return locateExplicitTask(root, taskRef); + } + if (requested.includes(':')) { + return locateExplicitTask(root, parseTaskRef(requested)); + } + assertUlid(requested, 'task locator bare taskId'); + const matches = await Promise.all( + (['local', 'shared'] as const).map(async (namespace) => { + const taskRef: TaskRef = { namespace, taskId: requested }; + const taskRoot = taskRootPath(root, taskRef); + return (await isDirectoryWithoutSymlink(taskRoot)) + ? { taskRef, taskRoot } + : null; + }), + ); + const existing = matches.filter( + (location): location is TaskLocation => location !== null, + ); + if (existing.length === 0) throw locatorError('MANCODE_TASK_NOT_FOUND'); + if (existing.length > 1) throw locatorError('MANCODE_TASK_AMBIGUOUS'); + const location = existing[0]; + if (location === undefined) throw locatorError('MANCODE_TASK_NOT_FOUND'); + return location; +} + +export function taskRootPath(projectRoot: string, taskRef: TaskRef): string { + const parsed = parseTaskRefValue(taskRef); + return path.join( + path.resolve(projectRoot), + '.mancode', + parsed.namespace, + 'workflows', + parsed.taskId, + ); +} + +export function resolveArtifactLocation( + projectRoot: string, + artifact: ArtifactRef, +): ArtifactLocation { + const artifactRef = parseArtifactRef(artifact); + const root = path.resolve(projectRoot); + const artifactRoot = artifactRootPath(root, artifactRef); + const relativePath = artifactRelativePath(artifactRef); + const artifactPath = path.resolve(artifactRoot, relativePath); + assertPathWithinRoot(artifactRoot, artifactPath); + return { artifactRef, artifactRoot, path: artifactPath }; +} + +export async function readTaskArtifact( + projectRoot: string, + artifact: ArtifactRef, +): Promise { + const location = resolveArtifactLocation(projectRoot, artifact); + try { + await assertResolvedArtifactPathSafe(location); + const before = await lstat(location.path); + const content = await readFile(location.path, 'utf8'); + const after = await lstat(location.path); + if (!sameFileIdentity(before, after) || after.isSymbolicLink()) { + throw locatorError('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return content; + } catch (error) { + if (isLocatorError(error)) throw error; + if (isNotFound(error)) throw locatorError('MANCODE_ARTIFACT_NOT_FOUND'); + throw error; + } +} + +export async function assertResolvedArtifactPathSafe( + location: ArtifactLocation, +): Promise { + assertPathWithinRoot(location.artifactRoot, location.path); + const rootStat = await safeLstat(location.artifactRoot); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw locatorError('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const relative = path.relative(location.artifactRoot, location.path); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw locatorError('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + let current = location.artifactRoot; + for (const segment of relative.split(path.sep)) { + current = path.join(current, segment); + const entry = await safeLstat(current); + if (entry.isSymbolicLink()) { + throw locatorError('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } + const [resolvedRoot, resolvedArtifact] = await Promise.all([ + realpath(location.artifactRoot), + realpath(location.path), + ]); + assertPathWithinRoot(resolvedRoot, resolvedArtifact); +} + +async function locateExplicitTask( + projectRoot: string, + taskRef: TaskRef, +): Promise { + const taskRoot = taskRootPath(projectRoot, taskRef); + if (!(await isDirectoryWithoutSymlink(taskRoot))) { + throw locatorError('MANCODE_TASK_NOT_FOUND'); + } + return { taskRef, taskRoot }; +} + +function artifactRootPath(projectRoot: string, artifact: ArtifactRef): string { + if (artifact.kind === 'handoff') { + if (artifact.taskRef.namespace !== 'shared') { + throw locatorError('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return path.join(projectRoot, '.mancode', 'shared', 'team', 'handoffs'); + } + return taskRootPath(projectRoot, artifact.taskRef); +} + +function artifactRelativePath(artifact: ArtifactRef): string { + switch (artifact.kind) { + case 'requirements': + assertNoArtifactId(artifact); + return 'requirements.json'; + case 'requirements_markdown': + assertNoArtifactId(artifact); + return 'requirements.md'; + case 'plan': + assertNoArtifactId(artifact); + return 'plan.md'; + case 'review_ledger': + assertNoArtifactId(artifact); + return 'review-ledger.json'; + case 'verification_ledger': + assertNoArtifactId(artifact); + return 'verification-ledger.json'; + case 'summary': + assertNoArtifactId(artifact); + return 'summary.md'; + case 'checkpoint': + return path.join('checkpoints', `${requiredArtifactId(artifact)}.json`); + case 'review_report': + return path.join('reports', `${requiredArtifactId(artifact)}.md`); + case 'evidence_summary': + return path.join( + 'reports', + `evidence-${requiredArtifactId(artifact)}.md`, + ); + case 'handoff': + return `${requiredArtifactId(artifact)}.json`; + } +} + +function requiredArtifactId(artifact: ArtifactRef): string { + if (artifact.artifactId === undefined) { + throw locatorError('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return artifact.artifactId; +} + +function assertNoArtifactId(artifact: ArtifactRef): void { + if (artifact.artifactId !== undefined) { + throw locatorError('MANCODE_ARTIFACT_PATH_UNSAFE'); + } +} + +function assertPathWithinRoot(root: string, target: string): void { + const relative = path.relative(root, target); + if ( + relative === '' || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw locatorError('MANCODE_ARTIFACT_PATH_UNSAFE'); + } +} + +async function isDirectoryWithoutSymlink(target: string): Promise { + try { + const entry = await lstat(target); + return entry.isDirectory() && !entry.isSymbolicLink(); + } catch { + return false; + } +} + +async function safeLstat(target: string) { + try { + return await lstat(target); + } catch (error) { + if (isNotFound(error)) throw locatorError('MANCODE_ARTIFACT_NOT_FOUND'); + throw error; + } +} + +function sameFileIdentity( + first: Awaited>, + second: Awaited>, +): boolean { + return first.dev === second.dev && first.ino === second.ino; +} + +function locatorError(code: TaskLocatorErrorCode): Error { + return new Error(code); +} + +function isLocatorError(error: unknown): error is Error { + return ( + error instanceof Error && + (error.message === 'MANCODE_TASK_NOT_FOUND' || + error.message === 'MANCODE_TASK_AMBIGUOUS' || + error.message === 'MANCODE_ARTIFACT_NOT_FOUND' || + error.message === 'MANCODE_ARTIFACT_PATH_UNSAFE') + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/context/task-mutation.ts b/src/context/task-mutation.ts new file mode 100644 index 0000000..4a64c19 --- /dev/null +++ b/src/context/task-mutation.ts @@ -0,0 +1,154 @@ +import { readCheckoutCodeHead } from '../runtime/project-runtime.js'; +import { + type TaskHeadFenceV1, + assertTaskHeadFenceTransition, +} from '../runtime/task-head-fence.js'; +import { + type OpenedV3TaskOperation, + taskEntityKey, + taskHeadEntityKey, +} from '../runtime/task-operation.js'; +import { + type TaskAggregateManifestV1, + taskAggregateDigest, +} from './aggregate.js'; +import type { Ulid } from './ids.js'; +import { + type ReviewLedgerV1, + assertReviewLedgerTransition, + parseReviewLedger, + reviewLedgerDigest, +} from './review-ledger.js'; +import { + type VerificationLedgerV1, + assertVerificationLedgerTransition, + parseVerificationLedger, + verificationLedgerDigest, +} from './verification-ledger.js'; + +export type MutableTaskEntity = + | 'requirements' + | 'plan' + | 'review' + | 'verification'; + +export function markTaskReviewStale( + previous: ReviewLedgerV1, + operationId: Ulid, + updatedAt: string, +): ReviewLedgerV1 { + const draft: ReviewLedgerV1 = { + ...previous, + revision: previous.revision + 1, + status: 'stale', + contentDigest: '', + lastOperationId: operationId, + updatedAt, + }; + const next = parseReviewLedger({ + ...draft, + contentDigest: reviewLedgerDigest(draft), + }); + assertReviewLedgerTransition(previous, next); + return next; +} + +export function markTaskVerificationStale( + previous: VerificationLedgerV1, + operationId: Ulid, + updatedAt: string, +): VerificationLedgerV1 { + const draft: VerificationLedgerV1 = { + ...previous, + revision: previous.revision + 1, + status: 'stale', + contentDigest: '', + lastOperationId: operationId, + updatedAt, + }; + const next = parseVerificationLedger({ + ...draft, + contentDigest: verificationLedgerDigest(draft), + }); + assertVerificationLedgerTransition(previous, next); + return next; +} + +export function nextTaskHeadFence( + context: OpenedV3TaskOperation, + aggregate: TaskAggregateManifestV1, + updatedAt: string, +): TaskHeadFenceV1 | null { + if (context.taskRef.namespace === 'local') return null; + const previous = context.coordination.taskHeadFence; + const codeHead = context.codeHead; + if (previous === null || codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + } + const next: TaskHeadFenceV1 = { + ...previous, + fenceRevision: previous.fenceRevision + 1, + taskRevision: aggregate.taskRevision, + aggregateDigest: taskAggregateDigest(aggregate), + ownershipEpoch: aggregate.ownershipEpoch, + codeRef: { head: codeHead }, + checkoutId: context.runtime.checkoutId, + lastOperationId: context.operationId, + updatedAt, + }; + assertTaskHeadFenceTransition(previous, next, { + expectedFenceRevision: previous.fenceRevision, + }); + return next; +} + +export function taskMutationExpectedRevisions( + context: Pick, + entities: MutableTaskEntity[], +): Record { + const taskRef = context.task.metadata.taskRef; + const expected: Record = { + [taskEntityKey(taskRef)]: context.task.metadata.revision, + }; + for (const entity of new Set(entities)) { + expected[`${entity}:${taskRef.taskId}`] = entityRevision( + context.task, + entity, + ); + } + if (taskRef.namespace === 'shared') { + const fence = context.coordination.taskHeadFence; + if (fence === null) throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + expected[taskHeadEntityKey(taskRef)] = fence.fenceRevision; + } + return expected; +} + +export async function assertTaskCodeHeadUnchanged( + projectRoot: string, + expectedCodeHead: string | null, +): Promise { + if (expectedCodeHead === null) { + throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + } + const currentCodeHead = await readCheckoutCodeHead(projectRoot); + if (currentCodeHead !== expectedCodeHead) { + throw new Error('MANCODE_TASK_HEAD_CODE_REF_STALE'); + } +} + +function entityRevision( + task: OpenedV3TaskOperation['task'], + entity: MutableTaskEntity, +): number { + switch (entity) { + case 'requirements': + return task.requirements.revision; + case 'plan': + return task.metadata.governance.planVersion; + case 'review': + return task.review.revision; + case 'verification': + return task.verification.revision; + } +} diff --git a/src/context/task-ref.ts b/src/context/task-ref.ts new file mode 100644 index 0000000..5becda0 --- /dev/null +++ b/src/context/task-ref.ts @@ -0,0 +1,51 @@ +import { type Ulid, assertUlid } from './ids.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export type TaskNamespace = 'local' | 'shared'; + +export interface TaskRef { + namespace: TaskNamespace; + taskId: Ulid; +} + +const TASK_REF_PATTERN = /^(local|shared):([0-7][0-9A-HJKMNPQRSTVWXYZ]{25})$/; + +export function formatTaskRef(taskRef: TaskRef): string { + assertTaskRef(taskRef); + return `${taskRef.namespace}:${taskRef.taskId}`; +} + +export function parseTaskRef(input: unknown): TaskRef { + if (typeof input !== 'string') { + throw new Error('TaskRef must be a string in namespace:ULID form'); + } + const match = TASK_REF_PATTERN.exec(input); + if (!match) { + throw new Error('TaskRef must use local: or shared:'); + } + return { + namespace: match[1] as TaskNamespace, + taskId: match[2] as Ulid, + }; +} + +export function parseTaskRefValue(value: unknown): TaskRef { + assertRecord(value, 'TaskRef'); + assertKnownKeys(value, ['namespace', 'taskId'], 'TaskRef'); + if (value.namespace !== 'local' && value.namespace !== 'shared') { + throw new Error('TaskRef namespace must be local or shared'); + } + assertUlid(value.taskId, 'TaskRef taskId'); + return { + namespace: value.namespace, + taskId: value.taskId, + }; +} + +export function assertTaskRef(value: unknown): asserts value is TaskRef { + parseTaskRefValue(value); +} + +export function sameTaskRef(left: TaskRef, right: TaskRef): boolean { + return left.namespace === right.namespace && left.taskId === right.taskId; +} diff --git a/src/context/validation.ts b/src/context/validation.ts new file mode 100644 index 0000000..eb09b77 --- /dev/null +++ b/src/context/validation.ts @@ -0,0 +1,37 @@ +export function isRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +export function assertRecord( + value: unknown, + label: string, +): asserts value is Record { + if (!isRecord(value)) { + throw new Error(`${label} must be an object`); + } +} + +export function assertKnownKeys( + value: Record, + allowedKeys: readonly string[], + label: string, +): void { + const allowed = new Set(allowedKeys); + const unknown = Object.keys(value).filter((key) => !allowed.has(key)); + if (unknown.length > 0) { + throw new Error( + `${label} contains unknown field(s): ${unknown.join(', ')}`, + ); + } +} + +export function assertNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} diff --git a/src/context/verification-ledger.ts b/src/context/verification-ledger.ts new file mode 100644 index 0000000..ff417c5 --- /dev/null +++ b/src/context/verification-ledger.ts @@ -0,0 +1,742 @@ +import { + type ArtifactRef, + assertReferenceNamespace, + parseArtifactRef, +} from './artifact-ref.js'; +import { digestCanonicalJson } from './canonical.js'; +import { type Ulid, assertUlid } from './ids.js'; +import { assertSharedTextSafe } from './privacy.js'; +import type { + ItemIdentity, + RequirementsLedgerV1, + VerificationRequirement, +} from './requirements-ledger.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export type VerificationComponentStatus = + | 'pending' + | 'passed' + | 'failed' + | 'manual_required' + | 'blocked'; +export type VerificationLedgerStatus = + | 'pending' + | 'passed' + | 'failed' + | 'stale' + | 'manual_required' + | 'blocked'; + +export interface VerificationLedgerContext { + requirementsDigest: string; + planVersion: number; + remediationRound: number; +} + +export interface VerificationComponentEvidence { + evidenceId: Ulid; + status: VerificationComponentStatus; + summary: string | null; + command: string | null; + exitCode: number | null; + artifactRef: ArtifactRef | null; + confirmedByActorId: Ulid | null; + confirmationSource: 'actor' | 'legacy_migration' | null; + updatedAt: string | null; +} + +export interface VerificationLedgerV1 { + schemaVersion: 1; + canonicalizationVersion: 'mancode-jcs-v1'; + taskRef: TaskRef; + revision: number; + status: VerificationLedgerStatus; + requirementsDigest: string; + planVersion: number; + remediationRound: number; + checks: Array< + ItemIdentity & { + checkId: Ulid; + criterionId: Ulid; + required: boolean; + verificationRequirement: VerificationRequirement; + automated: VerificationComponentEvidence | null; + manual: VerificationComponentEvidence | null; + } + >; + legacySource: { + sourceSchema: 'verification-v1'; + sourceDigest: string; + sourceRequirementsDigest: string; + fieldMapVersion: 1; + } | null; + contentDigest: string; + lastOperationId: Ulid | null; + updatedAt: string; +} + +const COMPONENT_STATUSES = new Set([ + 'pending', + 'passed', + 'failed', + 'manual_required', + 'blocked', +]); +const LEDGER_STATUSES = new Set([ + 'pending', + 'passed', + 'failed', + 'stale', + 'manual_required', + 'blocked', +]); +const VERIFICATION_REQUIREMENTS = new Set([ + 'automated', + 'manual', + 'hybrid', +]); +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function parseVerificationLedger( + value: unknown, + requirements?: RequirementsLedgerV1, +): VerificationLedgerV1 { + assertRecord(value, 'verification ledger'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'canonicalizationVersion', + 'taskRef', + 'revision', + 'status', + 'requirementsDigest', + 'planVersion', + 'remediationRound', + 'checks', + 'legacySource', + 'contentDigest', + 'lastOperationId', + 'updatedAt', + ], + 'verification ledger', + ); + if (value.schemaVersion !== 1) { + throw new Error('verification ledger schemaVersion must be 1'); + } + if (value.canonicalizationVersion !== 'mancode-jcs-v1') { + throw new Error('verification ledger canonicalizationVersion is invalid'); + } + const taskRef = parseTaskRefValue(value.taskRef); + const ledger: VerificationLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef, + revision: parsePositiveInteger( + value.revision, + 'verification ledger revision', + ), + status: parseLedgerStatus(value.status), + requirementsDigest: parseDigest( + value.requirementsDigest, + 'verification ledger requirementsDigest', + ), + planVersion: parsePositiveInteger( + value.planVersion, + 'verification ledger planVersion', + ), + remediationRound: parseNonNegativeInteger( + value.remediationRound, + 'verification ledger remediationRound', + ), + checks: parseChecks(value.checks, taskRef), + legacySource: parseLegacySource(value.legacySource), + contentDigest: parseDigest( + value.contentDigest, + 'verification ledger contentDigest', + ), + lastOperationId: parseUlidOrNull( + value.lastOperationId, + 'verification ledger lastOperationId', + ), + updatedAt: parseTimestamp(value.updatedAt, 'verification ledger updatedAt'), + }; + assertVerificationLedgerShape(ledger); + if (ledger.contentDigest !== verificationLedgerDigest(ledger)) { + throw new Error( + 'verification ledger contentDigest does not match canonical content', + ); + } + assertStoredVerificationStatus(ledger); + if (requirements !== undefined) { + assertVerificationLedgerRequirements(ledger, requirements); + } + return ledger; +} + +export function verificationLedgerDigest(ledger: VerificationLedgerV1): string { + return digestCanonicalJson({ + schemaVersion: ledger.schemaVersion, + canonicalizationVersion: ledger.canonicalizationVersion, + taskRef: ledger.taskRef, + status: ledger.status, + requirementsDigest: ledger.requirementsDigest, + planVersion: ledger.planVersion, + remediationRound: ledger.remediationRound, + checks: ledger.checks, + legacySource: ledger.legacySource, + }); +} + +export function deriveVerificationLedgerStatus( + ledger: VerificationLedgerV1, + context?: VerificationLedgerContext, +): VerificationLedgerStatus { + if (context !== undefined && isVerificationStale(ledger, context)) { + return 'stale'; + } + const statuses = ledger.checks + .filter((check) => check.required) + .flatMap((check) => verificationComponents(check)); + if (statuses.some((status) => status === 'blocked')) return 'blocked'; + if (statuses.some((status) => status === 'manual_required')) { + return 'manual_required'; + } + if (statuses.some((status) => status === 'failed')) return 'failed'; + if (statuses.some((status) => status === 'pending')) return 'pending'; + return statuses.length > 0 ? 'passed' : 'pending'; +} + +export function assertVerificationLedgerAgainstContext( + ledger: VerificationLedgerV1, + context: VerificationLedgerContext, +): void { + parseDigest( + context.requirementsDigest, + 'verification ledger context requirementsDigest', + ); + parsePositiveInteger( + context.planVersion, + 'verification ledger context planVersion', + ); + parseNonNegativeInteger( + context.remediationRound, + 'verification ledger context remediationRound', + ); + const expectedStatus = deriveVerificationLedgerStatus(ledger, context); + if (ledger.status !== expectedStatus) { + throw new Error( + `verification ledger status must be ${expectedStatus} for the current aggregate`, + ); + } +} + +export function assertVerificationLedgerRequirements( + ledger: VerificationLedgerV1, + requirements: RequirementsLedgerV1, +): void { + if (!sameTaskRef(ledger.taskRef, requirements.taskRef)) { + throw new Error( + 'verification ledger and requirements ledger must target the same task', + ); + } + if (ledger.requirementsDigest !== requirements.contentDigest) { + throw new Error( + 'verification ledger requirementsDigest must match the requirements ledger contentDigest', + ); + } + if (ledger.checks.length !== requirements.acceptanceCriteria.length) { + throw new Error( + 'verification ledger must contain exactly one check per acceptance criterion', + ); + } + const criteria = new Map( + requirements.acceptanceCriteria.map((criterion) => [ + criterion.criterionId, + criterion, + ]), + ); + for (const check of ledger.checks) { + const criterion = criteria.get(check.criterionId); + if (criterion === undefined) { + throw new Error( + 'verification ledger check references an unknown acceptance criterion', + ); + } + if ( + check.displayId !== criterion.displayId || + check.legacyId !== criterion.legacyId || + check.required !== criterion.required || + check.verificationRequirement !== criterion.verificationRequirement + ) { + throw new Error( + 'verification ledger check must preserve its acceptance criterion identity and requirement', + ); + } + } +} + +export function assertVerificationLedgerTransition( + previous: VerificationLedgerV1, + next: VerificationLedgerV1, +): void { + if (next.revision !== previous.revision + 1) { + throw new Error( + 'verification ledger revision must increase exactly once per mutation', + ); + } + if ( + previous.schemaVersion !== next.schemaVersion || + previous.canonicalizationVersion !== next.canonicalizationVersion || + !sameTaskRef(previous.taskRef, next.taskRef) + ) { + throw new Error('verification ledger schema and TaskRef are immutable'); + } + if (next.remediationRound < previous.remediationRound) { + throw new Error('verification ledger remediationRound cannot decrease'); + } + if (next.remediationRound > previous.remediationRound + 1) { + throw new Error( + 'verification ledger remediationRound can increase by at most one', + ); + } + if (previous.legacySource === null && next.legacySource !== null) { + throw new Error('verification ledger cannot introduce a legacy source'); + } + if (!allowedVerificationTransitions(previous.status).has(next.status)) { + throw new Error( + `invalid verification ledger status transition: ${previous.status} -> ${next.status}`, + ); + } +} + +function assertVerificationLedgerShape(ledger: VerificationLedgerV1): void { + const checkIds = new Set(); + const criterionIds = new Set(); + const displayIds = new Set(); + for (const check of ledger.checks) { + if (checkIds.has(check.checkId)) { + throw new Error('verification ledger checkIds must be unique'); + } + if (criterionIds.has(check.criterionId)) { + throw new Error('verification ledger criterionIds must be unique'); + } + if (displayIds.has(check.displayId)) { + throw new Error('verification ledger displayIds must be unique'); + } + checkIds.add(check.checkId); + criterionIds.add(check.criterionId); + displayIds.add(check.displayId); + } +} + +function assertStoredVerificationStatus(ledger: VerificationLedgerV1): void { + if (ledger.status === 'stale') return; + const expectedStatus = deriveVerificationLedgerStatus(ledger); + if (ledger.status !== expectedStatus) { + throw new Error( + `verification ledger status must be derived as ${expectedStatus} from its required evidence`, + ); + } +} + +function isVerificationStale( + ledger: VerificationLedgerV1, + context: VerificationLedgerContext, +): boolean { + return ( + ledger.requirementsDigest !== context.requirementsDigest || + ledger.planVersion !== context.planVersion || + ledger.remediationRound !== context.remediationRound + ); +} + +function parseLedgerStatus(value: unknown): VerificationLedgerStatus { + if ( + typeof value !== 'string' || + !LEDGER_STATUSES.has(value as VerificationLedgerStatus) + ) { + throw new Error('verification ledger status is invalid'); + } + return value as VerificationLedgerStatus; +} + +function parseChecks( + value: unknown, + taskRef: TaskRef, +): VerificationLedgerV1['checks'] { + if (!Array.isArray(value)) { + throw new Error('verification ledger checks must be an array'); + } + return value.map((item) => { + const identity = parseItemIdentity(item, 'verification ledger check'); + assertRecord(item, 'verification ledger check'); + assertKnownKeys( + item, + [ + 'displayId', + 'legacyId', + 'checkId', + 'criterionId', + 'required', + 'verificationRequirement', + 'automated', + 'manual', + ], + 'verification ledger check', + ); + assertUlid(item.checkId, 'verification ledger checkId'); + assertUlid(item.criterionId, 'verification ledger criterionId'); + if (typeof item.required !== 'boolean') { + throw new Error('verification ledger check required must be boolean'); + } + if ( + typeof item.verificationRequirement !== 'string' || + !VERIFICATION_REQUIREMENTS.has( + item.verificationRequirement as VerificationRequirement, + ) + ) { + throw new Error( + 'verification ledger check verificationRequirement is invalid', + ); + } + const verificationRequirement = + item.verificationRequirement as VerificationRequirement; + const automated = parseEvidence(item.automated, taskRef, 'automated'); + const manual = parseEvidence(item.manual, taskRef, 'manual'); + assertVerificationSlots(verificationRequirement, automated, manual); + return { + ...identity, + checkId: item.checkId, + criterionId: item.criterionId, + required: item.required, + verificationRequirement, + automated, + manual, + }; + }); +} + +function assertVerificationSlots( + requirement: VerificationRequirement, + automated: VerificationComponentEvidence | null, + manual: VerificationComponentEvidence | null, +): void { + if (requirement === 'automated' && (automated === null || manual !== null)) { + throw new Error( + 'automated acceptance criteria require only an automated evidence slot', + ); + } + if (requirement === 'manual' && (automated !== null || manual === null)) { + throw new Error( + 'manual acceptance criteria require only a manual evidence slot', + ); + } + if (requirement === 'hybrid' && (automated === null || manual === null)) { + throw new Error( + 'hybrid acceptance criteria require both automated and manual evidence slots', + ); + } +} + +function parseEvidence( + value: unknown, + taskRef: TaskRef, + kind: 'automated' | 'manual', +): VerificationComponentEvidence | null { + if (value === null) return null; + assertRecord(value, `verification ledger ${kind} evidence`); + assertKnownKeys( + value, + [ + 'evidenceId', + 'status', + 'summary', + 'command', + 'exitCode', + 'artifactRef', + 'confirmedByActorId', + 'confirmationSource', + 'updatedAt', + ], + `verification ledger ${kind} evidence`, + ); + assertUlid(value.evidenceId, `verification ledger ${kind} evidenceId`); + if ( + typeof value.status !== 'string' || + !COMPONENT_STATUSES.has(value.status as VerificationComponentStatus) + ) { + throw new Error(`verification ledger ${kind} evidence status is invalid`); + } + const status = value.status as VerificationComponentStatus; + if (kind === 'automated' && status === 'manual_required') { + throw new Error('automated evidence cannot be manual_required'); + } + if ( + value.confirmationSource !== null && + value.confirmationSource !== 'actor' && + value.confirmationSource !== 'legacy_migration' + ) { + throw new Error( + `verification ledger ${kind} confirmationSource is invalid`, + ); + } + const confirmedByActorId = parseUlidOrNull( + value.confirmedByActorId, + `verification ledger ${kind} confirmedByActorId`, + ); + const confirmationSource = value.confirmationSource; + if (confirmationSource === 'actor' && confirmedByActorId === null) { + throw new Error( + `verification ledger ${kind} actor confirmation requires an actor`, + ); + } + if (confirmationSource === null && confirmedByActorId !== null) { + throw new Error( + `verification ledger ${kind} confirmation source is required for an actor`, + ); + } + if ( + confirmationSource === 'legacy_migration' && + confirmedByActorId !== null + ) { + throw new Error( + `verification ledger ${kind} legacy confirmation cannot invent an actor`, + ); + } + if (kind === 'automated' && confirmedByActorId !== null) { + throw new Error('automated evidence cannot contain manual confirmation'); + } + if (kind === 'manual' && status === 'passed' && confirmationSource === null) { + throw new Error('passed manual evidence requires an explicit confirmation'); + } + if (kind === 'manual' && status !== 'passed' && confirmationSource !== null) { + throw new Error('only passed manual evidence may contain a confirmation'); + } + const summary = parseNonEmptyStringOrNull( + value.summary, + `verification ledger ${kind} evidence summary`, + ); + const command = parseNonEmptyStringOrNull( + value.command, + `verification ledger ${kind} evidence command`, + ); + if (taskRef.namespace === 'shared') { + if (summary !== null) { + assertSharedTextSafe( + summary, + `verification ledger ${kind} evidence summary`, + ); + } + if (command !== null) { + assertSharedTextSafe( + command, + `verification ledger ${kind} evidence command`, + ); + } + } + return { + evidenceId: value.evidenceId, + status, + summary, + command, + exitCode: parseExitCodeOrNull( + value.exitCode, + `verification ledger ${kind} evidence exitCode`, + ), + artifactRef: + value.artifactRef === null + ? null + : parseEvidenceArtifactRef(value.artifactRef, taskRef), + confirmedByActorId, + confirmationSource, + updatedAt: + value.updatedAt === null + ? null + : parseTimestamp( + value.updatedAt, + `verification ledger ${kind} evidence updatedAt`, + ), + }; +} + +function parseEvidenceArtifactRef( + value: unknown, + taskRef: TaskRef, +): ArtifactRef { + const artifactRef = parseArtifactRef(value); + assertReferenceNamespace(taskRef.namespace, artifactRef); + if ( + artifactRef.kind !== 'evidence_summary' || + !sameTaskRef(artifactRef.taskRef, taskRef) + ) { + throw new Error( + 'verification evidence artifactRef must be an evidence_summary for the same task', + ); + } + return artifactRef; +} + +function parseLegacySource( + value: unknown, +): VerificationLedgerV1['legacySource'] { + if (value === null) return null; + assertRecord(value, 'verification ledger legacySource'); + assertKnownKeys( + value, + [ + 'sourceSchema', + 'sourceDigest', + 'sourceRequirementsDigest', + 'fieldMapVersion', + ], + 'verification ledger legacySource', + ); + if (value.sourceSchema !== 'verification-v1' || value.fieldMapVersion !== 1) { + throw new Error('verification ledger legacySource is invalid'); + } + return { + sourceSchema: 'verification-v1', + sourceDigest: parseDigest( + value.sourceDigest, + 'verification ledger legacySource sourceDigest', + ), + sourceRequirementsDigest: parseDigest( + value.sourceRequirementsDigest, + 'verification ledger legacySource sourceRequirementsDigest', + ), + fieldMapVersion: 1, + }; +} + +function verificationComponents( + check: VerificationLedgerV1['checks'][number], +): VerificationComponentStatus[] { + return [check.automated, check.manual] + .filter( + (component): component is VerificationComponentEvidence => + component !== null, + ) + .map((component) => component.status); +} + +function allowedVerificationTransitions( + status: VerificationLedgerStatus, +): Set { + switch (status) { + case 'pending': + return new Set([ + 'pending', + 'passed', + 'failed', + 'manual_required', + 'blocked', + 'stale', + ]); + case 'passed': + return new Set(['passed', 'stale']); + case 'failed': + return new Set([ + 'failed', + 'pending', + 'passed', + 'manual_required', + 'blocked', + 'stale', + ]); + case 'manual_required': + return new Set([ + 'manual_required', + 'passed', + 'failed', + 'blocked', + 'stale', + ]); + case 'blocked': + return new Set([ + 'blocked', + 'pending', + 'passed', + 'failed', + 'manual_required', + 'stale', + ]); + case 'stale': + return new Set([ + 'stale', + 'pending', + 'passed', + 'failed', + 'manual_required', + 'blocked', + ]); + } +} + +function parseItemIdentity(value: unknown, label: string): ItemIdentity { + assertRecord(value, label); + if (typeof value.displayId !== 'string' || !value.displayId.trim()) { + throw new Error(`${label} displayId is required`); + } + if ( + value.legacyId !== null && + (typeof value.legacyId !== 'string' || !value.legacyId.trim()) + ) { + throw new Error(`${label} legacyId must be a non-empty string or null`); + } + return { displayId: value.displayId, legacyId: value.legacyId }; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parseNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseExitCodeOrNull(value: unknown, label: string): number | null { + if (value === null) return null; + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new Error(`${label} must be an integer or null`); + } + return value; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseUlidOrNull(value: unknown, label: string): Ulid | null { + if (value === null) return null; + assertUlid(value, label); + return value; +} + +function parseNonEmptyStringOrNull( + value: unknown, + label: string, +): string | null { + if (value === null) return null; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} must be a non-empty string or null`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} diff --git a/src/context/verification-record.ts b/src/context/verification-record.ts new file mode 100644 index 0000000..8ff0c2e --- /dev/null +++ b/src/context/verification-record.ts @@ -0,0 +1,289 @@ +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, +} from '../runtime/operation-recovery-payload.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskHeadEntityKey, + writeTaskAuthorityFile, +} from '../runtime/task-operation.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, +} from './aggregate.js'; +import type { Ulid } from './ids.js'; +import { + assertTaskCodeHeadUnchanged, + nextTaskHeadFence, + taskMutationExpectedRevisions, +} from './task-mutation.js'; +import { type TaskRef, parseTaskRefValue, sameTaskRef } from './task-ref.js'; +import { + type VerificationLedgerV1, + assertVerificationLedgerAgainstContext, + assertVerificationLedgerRequirements, + assertVerificationLedgerTransition, + parseVerificationLedger, + verificationLedgerDigest, +} from './verification-ledger.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface RecordV3VerificationInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + /** A complete verification ledger bound to the current requirements/plan. */ + verification: unknown; + operationId?: Ulid; + now?: Date; +} + +export interface RecordedV3Verification { + metadata: WorkflowMetadataV3; + verification: VerificationLedgerV1; + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1 | null; + operation: OperationJournalV1; +} + +/** Records a complete current verification ledger and refreshes metadata cache. */ +export async function recordV3Verification( + input: RecordV3VerificationInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + const submitted = parseVerificationLedger(input.verification); + if (!sameTaskRef(submitted.taskRef, taskRef)) { + throw new Error('MANCODE_VERIFICATION_TASK_REF_MISMATCH'); + } + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId: input.operationId, + extraEntityLocks: + taskRef.namespace === 'shared' ? [taskHeadEntityKey(taskRef)] : [], + now: input.now, + }); + let journal: OperationJournalV1 | null = null; + try { + assertVerificationEligible( + context.task.metadata, + context.task.plan !== null, + ); + const timestamp = context.now.toISOString(); + const verification = createCurrentVerification( + context.task.verification, + submitted, + context.task.metadata, + context.task.review.remediationRound, + context.task.requirements, + context.operationId, + timestamp, + ); + const metadata = updateMetadata( + context.task.metadata, + verification, + context.operationId, + timestamp, + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review: context.task.review, + verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: context.task.latestCheckpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + + journal = await createTaskOperationJournal(context, { + type: 'verification_record', + action: + taskRef.namespace === 'shared' + ? 'shared_ledger_evidence' + : 'local_workflow_mutation', + evidence: + taskRef.namespace === 'shared' + ? { + assignedToActor: + context.task.metadata.ownerActorId === context.session.actorId, + restrictsWriteToAssignedItem: true, + } + : null, + expectedRevisions: taskMutationExpectedRevisions(context, [ + 'verification', + ]), + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'write-verification-ledger', + taskRef, + fileName: 'verification-ledger.json', + beforeContent: serializeTaskAuthority(context.task.verification), + targetContent: serializeTaskAuthority(verification), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'update-metadata', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(metadata), + }), + ...(taskHeadFence === null + ? [] + : [ + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ]), + ], + noOpStepIds: taskHeadFence === null ? ['update-task-head-fence'] : [], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + + journal = await advanceTaskOperation( + context, + journal, + 'write-verification-ledger', + false, + ); + await writeTaskAuthorityFile( + context, + 'verification-ledger.json', + serializeTaskAuthority(verification), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-metadata', + false, + ); + await writeTaskAuthorityFile( + context, + 'metadata.json', + serializeTaskAuthority(metadata), + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-task-head-fence', + false, + ); + if (taskHeadFence !== null) { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + } + const operation = await commitTaskOperation(context, journal); + return { metadata, verification, aggregate, taskHeadFence, operation }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A durable journal is already enough to block ordinary writes. + } + } + throw error; + } finally { + await context.release(); + } +} + +function assertVerificationEligible( + metadata: WorkflowMetadataV3, + hasPlan: boolean, +): void { + if (metadata.status !== 'in_progress' && metadata.status !== 'blocked') { + throw new Error('MANCODE_VERIFICATION_WORKFLOW_NOT_ACTIVE'); + } + if (metadata.workflowMode === 'manba') return; + if ( + metadata.governance.planDecision !== 'governed_execution' || + metadata.currentStep < 5 || + !hasPlan + ) { + throw new Error('MANCODE_VERIFICATION_PLAN_GATE_REQUIRED'); + } +} + +function createCurrentVerification( + previous: VerificationLedgerV1, + submitted: VerificationLedgerV1, + metadata: WorkflowMetadataV3, + remediationRound: number, + requirements: Parameters[1], + operationId: Ulid, + updatedAt: string, +): VerificationLedgerV1 { + if ( + submitted.requirementsDigest !== metadata.governance.requirementsDigest || + submitted.planVersion !== metadata.governance.planVersion || + submitted.remediationRound !== remediationRound + ) { + throw new Error('MANCODE_VERIFICATION_CONTEXT_STALE'); + } + const draft: VerificationLedgerV1 = { + ...submitted, + taskRef: previous.taskRef, + revision: previous.revision + 1, + contentDigest: '', + lastOperationId: operationId, + updatedAt, + }; + const next = parseVerificationLedger( + { + ...draft, + contentDigest: verificationLedgerDigest(draft), + }, + requirements, + ); + assertVerificationLedgerTransition(previous, next); + assertVerificationLedgerRequirements(next, requirements); + assertVerificationLedgerAgainstContext(next, { + requirementsDigest: metadata.governance.requirementsDigest, + planVersion: metadata.governance.planVersion, + remediationRound, + }); + return next; +} + +function updateMetadata( + previous: WorkflowMetadataV3, + verification: VerificationLedgerV1, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'stable', + lastOperationId: operationId, + governance: { + ...previous.governance, + verificationStatus: verification.status, + verificationLedgerDigest: verification.contentDigest, + }, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} diff --git a/src/context/workflow-create.ts b/src/context/workflow-create.ts new file mode 100644 index 0000000..76be0b7 --- /dev/null +++ b/src/context/workflow-create.ts @@ -0,0 +1,1126 @@ +import { + lstat, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { + type EntityHomeStore, + resolveTaskEntityHomeStore, +} from '../runtime/entity-home-store.js'; +import { acquireEntityLocks } from '../runtime/local-lock.js'; +import { + armOperationCrashAfterVisibleWrite, + throwIfDeferredOperationCrashInjected, + throwIfOperationCrashInjected, +} from '../runtime/operation-crash-injection.js'; +import { + assertOperationJournalMatchesDefinition, + getOperationDefinition, +} from '../runtime/operation-definition.js'; +import type { + OperationJournalV1, + OperationStep, +} from '../runtime/operation-journal.js'; +import { + assertOperationRecoveryPayloadCoversJournal, + createTaskHeadFenceRecoveryAction, + createWorkflowTaskDirectoryRecoveryAction, + operationRecoveryPayloadDigest, + parseOperationRecoveryPayload, +} from '../runtime/operation-recovery-payload.js'; +import { writeOperationRecoveryPayload } from '../runtime/operation-recovery-store.js'; +import { + createPreparedOperationJournal, + updateOperationJournal, +} from '../runtime/operation-store.js'; +import { + readCheckoutCodeHead, + readProjectRuntimeContext, +} from '../runtime/project-runtime.js'; +import { + completeProjectionIntent, + enqueueSessionPointerProjection, +} from '../runtime/projection-outbox.js'; +import { readSession, resumeSession } from '../runtime/session.js'; +import { parseTaskHeadFence } from '../runtime/task-head-fence.js'; +import { createTaskHeadFence } from '../runtime/task-head-store.js'; +import { readSharedActorProfile } from '../team/actor.js'; +import type { TeamAssessment } from '../team/assessment.js'; +import { createAuthorizationBasis } from '../team/authorization.js'; +import { assertTransportCoordinationWriteAllowed } from '../team/transport-migration-freeze.js'; +import { capabilitiesFromProjectConfig } from '../team/transport.js'; +import { VERSION } from '../version.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, + taskAggregateDigest, +} from './aggregate.js'; +import { digestCanonicalJson, sortUtf8StringSet } from './canonical.js'; +import { assertCompatibilityGate } from './compatibility.js'; +import { + type WorkflowCreationResolution, + resolveWorkflowCreation, +} from './creation-resolution.js'; +import { type Ulid, assertUlid, createUlid } from './ids.js'; +import { scanLegacyAuthority } from './layout.js'; +import type { ParentSnapshot } from './parent-snapshot.js'; +import { + type RequirementsLedgerV1, + parseRequirementsLedger, + requirementsLedgerDigest, +} from './requirements-ledger.js'; +import { + type ReviewLedgerV1, + deriveReviewLedgerStatus, + parseReviewLedger, + reviewLedgerDigest, +} from './review-ledger.js'; +import { parseWorkflowMode } from './schema.js'; +import { V3ContextStore } from './store.js'; +import { + type TaskNamespace, + type TaskRef, + parseTaskRefValue, +} from './task-ref.js'; +import { + type VerificationLedgerV1, + deriveVerificationLedgerStatus, + parseVerificationLedger, + verificationLedgerDigest, +} from './verification-ledger.js'; +import { + type WorkflowMetadataV3, + parseWorkflowMetadata, +} from './workflow-metadata.js'; + +export interface WorkflowCreateScope { + include?: string[]; + exclude?: string[]; + modules?: string[]; +} + +export interface CreateV3WorkflowInput { + projectRoot: string; + task: string; + workflowMode: 'man' | 'manba' | 'manteam'; + sessionId: Ulid; + client: string; + parentTaskRef?: TaskRef | null; + visibility?: TaskNamespace; + coordination?: 'single' | 'team'; + displaySlug?: string; + implementationScope?: WorkflowCreateScope; + /** Additional joined actors invited into a newly-created team workflow. */ + participantActorIds?: Ulid[]; + /** Explicit confirmation is mandatory whenever metadata enters shared V3. */ + sharedPrivacyConfirmed?: boolean; + taskId?: Ulid; + operationId?: Ulid; + assessment?: TeamAssessment | null; + now?: Date; +} + +export interface CreatedV3Workflow { + taskRef: TaskRef; + metadata: WorkflowMetadataV3; + requirements: RequirementsLedgerV1; + review: ReviewLedgerV1; + verification: VerificationLedgerV1; + aggregate: TaskAggregateManifestV1; + operation: OperationJournalV1; + resolution: WorkflowCreationResolution; + /** A failed session-pointer projection never rolls back a committed task. */ + sessionResumed: boolean; +} + +interface InitialEntities { + metadata: WorkflowMetadataV3; + requirements: RequirementsLedgerV1; + review: ReviewLedgerV1; + verification: VerificationLedgerV1; + aggregate: TaskAggregateManifestV1; +} + +interface ParentCreationContext { + metadata: WorkflowMetadataV3; + snapshot: ParentSnapshot; +} + +/** + * Creates the smallest valid V3 workflow tuple. The write-ahead journal is + * durable before any task entity becomes visible; an uncertain post-intent + * failure is deliberately left for forward repair instead of guessing that a + * task directory was not published. + */ +export async function createV3Workflow( + input: CreateV3WorkflowInput, +): Promise { + const projectRoot = path.resolve(requireProjectRoot(input.projectRoot)); + const task = requireText(input.task, 'workflow task'); + const client = requireText(input.client, 'workflow client'); + const now = input.now ?? new Date(); + const timestamp = now.toISOString(); + assertUlid(input.sessionId, 'workflow sessionId'); + const workflowMode = parseWorkflowMode(input.workflowMode); + const session = await readSession(projectRoot, input.sessionId); + if (session === null || session.status !== 'active') { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + + const runtime = await readProjectRuntimeContext(projectRoot); + const contextStore = new V3ContextStore(projectRoot); + const [project, legacy] = await Promise.all([ + contextStore.readProjectSnapshot(), + scanLegacyAuthority(projectRoot), + ]); + assertCompatibilityGate({ + manifest: project.manifest, + expectedSchemaEpoch: project.manifest.epoch, + readerVersion: VERSION, + writerVersion: VERSION, + adapterVersions: project.manifest.managedAdapters, + currentLegacyBaseline: legacy.baseline, + legacyAuthorityPresent: legacy.authorityPresent, + operation: 'v3_business_write', + }); + + const parent = await resolveParentCreationContext( + contextStore, + workflowMode, + input.parentTaskRef ?? null, + session.actorId, + ); + const resolution = resolveWorkflowCreation({ + workflowMode, + parent: + parent === null + ? null + : { + taskRef: parent.metadata.taskRef, + workflowMode: parent.metadata.workflowMode, + visibility: parent.metadata.visibility, + coordination: parent.metadata.coordination, + }, + visibility: input.visibility, + coordination: input.coordination, + policy: project.policy, + assessment: input.assessment ?? null, + }); + const taskRef = parseTaskRefValue({ + namespace: resolution.descriptor.visibility, + taskId: input.taskId ?? createUlid(now.getTime()), + }); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'workflow operationId'); + const participants = resolveInitialParticipants( + parent, + input.participantActorIds, + session.actorId, + resolution.descriptor.coordination, + ); + if (taskRef.namespace === 'shared') { + await assertSharedCreationPrerequisites( + projectRoot, + session.actorId, + input.sharedPrivacyConfirmed === true, + participants, + ); + } + + const codeHead = + taskRef.namespace === 'shared' + ? await requireSharedCodeHead(projectRoot) + : null; + const entities = buildInitialEntities({ + taskRef, + task, + displaySlug: input.displaySlug, + workflowMode, + coordination: resolution.descriptor.coordination, + actorId: session.actorId, + client, + operationId, + parent, + participants, + explicitScope: input.implementationScope, + timestamp, + }); + const homeStore = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + taskRef, + ); + if (taskRef.namespace === 'shared' && codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + } + const taskHeadFence = + taskRef.namespace === 'shared' + ? parseTaskHeadFence({ + schemaVersion: 1, + workspaceId: runtime.workspaceId, + taskRef, + fenceRevision: 1, + taskRevision: entities.metadata.revision, + aggregateDigest: taskAggregateDigest(entities.aggregate), + ownershipEpoch: entities.metadata.ownershipEpoch, + codeRef: { head: codeHead as string }, + checkoutId: runtime.checkoutId, + remoteRevision: null, + lastOperationId: operationId, + updatedAt: timestamp, + }) + : null; + const recoveryPayload = parseOperationRecoveryPayload({ + schemaVersion: 1, + operationId, + type: 'workflow_create', + primaryStoreId: homeStore.storeId, + actions: [ + createWorkflowTaskDirectoryRecoveryAction({ + stepId: 'publish-task-directory', + taskRef, + files: [ + { + fileName: 'metadata.json', + content: `${JSON.stringify(entities.metadata, null, 2)}\n`, + }, + { + fileName: 'requirements.json', + content: `${JSON.stringify(entities.requirements, null, 2)}\n`, + }, + { + fileName: 'review-ledger.json', + content: `${JSON.stringify(entities.review, null, 2)}\n`, + }, + { + fileName: 'verification-ledger.json', + content: `${JSON.stringify(entities.verification, null, 2)}\n`, + }, + ], + }), + ...(taskHeadFence === null + ? [] + : [ + createTaskHeadFenceRecoveryAction({ + stepId: 'publish-locator', + before: null, + fence: taskHeadFence, + }), + ]), + ], + noOpStepIds: taskHeadFence === null ? ['publish-locator'] : [], + }); + const operation = buildCreateOperation({ + operationId, + store: homeStore, + checkoutId: runtime.checkoutId, + actorId: session.actorId, + sessionId: session.sessionId, + taskRef, + parentTaskRef: parent?.metadata.taskRef ?? null, + shared: taskRef.namespace === 'shared', + sharedPrivacyConfirmed: input.sharedPrivacyConfirmed === true, + sharedWriteGuard: capabilitiesFromProjectConfig(project.config).writeGuard, + timestamp, + recoveryPayloadDigest: operationRecoveryPayloadDigest(recoveryPayload), + }); + assertOperationRecoveryPayloadCoversJournal(operation, recoveryPayload); + assertOperationJournalMatchesDefinition(operation); + + const taskParent = await ensureSafeTaskParent(projectRoot, taskRef); + const targetDirectory = path.join(taskParent, taskRef.taskId); + const stagingDirectory = path.join( + taskParent, + `.${taskRef.taskId}.${operationId}.staging`, + ); + const locks = await acquireEntityLocks( + homeStore, + operationId, + operation.entityLocks, + { now }, + ); + let journal = operation; + let journalCreated = false; + let sessionProjectionId: string | null = null; + try { + if (taskRef.namespace === 'shared') { + await assertTransportCoordinationWriteAllowed(homeStore, project.config); + } + if (parent !== null) { + const lockedParent = await resolveParentCreationContext( + contextStore, + workflowMode, + parent.metadata.taskRef, + session.actorId, + ); + if (!sameParentCreationContext(parent, lockedParent)) { + throw new Error('MANCODE_PARENT_STALE'); + } + } + await assertDirectoryAbsent( + targetDirectory, + 'MANCODE_WORKFLOW_ALREADY_EXISTS', + ); + await assertDirectoryAbsent( + stagingDirectory, + 'MANCODE_WORKFLOW_STAGING_CONFLICT', + ); + const sessionProjection = await enqueueSessionPointerProjection( + projectRoot, + { + operationId, + action: 'resume', + sessionId: session.sessionId, + expectedPreviousTaskRef: session.activeTaskRef, + taskRef, + workflowMode, + taskRevision: entities.metadata.revision, + now, + }, + ); + sessionProjectionId = sessionProjection.projectionId; + await writeOperationRecoveryPayload(homeStore, recoveryPayload); + journal = await createPreparedOperationJournal(homeStore, journal); + journalCreated = true; + throwIfOperationCrashInjected('workflow_create', 'prepared'); + + journal = await advanceJournal(homeStore, journal, 'validate', now, true); + // Mark write intent before the visible effect. This makes a crash in the + // narrow rename/write window repair-only rather than accidentally abortable. + journal = await advanceJournal( + homeStore, + journal, + 'write-staging-aggregate', + now, + false, + ); + await writeStagedEntities(stagingDirectory, entities); + await validateStagedEntities(stagingDirectory); + journal = await advanceJournal( + homeStore, + journal, + 'validate-aggregate', + now, + false, + ); + journal = await advanceJournal( + homeStore, + journal, + 'publish-task-directory', + now, + false, + ); + await publishStagedTask(stagingDirectory, targetDirectory); + journal = await advanceJournal( + homeStore, + journal, + 'publish-locator', + now, + false, + ); + if (taskHeadFence !== null) { + await createTaskHeadFence(homeStore, taskHeadFence); + } + journal = await commitJournal(homeStore, journal, now); + } catch (error) { + if (journalCreated) { + try { + if (hasBusinessWriteIntent(journal)) { + await markRepairRequired(homeStore, journal, now); + } else { + await abortPreparedCreate(homeStore, journal, stagingDirectory, now); + } + } catch { + // The persisted journal is still sufficient for doctor recovery. A + // compensation failure must not hide the original interrupted write. + } + } + throw error; + } finally { + await Promise.allSettled( + [...locks].reverse().map((lock) => lock.release()), + ); + } + + let sessionResumed = true; + try { + await resumeSession(projectRoot, session.sessionId, { + taskRef, + workflowMode, + taskRevision: entities.metadata.revision, + now, + }); + } catch { + sessionResumed = false; + } + if (sessionResumed && sessionProjectionId !== null) { + try { + await completeProjectionIntent( + projectRoot, + operationId, + sessionProjectionId, + now, + ); + } catch { + // The pointer is already correct; doctor can close the pending intent. + } + } + return { + taskRef, + metadata: entities.metadata, + requirements: entities.requirements, + review: entities.review, + verification: entities.verification, + aggregate: entities.aggregate, + operation: journal, + resolution, + sessionResumed, + }; +} + +async function resolveParentCreationContext( + store: V3ContextStore, + workflowMode: CreateV3WorkflowInput['workflowMode'], + parentTaskRef: TaskRef | null, + actorId: Ulid, +): Promise { + if (parentTaskRef === null) return null; + if (workflowMode !== 'manba') { + throw new Error('MANCODE_PARENT_MODE_INVALID'); + } + const snapshot = await store.readTaskSnapshot( + parseTaskRefValue(parentTaskRef), + ); + const parent = snapshot.metadata; + if ( + (parent.workflowMode !== 'man' && parent.workflowMode !== 'manteam') || + parent.status !== 'in_progress' || + parent.currentStep !== 6 || + parent.transitionState !== 'stable' || + snapshot.aggregate === null + ) { + throw new Error('MANCODE_PARENT_NOT_ELIGIBLE'); + } + if (parent.ownerActorId !== actorId) { + throw new Error('MANCODE_PARENT_OWNER_REQUIRED'); + } + if (!parent.participants.includes(actorId)) { + throw new Error('MANCODE_PARENT_PARTICIPANT_REQUIRED'); + } + return { + metadata: parent, + snapshot: { + taskRef: parent.taskRef, + revisionAtCreate: parent.revision, + planVersionAtCreate: parent.governance.planVersion, + requirementsDigestAtCreate: parent.governance.requirementsDigest, + implementationScopeDigestAtCreate: parent.implementationScope.digest, + visibility: parent.visibility, + coordination: parent.coordination, + participants: [...parent.participants], + }, + }; +} + +async function assertSharedCreationPrerequisites( + projectRoot: string, + actorId: Ulid, + privacyConfirmed: boolean, + participants: readonly Ulid[], +): Promise { + if (!privacyConfirmed) { + throw new Error('MANCODE_PRIVACY_CONFIRMATION_REQUIRED'); + } + if ((await readSharedActorProfile(projectRoot, actorId)) === null) { + throw new Error('MANCODE_JOIN_REQUIRED'); + } + for (const participantActorId of participants) { + if (participantActorId === actorId) continue; + if ( + (await readSharedActorProfile(projectRoot, participantActorId)) === null + ) { + throw new Error('MANCODE_PARTICIPANT_JOIN_REQUIRED'); + } + } +} + +async function requireSharedCodeHead(projectRoot: string): Promise { + const head = await readCheckoutCodeHead(projectRoot); + if (head === null) throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + return head; +} + +function resolveInitialParticipants( + parent: ParentCreationContext | null, + requested: Ulid[] | undefined, + ownerActorId: Ulid, + coordination: 'single' | 'team', +): Ulid[] { + if (parent !== null) { + if (requested !== undefined) { + throw new Error('MANCODE_PARENT_PARTICIPANT_INHERITANCE_REQUIRED'); + } + return [...parent.metadata.participants]; + } + const extras = requested ?? []; + const seen = new Set(); + for (const actorId of extras) { + assertUlid(actorId, 'workflow participant actorId'); + if (actorId === ownerActorId) { + throw new Error('MANCODE_OWNER_PARTICIPANT_REDUNDANT'); + } + if (seen.has(actorId)) { + throw new Error('MANCODE_WORKFLOW_PARTICIPANT_DUPLICATE'); + } + seen.add(actorId); + } + if (extras.length > 0 && coordination !== 'team') { + throw new Error('MANCODE_WORKFLOW_PARTICIPANTS_REQUIRE_TEAM'); + } + return sortUtf8StringSet([ownerActorId, ...extras]) as Ulid[]; +} + +function buildInitialEntities(input: { + taskRef: TaskRef; + task: string; + displaySlug: string | undefined; + workflowMode: CreateV3WorkflowInput['workflowMode']; + coordination: 'single' | 'team'; + actorId: Ulid; + client: string; + operationId: Ulid; + parent: ParentCreationContext | null; + participants: Ulid[]; + explicitScope: WorkflowCreateScope | undefined; + timestamp: string; +}): InitialEntities { + const scope = initialScope(input.parent, input.explicitScope); + const requirementsDraft: RequirementsLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: input.taskRef, + revision: 1, + status: 'draft', + goal: input.task, + functionalScope: { inScope: [], outOfScope: [] }, + technicalDecisions: [], + defaults: [], + coverage: [], + requirements: [], + acceptanceCriteria: [], + blockingUnknowns: [], + legacySource: null, + contentDigest: '', + lastOperationId: input.operationId, + updatedAt: input.timestamp, + }; + const requirements = parseRequirementsLedger({ + ...requirementsDraft, + contentDigest: requirementsLedgerDigest(requirementsDraft), + }); + const reviewDraft: ReviewLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: input.taskRef, + revision: 1, + status: 'pending', + depth: 'targeted', + requirementsDigest: requirements.contentDigest, + planVersion: 1, + requiredDomains: ['quality'], + domains: [{ domain: 'quality', status: 'pending', reportRef: null }], + blockers: [], + remediationRound: 0, + skip: null, + legacySource: null, + contentDigest: '', + lastOperationId: input.operationId, + updatedAt: input.timestamp, + }; + const reviewWithStatus = { + ...reviewDraft, + status: deriveReviewLedgerStatus(reviewDraft), + }; + const review = parseReviewLedger({ + ...reviewWithStatus, + contentDigest: reviewLedgerDigest(reviewWithStatus), + }); + const verificationDraft: VerificationLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: input.taskRef, + revision: 1, + status: 'pending', + requirementsDigest: requirements.contentDigest, + planVersion: 1, + remediationRound: 0, + checks: [], + legacySource: null, + contentDigest: '', + lastOperationId: input.operationId, + updatedAt: input.timestamp, + }; + const verificationWithStatus = { + ...verificationDraft, + status: deriveVerificationLedgerStatus(verificationDraft), + }; + const verification = parseVerificationLedger( + { + ...verificationWithStatus, + contentDigest: verificationLedgerDigest(verificationWithStatus), + }, + requirements, + ); + const metadata = parseWorkflowMetadata({ + schemaVersion: 3, + taskRef: input.taskRef, + displaySlug: input.displaySlug ?? displaySlug(input.task), + task: input.task, + workflowMode: input.workflowMode, + visibility: input.taskRef.namespace, + coordination: input.coordination, + status: 'in_progress', + currentStep: 1, + skippedSteps: [], + blockingReason: null, + outcome: null, + revision: 1, + transitionState: 'stable', + lastOperationId: input.operationId, + ownerActorId: input.actorId, + ownershipEpoch: 1, + participants: input.participants, + createdBy: { + actorId: input.actorId, + client: input.client, + source: 'actor', + }, + base: null, + implementationScope: scope, + governance: { + requirementsStatus: 'needs_clarification', + requirementsDigest: requirements.contentDigest, + planVersion: 1, + planDecision: null, + policyVersions: { planning: 1, review: 1, verification: 1 }, + reviewStatus: review.status, + reviewLedgerDigest: review.contentDigest, + verificationStatus: verification.status, + verificationLedgerDigest: verification.contentDigest, + }, + soloExecution: null, + latestCheckpointRef: null, + parent: input.parent?.snapshot ?? null, + successorTaskRef: null, + legacyCompatibility: null, + startedAt: input.timestamp, + updatedAt: input.timestamp, + }); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements, + review, + verification, + planDigest: null, + latestCheckpoint: null, + }); + return { metadata, requirements, review, verification, aggregate }; +} + +function initialScope( + parent: ParentCreationContext | null, + explicitScope: WorkflowCreateScope | undefined, +): WorkflowMetadataV3['implementationScope'] { + if (parent !== null) { + if (explicitScope !== undefined) { + throw new Error('MANCODE_PARENT_SCOPE_INHERITANCE_REQUIRED'); + } + const inherited = { + source: 'inherited' as const, + include: [...parent.metadata.implementationScope.include], + exclude: [...parent.metadata.implementationScope.exclude], + modules: [...parent.metadata.implementationScope.modules], + }; + return { ...inherited, digest: digestCanonicalJson(inherited) }; + } + const explicit = { + source: 'explicit' as const, + include: [...(explicitScope?.include ?? [])], + exclude: [...(explicitScope?.exclude ?? [])], + modules: [...(explicitScope?.modules ?? [])], + }; + return { ...explicit, digest: digestCanonicalJson(explicit) }; +} + +function buildCreateOperation(input: { + operationId: Ulid; + store: EntityHomeStore; + checkoutId: Ulid; + actorId: Ulid; + sessionId: Ulid; + taskRef: TaskRef; + parentTaskRef: TaskRef | null; + shared: boolean; + sharedPrivacyConfirmed: boolean; + sharedWriteGuard: 'enforced' | 'advisory' | 'unavailable'; + timestamp: string; + recoveryPayloadDigest: string; +}): OperationJournalV1 { + const taskKey = `task:${input.taskRef.namespace}:${input.taskRef.taskId}`; + const locatorKey = `locator:${input.taskRef.namespace}:${input.taskRef.taskId}`; + const entityLocks = [taskKey, locatorKey]; + if (input.parentTaskRef !== null) { + entityLocks.push( + `task:${input.parentTaskRef.namespace}:${input.parentTaskRef.taskId}`, + ); + } + if (input.shared) { + entityLocks.push(`task_head:${input.taskRef.taskId}`); + } + const authorizationBasis = createAuthorizationBasis({ + action: input.shared + ? 'shared_create_publish_promote' + : 'local_workflow_mutation', + actorId: input.actorId, + session: { + sessionId: input.sessionId, + actorId: input.actorId, + status: 'active', + }, + joined: input.shared, + sharedWriteGuard: input.sharedWriteGuard, + task: null, + claim: null, + handoff: null, + evidence: null, + profileActorId: null, + conditions: { + expectedRevisionMatches: true, + privacyConfirmed: input.shared ? input.sharedPrivacyConfirmed : undefined, + }, + }); + const definition = getOperationDefinition('workflow_create'); + return { + schemaVersion: 1, + operationId: input.operationId, + type: 'workflow_create', + state: 'prepared', + primaryStoreId: input.store.storeId, + checkoutId: input.checkoutId, + secondaryReservations: [], + actorId: input.actorId, + sessionId: input.sessionId, + authorizationBasis, + recoveryPayloadDigest: input.recoveryPayloadDigest, + entityLocks, + expectedRevisions: { [taskKey]: 0, [locatorKey]: 0 }, + steps: definition.steps.map((step) => ({ id: step.id, state: 'pending' })), + startedAt: input.timestamp, + updatedAt: input.timestamp, + }; +} + +function sameParentCreationContext( + expected: ParentCreationContext, + current: ParentCreationContext | null, +): boolean { + return ( + current !== null && + expected.metadata.revision === current.metadata.revision && + JSON.stringify(expected.snapshot) === JSON.stringify(current.snapshot) + ); +} + +async function ensureSafeTaskParent( + projectRoot: string, + taskRef: TaskRef, +): Promise { + const segments = ['.mancode', taskRef.namespace, 'workflows']; + let current = projectRoot; + for (const segment of segments) { + current = path.join(current, segment); + const existing = await lstatOrNull(current); + if (existing === null) { + await mkdir(current); + continue; + } + if (!existing.isDirectory() || existing.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } + return current; +} + +async function assertDirectoryAbsent( + target: string, + code: string, +): Promise { + if ((await lstatOrNull(target)) !== null) throw new Error(code); +} + +async function writeStagedEntities( + stagingDirectory: string, + entities: InitialEntities, +): Promise { + await mkdir(stagingDirectory); + await assertDirectory(stagingDirectory); + await Promise.all([ + writeStagedJson(stagingDirectory, 'metadata.json', entities.metadata), + writeStagedJson( + stagingDirectory, + 'requirements.json', + entities.requirements, + ), + writeStagedJson(stagingDirectory, 'review-ledger.json', entities.review), + writeStagedJson( + stagingDirectory, + 'verification-ledger.json', + entities.verification, + ), + ]); + await assertDirectory(stagingDirectory); +} + +async function writeStagedJson( + stagingDirectory: string, + fileName: string, + value: unknown, +): Promise { + await assertDirectory(stagingDirectory); + const target = path.join(stagingDirectory, fileName); + await writeFile(target, `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + const entry = await lstat(target); + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } +} + +async function validateStagedEntities(stagingDirectory: string): Promise { + await assertDirectory(stagingDirectory); + const [metadata, requirements, review, verification] = await Promise.all([ + readStagedJson(stagingDirectory, 'metadata.json', parseWorkflowMetadata), + readStagedJson( + stagingDirectory, + 'requirements.json', + parseRequirementsLedger, + ), + readStagedJson(stagingDirectory, 'review-ledger.json', parseReviewLedger), + readStagedJson(stagingDirectory, 'verification-ledger.json', (value) => + parseVerificationLedger(value), + ), + ]); + parseVerificationLedger(verification, requirements); + buildTaskAggregateManifest({ + metadata, + requirements, + review, + verification, + planDigest: null, + latestCheckpoint: null, + }); +} + +async function readStagedJson( + stagingDirectory: string, + fileName: string, + parser: (value: unknown) => T, +): Promise { + const target = path.join(stagingDirectory, fileName); + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const parsed = parser(JSON.parse(await readFile(target, 'utf8'))); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return parsed; +} + +async function publishStagedTask( + stagingDirectory: string, + targetDirectory: string, +): Promise { + await assertDirectory(stagingDirectory); + await validateStagedEntities(stagingDirectory); + await assertDirectoryAbsent( + targetDirectory, + 'MANCODE_WORKFLOW_ALREADY_EXISTS', + ); + await rename(stagingDirectory, targetDirectory); + await assertDirectory(targetDirectory); +} + +async function advanceJournal( + store: EntityHomeStore, + previous: OperationJournalV1, + stepId: string, + now: Date, + canAbort: boolean, +): Promise { + throwIfDeferredOperationCrashInjected(previous.type); + const next = { + ...previous, + state: 'applying' as const, + steps: completeJournalStep(previous.steps, stepId), + updatedAt: now.toISOString(), + }; + const advanced = await updateOperationJournal(store, next, { canAbort }); + injectAfterWorkflowCreateStep(previous.type, stepId); + return advanced; +} + +async function commitJournal( + store: EntityHomeStore, + previous: OperationJournalV1, + now: Date, +): Promise { + throwIfDeferredOperationCrashInjected(previous.type); + const committed = await updateOperationJournal( + store, + { + ...previous, + state: 'committed', + steps: completeJournalStep(previous.steps, 'commit'), + updatedAt: now.toISOString(), + }, + { canAbort: false }, + ); + throwIfOperationCrashInjected(previous.type, 'commit'); + return committed; +} + +function injectAfterWorkflowCreateStep( + operationType: OperationJournalV1['type'], + stepId: string, +): void { + const step = getOperationDefinition(operationType).steps.find( + (candidate) => candidate.id === stepId, + ); + if (step?.visibility === 'business_write') { + armOperationCrashAfterVisibleWrite(operationType, stepId); + return; + } + throwIfOperationCrashInjected(operationType, stepId); +} + +function completeJournalStep( + steps: OperationStep[], + requestedStepId: string, +): OperationStep[] { + const index = steps.findIndex((step) => step.id === requestedStepId); + if (index < 0) throw new Error('MANCODE_OPERATION_STEP_INVALID'); + if (steps[index]?.state === 'completed') { + throw new Error('MANCODE_OPERATION_STEP_ALREADY_COMPLETED'); + } + if (steps.slice(0, index).some((step) => step.state !== 'completed')) { + throw new Error('MANCODE_OPERATION_STEP_ORDER_INVALID'); + } + return steps.map((step, stepIndex) => + stepIndex === index ? { ...step, state: 'completed' as const } : step, + ); +} + +function hasBusinessWriteIntent(journal: OperationJournalV1): boolean { + const definition = getOperationDefinition(journal.type); + return journal.steps.some( + (step, index) => + step.state === 'completed' && + definition.steps[index]?.visibility === 'business_write', + ); +} + +async function markRepairRequired( + store: EntityHomeStore, + journal: OperationJournalV1, + now: Date, +): Promise { + if (journal.state === 'committed' || journal.state === 'aborted') return; + try { + await updateOperationJournal( + store, + { ...journal, state: 'repair_required', updatedAt: now.toISOString() }, + { canAbort: false }, + ); + } catch { + // Preserve the primary error. The durable completed write-intent is still + // enough for the resolver/reconciler to refuse ordinary mutations. + } +} + +async function abortPreparedCreate( + store: EntityHomeStore, + journal: OperationJournalV1, + stagingDirectory: string, + now: Date, +): Promise { + try { + await updateOperationJournal( + store, + { ...journal, state: 'aborted', updatedAt: now.toISOString() }, + { canAbort: true }, + ); + } finally { + await rm(stagingDirectory, { recursive: true, force: true }); + } +} + +async function assertDirectory(target: string): Promise { + const entry = await lstat(target); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } +} + +async function lstatOrNull(target: string) { + try { + return await lstat(target); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +function displaySlug(task: string): string { + const normalized = task + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return normalized.slice(0, 80) || 'task'; +} + +function requireProjectRoot(value: string): string { + if (!value.trim() || value.includes('\0')) { + throw new Error('workflow projectRoot is required'); + } + return value; +} + +function requireText(value: string, label: string): string { + if (!value.trim() || value.includes('\0')) { + throw new Error(`${label} is required`); + } + return value.trim(); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/context/workflow-metadata.ts b/src/context/workflow-metadata.ts new file mode 100644 index 0000000..9368bea --- /dev/null +++ b/src/context/workflow-metadata.ts @@ -0,0 +1,907 @@ +import { + type ArtifactRef, + assertReferenceNamespace, + parseArtifactRef, +} from './artifact-ref.js'; +import { digestCanonicalJson, sortUtf8StringSet } from './canonical.js'; +import { type Ulid, assertUlid } from './ids.js'; +import { type ParentSnapshot, parseParentSnapshot } from './parent-snapshot.js'; +import { assertSharedTextSafe } from './privacy.js'; +import { + type Coordination, + type WorkflowMode, + type WorkflowStatus, + type WorkflowTransitionOperation, + assertWorkflowStatusTransition, + parseWorkflowMode, + parseWorkflowStatus, +} from './schema.js'; +import { + type TaskNamespace, + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from './task-ref.js'; +import { assertKnownKeys, assertRecord } from './validation.js'; + +export type WorkflowTransitionState = + | 'stable' + | 'operation_pending' + | 'pending_repair'; +export type RequirementsStatus = 'ready' | 'needs_clarification'; +export type ReviewStatus = + | 'pending' + | 'in_review' + | 'passed' + | 'blocked' + | 'skipped' + | 'stale'; +export type VerificationStatus = + | 'pending' + | 'passed' + | 'failed' + | 'stale' + | 'manual_required' + | 'blocked'; +export type PlanDecision = + | 'plan_only' + | 'solo_handoff' + | 'governed_execution' + | null; + +export interface WorkflowMetadataV3 { + schemaVersion: 3; + taskRef: TaskRef; + displaySlug: string; + task: string; + workflowMode: WorkflowMode; + visibility: TaskNamespace; + coordination: Coordination; + status: WorkflowStatus; + currentStep: number; + skippedSteps: string[]; + blockingReason: string | null; + outcome: 'fixed' | 'verified' | 'no_repro' | 'manual_test_required' | null; + revision: number; + transitionState: WorkflowTransitionState; + lastOperationId: Ulid | null; + ownerActorId: Ulid | null; + ownershipEpoch: number; + participants: Ulid[]; + createdBy: { + actorId: Ulid | null; + client: string; + source: 'actor' | 'legacy_migration'; + }; + base: { + branch: string; + head: string; + upstream: string | null; + } | null; + implementationScope: { + source: 'explicit' | 'inherited' | 'legacy_unspecified'; + include: string[]; + exclude: string[]; + modules: string[]; + digest: string; + }; + governance: { + requirementsStatus: RequirementsStatus; + requirementsDigest: string; + planVersion: number; + planDecision: PlanDecision; + policyVersions: { + planning: number | null; + review: number | null; + verification: number | null; + }; + reviewStatus: ReviewStatus; + reviewLedgerDigest: string; + verificationStatus: VerificationStatus; + verificationLedgerDigest: string; + }; + soloExecution: { + state: 'active' | 'completed'; + planVersion: number; + assignedSessionId: Ulid | null; + startedAt: string | null; + completedAt: string | null; + } | null; + latestCheckpointRef: ArtifactRef | null; + parent: ParentSnapshot | null; + successorTaskRef: TaskRef | null; + legacyCompatibility: { + legacyTaskId: string; + sourceMetadataDigest: string; + fieldMapVersion: number; + } | null; + startedAt: string; + updatedAt: string; +} + +const TRANSITION_STATES = new Set([ + 'stable', + 'operation_pending', + 'pending_repair', +]); +const REQUIREMENTS_STATUSES = new Set([ + 'ready', + 'needs_clarification', +]); +const REVIEW_STATUSES = new Set([ + 'pending', + 'in_review', + 'passed', + 'blocked', + 'skipped', + 'stale', +]); +const VERIFICATION_STATUSES = new Set([ + 'pending', + 'passed', + 'failed', + 'stale', + 'manual_required', + 'blocked', +]); +const PLAN_DECISIONS = new Set>([ + 'plan_only', + 'solo_handoff', + 'governed_execution', +]); +const OUTCOMES = new Set>([ + 'fixed', + 'verified', + 'no_repro', + 'manual_test_required', +]); +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function parseWorkflowMetadata(value: unknown): WorkflowMetadataV3 { + assertRecord(value, 'workflow metadata'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'taskRef', + 'displaySlug', + 'task', + 'workflowMode', + 'visibility', + 'coordination', + 'status', + 'currentStep', + 'skippedSteps', + 'blockingReason', + 'outcome', + 'revision', + 'transitionState', + 'lastOperationId', + 'ownerActorId', + 'ownershipEpoch', + 'participants', + 'createdBy', + 'base', + 'implementationScope', + 'governance', + 'soloExecution', + 'latestCheckpointRef', + 'parent', + 'successorTaskRef', + 'legacyCompatibility', + 'startedAt', + 'updatedAt', + ], + 'workflow metadata', + ); + if (value.schemaVersion !== 3) { + throw new Error('workflow metadata schemaVersion must be 3'); + } + if (value.visibility !== 'local' && value.visibility !== 'shared') { + throw new Error('workflow metadata visibility must be local or shared'); + } + if (value.coordination !== 'single' && value.coordination !== 'team') { + throw new Error('workflow metadata coordination must be single or team'); + } + const taskRef = parseTaskRefValue(value.taskRef); + if (taskRef.namespace !== value.visibility) { + throw new Error( + 'workflow metadata TaskRef namespace must match visibility', + ); + } + const metadata: WorkflowMetadataV3 = { + schemaVersion: 3, + taskRef, + displaySlug: parseNonEmptyString( + value.displaySlug, + 'workflow metadata displaySlug', + ), + task: parseNonEmptyString(value.task, 'workflow metadata task'), + workflowMode: parseWorkflowMode(value.workflowMode), + visibility: value.visibility, + coordination: value.coordination, + status: parseWorkflowStatus(value.status), + currentStep: parseCurrentStep(value.currentStep, value.workflowMode), + skippedSteps: parseUniqueStringList( + value.skippedSteps, + 'workflow metadata skippedSteps', + ), + blockingReason: parseNonEmptyStringOrNull( + value.blockingReason, + 'workflow metadata blockingReason', + ), + outcome: parseOutcome(value.outcome), + revision: parsePositiveInteger( + value.revision, + 'workflow metadata revision', + ), + transitionState: parseTransitionState(value.transitionState), + lastOperationId: parseUlidOrNull( + value.lastOperationId, + 'workflow metadata lastOperationId', + ), + ownerActorId: parseUlidOrNull( + value.ownerActorId, + 'workflow metadata ownerActorId', + ), + ownershipEpoch: parseNonNegativeInteger( + value.ownershipEpoch, + 'workflow metadata ownershipEpoch', + ), + participants: parseUlidSet( + value.participants, + 'workflow metadata participants', + ), + createdBy: parseCreatedBy(value.createdBy), + base: parseBase(value.base), + implementationScope: parseImplementationScope(value.implementationScope), + governance: parseGovernance(value.governance), + soloExecution: parseSoloExecution(value.soloExecution), + latestCheckpointRef: + value.latestCheckpointRef === null + ? null + : parseArtifactRef(value.latestCheckpointRef), + parent: + value.parent === null + ? null + : parseParentSnapshot({ parent: value.parent }), + successorTaskRef: + value.successorTaskRef === null + ? null + : parseTaskRefValue(value.successorTaskRef), + legacyCompatibility: parseLegacyCompatibility(value.legacyCompatibility), + startedAt: parseTimestamp(value.startedAt, 'workflow metadata startedAt'), + updatedAt: parseTimestamp(value.updatedAt, 'workflow metadata updatedAt'), + }; + assertWorkflowMetadataShape(metadata); + if (metadata.visibility === 'shared') { + assertSharedTextSafe(metadata.displaySlug, 'workflow metadata displaySlug'); + assertSharedTextSafe(metadata.task, 'workflow metadata task'); + if (metadata.blockingReason !== null) { + assertSharedTextSafe( + metadata.blockingReason, + 'workflow metadata blockingReason', + ); + } + for (const [label, values] of [ + ['implementationScope.include', metadata.implementationScope.include], + ['implementationScope.exclude', metadata.implementationScope.exclude], + ['implementationScope.modules', metadata.implementationScope.modules], + ] as const) { + for (const value of values) { + assertSharedTextSafe(value, `workflow metadata ${label}`); + } + } + } + return metadata; +} + +export function workflowMetadataDigest(metadata: WorkflowMetadataV3): string { + return digestCanonicalJson({ + taskRef: metadata.taskRef, + displaySlug: metadata.displaySlug, + task: metadata.task, + workflowMode: metadata.workflowMode, + visibility: metadata.visibility, + coordination: metadata.coordination, + status: metadata.status, + currentStep: metadata.currentStep, + skippedSteps: metadata.skippedSteps, + blockingReason: metadata.blockingReason, + outcome: metadata.outcome, + ownerActorId: metadata.ownerActorId, + ownershipEpoch: metadata.ownershipEpoch, + participants: metadata.participants, + createdBy: metadata.createdBy, + base: metadata.base, + implementationScope: metadata.implementationScope, + governance: metadata.governance, + soloExecution: metadata.soloExecution, + parent: metadata.parent, + successorTaskRef: metadata.successorTaskRef, + legacyCompatibility: metadata.legacyCompatibility, + }); +} + +export function assertWorkflowMetadataTransition( + previous: WorkflowMetadataV3, + next: WorkflowMetadataV3, + operation: WorkflowTransitionOperation, +): void { + if (next.revision !== previous.revision + 1) { + throw new Error( + 'workflow metadata revision must increase exactly once per mutation', + ); + } + if ( + previous.workflowMode !== next.workflowMode || + previous.visibility !== next.visibility || + previous.coordination !== next.coordination || + previous.taskRef.namespace !== next.taskRef.namespace || + previous.taskRef.taskId !== next.taskRef.taskId || + JSON.stringify(previous.parent) !== JSON.stringify(next.parent) + ) { + throw new Error( + 'workflow mode, dimensions, TaskRef, and parent are immutable', + ); + } + assertWorkflowStatusTransition({ + sourceTaskRef: previous.taskRef, + from: previous.status, + to: next.status, + operation, + successorTaskRef: next.successorTaskRef, + }); +} + +export function assertParentWorkflowRelation( + child: WorkflowMetadataV3, + parent: WorkflowMetadataV3, +): void { + if (child.parent === null) { + throw new Error('workflow has no parent snapshot'); + } + if ( + child.parent.taskRef.taskId !== parent.taskRef.taskId || + child.parent.taskRef.namespace !== parent.taskRef.namespace || + child.parent.visibility !== parent.visibility || + child.parent.coordination !== parent.coordination + ) { + throw new Error('parent metadata does not match the child parent snapshot'); + } + if (child.workflowMode !== 'manba') { + throw new Error('only manba workflows may have a parent'); + } + if ( + (child.visibility === 'local' && parent.workflowMode !== 'man') || + (child.visibility === 'shared' && + child.coordination === 'single' && + parent.workflowMode !== 'man') || + (child.visibility === 'shared' && + child.coordination === 'team' && + parent.workflowMode !== 'manteam') + ) { + throw new Error( + 'parent workflow mode does not satisfy the child inheritance contract', + ); + } +} + +function parseTransitionState(value: unknown): WorkflowTransitionState { + if ( + typeof value !== 'string' || + !TRANSITION_STATES.has(value as WorkflowTransitionState) + ) { + throw new Error('workflow metadata transitionState is invalid'); + } + return value as WorkflowTransitionState; +} + +function parseCurrentStep(value: unknown, mode: unknown): number { + const workflowMode = parseWorkflowMode(mode); + const maxStep = workflowMode === 'manba' ? 5 : 9; + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < 1 || + value > maxStep + ) { + throw new Error('workflow metadata currentStep is invalid'); + } + return value; +} + +function parseOutcome(value: unknown): WorkflowMetadataV3['outcome'] { + if (value === null) return null; + const outcome = value as NonNullable; + if (typeof value !== 'string' || !OUTCOMES.has(outcome)) { + throw new Error('workflow metadata outcome is invalid'); + } + return outcome; +} + +function parseCreatedBy(value: unknown): WorkflowMetadataV3['createdBy'] { + assertRecord(value, 'workflow metadata createdBy'); + assertKnownKeys( + value, + ['actorId', 'client', 'source'], + 'workflow metadata createdBy', + ); + if (value.source !== 'actor' && value.source !== 'legacy_migration') { + throw new Error('workflow metadata createdBy source is invalid'); + } + if (typeof value.client !== 'string' || !value.client.trim()) { + throw new Error('workflow metadata createdBy client is required'); + } + const actorId = parseUlidOrNull( + value.actorId, + 'workflow metadata createdBy actorId', + ); + if (value.source === 'actor' && actorId === null) { + throw new Error('native workflow metadata requires a createdBy actorId'); + } + return { actorId, client: value.client, source: value.source }; +} + +function parseBase(value: unknown): WorkflowMetadataV3['base'] { + if (value === null) return null; + assertRecord(value, 'workflow metadata base'); + assertKnownKeys( + value, + ['branch', 'head', 'upstream'], + 'workflow metadata base', + ); + if ( + typeof value.branch !== 'string' || + !value.branch.trim() || + typeof value.head !== 'string' || + !value.head.trim() + ) { + throw new Error('workflow metadata base branch and head are required'); + } + return { + branch: value.branch, + head: value.head, + upstream: parseNonEmptyStringOrNull( + value.upstream, + 'workflow metadata base upstream', + ), + }; +} + +function parseImplementationScope( + value: unknown, +): WorkflowMetadataV3['implementationScope'] { + assertRecord(value, 'workflow metadata implementationScope'); + assertKnownKeys( + value, + ['source', 'include', 'exclude', 'modules', 'digest'], + 'workflow metadata implementationScope', + ); + if ( + value.source !== 'explicit' && + value.source !== 'inherited' && + value.source !== 'legacy_unspecified' + ) { + throw new Error('workflow metadata implementationScope source is invalid'); + } + const source = + value.source as WorkflowMetadataV3['implementationScope']['source']; + const scope = { + source, + include: parseStringSet( + value.include, + 'workflow metadata implementationScope include', + ), + exclude: parseStringSet( + value.exclude, + 'workflow metadata implementationScope exclude', + ), + modules: parseStringSet( + value.modules, + 'workflow metadata implementationScope modules', + ), + }; + const digest = parseDigest( + value.digest, + 'workflow metadata implementationScope digest', + ); + if (digest !== digestCanonicalJson(scope)) { + throw new Error( + 'workflow metadata implementationScope digest does not match scope', + ); + } + return { ...scope, digest }; +} + +function parseGovernance(value: unknown): WorkflowMetadataV3['governance'] { + assertRecord(value, 'workflow metadata governance'); + assertKnownKeys( + value, + [ + 'requirementsStatus', + 'requirementsDigest', + 'planVersion', + 'planDecision', + 'policyVersions', + 'reviewStatus', + 'reviewLedgerDigest', + 'verificationStatus', + 'verificationLedgerDigest', + ], + 'workflow metadata governance', + ); + if ( + typeof value.requirementsStatus !== 'string' || + !REQUIREMENTS_STATUSES.has(value.requirementsStatus as RequirementsStatus) + ) { + throw new Error( + 'workflow metadata governance requirementsStatus is invalid', + ); + } + if ( + typeof value.reviewStatus !== 'string' || + !REVIEW_STATUSES.has(value.reviewStatus as ReviewStatus) + ) { + throw new Error('workflow metadata governance reviewStatus is invalid'); + } + if ( + typeof value.verificationStatus !== 'string' || + !VERIFICATION_STATUSES.has(value.verificationStatus as VerificationStatus) + ) { + throw new Error( + 'workflow metadata governance verificationStatus is invalid', + ); + } + return { + requirementsStatus: value.requirementsStatus as RequirementsStatus, + requirementsDigest: parseDigest( + value.requirementsDigest, + 'workflow metadata governance requirementsDigest', + ), + planVersion: parsePositiveInteger( + value.planVersion, + 'workflow metadata governance planVersion', + ), + planDecision: parsePlanDecision(value.planDecision), + policyVersions: parsePolicyVersions(value.policyVersions), + reviewStatus: value.reviewStatus as ReviewStatus, + reviewLedgerDigest: parseDigest( + value.reviewLedgerDigest, + 'workflow metadata governance reviewLedgerDigest', + ), + verificationStatus: value.verificationStatus as VerificationStatus, + verificationLedgerDigest: parseDigest( + value.verificationLedgerDigest, + 'workflow metadata governance verificationLedgerDigest', + ), + }; +} + +function parsePolicyVersions( + value: unknown, +): WorkflowMetadataV3['governance']['policyVersions'] { + assertRecord(value, 'workflow metadata policyVersions'); + assertKnownKeys( + value, + ['planning', 'review', 'verification'], + 'workflow metadata policyVersions', + ); + return { + planning: parsePositiveIntegerOrNull( + value.planning, + 'workflow metadata planning policy version', + ), + review: parsePositiveIntegerOrNull( + value.review, + 'workflow metadata review policy version', + ), + verification: parsePositiveIntegerOrNull( + value.verification, + 'workflow metadata verification policy version', + ), + }; +} + +function parsePlanDecision(value: unknown): PlanDecision { + if (value === null) return null; + if ( + typeof value !== 'string' || + !PLAN_DECISIONS.has(value as Exclude) + ) { + throw new Error('workflow metadata governance planDecision is invalid'); + } + return value as Exclude; +} + +function parseSoloExecution( + value: unknown, +): WorkflowMetadataV3['soloExecution'] { + if (value === null) return null; + assertRecord(value, 'workflow metadata soloExecution'); + assertKnownKeys( + value, + ['state', 'planVersion', 'assignedSessionId', 'startedAt', 'completedAt'], + 'workflow metadata soloExecution', + ); + if (value.state !== 'active' && value.state !== 'completed') { + throw new Error('workflow metadata soloExecution state is invalid'); + } + const soloExecution: NonNullable = { + state: value.state, + planVersion: parsePositiveInteger( + value.planVersion, + 'workflow metadata soloExecution planVersion', + ), + assignedSessionId: parseUlidOrNull( + value.assignedSessionId, + 'workflow metadata soloExecution assignedSessionId', + ), + startedAt: parseTimestampOrNull( + value.startedAt, + 'workflow metadata soloExecution startedAt', + ), + completedAt: parseTimestampOrNull( + value.completedAt, + 'workflow metadata soloExecution completedAt', + ), + }; + if (soloExecution.state === 'active' && soloExecution.completedAt !== null) { + throw new Error('active soloExecution cannot have completedAt'); + } + if ( + soloExecution.state === 'completed' && + soloExecution.completedAt === null + ) { + throw new Error('completed soloExecution requires completedAt'); + } + return soloExecution; +} + +function parseLegacyCompatibility( + value: unknown, +): WorkflowMetadataV3['legacyCompatibility'] { + if (value === null) return null; + assertRecord(value, 'workflow metadata legacyCompatibility'); + assertKnownKeys( + value, + ['legacyTaskId', 'sourceMetadataDigest', 'fieldMapVersion'], + 'workflow metadata legacyCompatibility', + ); + return { + legacyTaskId: parseNonEmptyString( + value.legacyTaskId, + 'workflow metadata legacyCompatibility legacyTaskId', + ), + sourceMetadataDigest: parseDigest( + value.sourceMetadataDigest, + 'workflow metadata legacyCompatibility sourceMetadataDigest', + ), + fieldMapVersion: parsePositiveInteger( + value.fieldMapVersion, + 'workflow metadata legacyCompatibility fieldMapVersion', + ), + }; +} + +function assertWorkflowMetadataShape(metadata: WorkflowMetadataV3): void { + if (metadata.workflowMode === 'man') { + if (metadata.coordination !== 'single' || metadata.parent !== null) { + throw new Error( + 'man metadata requires single coordination and no parent', + ); + } + } else if (metadata.workflowMode === 'manteam') { + if ( + metadata.visibility !== 'shared' || + metadata.coordination !== 'team' || + metadata.parent !== null + ) { + throw new Error( + 'manteam metadata requires shared team coordination and no parent', + ); + } + } else if (metadata.visibility === 'local') { + if (metadata.coordination !== 'single') { + throw new Error('local manba metadata requires single coordination'); + } + } else if (metadata.parent === null) { + throw new Error('shared manba metadata requires a parent snapshot'); + } + if (metadata.parent !== null) { + if ( + metadata.parent.taskRef.namespace !== metadata.visibility || + metadata.parent.visibility !== metadata.visibility || + metadata.parent.coordination !== metadata.coordination + ) { + throw new Error( + 'workflow metadata parent snapshot must remain in the child namespace and coordination domain', + ); + } + } + if (metadata.latestCheckpointRef !== null) { + assertReferenceNamespace(metadata.visibility, metadata.latestCheckpointRef); + if ( + metadata.latestCheckpointRef.kind !== 'checkpoint' || + !sameTaskRef(metadata.latestCheckpointRef.taskRef, metadata.taskRef) + ) { + throw new Error( + 'workflow metadata latestCheckpointRef must be a checkpoint for the same task', + ); + } + } + if ((metadata.status === 'blocked') !== (metadata.blockingReason !== null)) { + throw new Error( + 'workflow metadata blockingReason is only valid for blocked workflows', + ); + } + if (metadata.workflowMode !== 'manba' && metadata.outcome !== null) { + throw new Error('only manba metadata may carry an outcome'); + } + if ( + metadata.workflowMode === 'manba' && + metadata.status !== 'completed' && + metadata.outcome !== null + ) { + throw new Error('manba outcomes are only valid for completed workflows'); + } + if ( + metadata.workflowMode === 'manba' && + metadata.status === 'completed' && + metadata.outcome === null + ) { + throw new Error('completed manba metadata requires an outcome'); + } + if (metadata.status === 'superseded' && metadata.successorTaskRef === null) { + throw new Error('superseded workflow metadata requires successorTaskRef'); + } + if (metadata.status !== 'superseded' && metadata.successorTaskRef !== null) { + throw new Error( + 'only superseded workflow metadata may carry successorTaskRef', + ); + } + if (metadata.successorTaskRef !== null) { + if ( + metadata.taskRef.namespace !== 'local' || + metadata.successorTaskRef.namespace !== 'shared' || + sameTaskRef(metadata.taskRef, metadata.successorTaskRef) + ) { + throw new Error( + 'workflow metadata successor must promote a local task to a distinct shared TaskRef', + ); + } + } + if ( + metadata.transitionState !== 'stable' && + metadata.lastOperationId === null + ) { + throw new Error('pending workflow metadata requires lastOperationId'); + } + if ( + metadata.soloExecution !== null && + (metadata.workflowMode !== 'man' || + metadata.coordination !== 'single' || + metadata.governance.planDecision !== 'solo_handoff') + ) { + throw new Error('soloExecution requires man + single + solo_handoff'); + } + if (metadata.legacyCompatibility === null) { + if ( + metadata.ownerActorId === null || + metadata.createdBy.source !== 'actor' + ) { + throw new Error( + 'native workflow metadata requires an owner and actor-createdBy', + ); + } + } else if ( + !isTerminalStatus(metadata.status) && + metadata.ownerActorId === null + ) { + throw new Error( + 'active migrated workflow metadata requires an assigned owner', + ); + } +} + +function isTerminalStatus(status: WorkflowStatus): boolean { + return ( + status === 'completed' || status === 'abandoned' || status === 'superseded' + ); +} + +function parseStringSet(value: unknown, label: string): string[] { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== 'string' || !item.trim()) + ) { + throw new Error(`${label} must be an array of non-empty strings`); + } + const normalized = sortUtf8StringSet(value); + if (normalized.length !== value.length) + throw new Error(`${label} must not contain duplicates`); + return normalized; +} + +/** + * `skippedSteps` is an ordered audit trail, unlike scope and participant + * collections. Migration must retain both the legacy spelling and sequence. + */ +function parseUniqueStringList(value: unknown, label: string): string[] { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== 'string' || !item.trim()) + ) { + throw new Error(`${label} must be an array of non-empty strings`); + } + if (new Set(value).size !== value.length) { + throw new Error(`${label} must not contain duplicates`); + } + return [...value]; +} + +function parseUlidSet(value: unknown, label: string): Ulid[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + const normalized = parseStringSet(value, label); + for (const item of normalized) assertUlid(item, label); + return normalized as Ulid[]; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parsePositiveIntegerOrNull( + value: unknown, + label: string, +): number | null { + return value === null ? null : parsePositiveInteger(value, label); +} + +function parseNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseUlidOrNull(value: unknown, label: string): Ulid | null { + if (value === null) return null; + assertUlid(value, label); + return value; +} + +function parseNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} is required`); + } + return value; +} + +function parseNonEmptyStringOrNull( + value: unknown, + label: string, +): string | null { + return value === null ? null : parseNonEmptyString(value, label); +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function parseTimestampOrNull(value: unknown, label: string): string | null { + return value === null ? null : parseTimestamp(value, label); +} diff --git a/src/installers/v3-adapter.ts b/src/installers/v3-adapter.ts new file mode 100644 index 0000000..2dd8693 --- /dev/null +++ b/src/installers/v3-adapter.ts @@ -0,0 +1,632 @@ +import { + lstat, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { + hasManagedBlock, + removeManagedBlock, + replaceManagedBlock, +} from './managed-block.js'; +import type { PlatformName } from './registry.js'; + +/** + * This is the schema of the generated bootstrap, not a product version. The + * schema manifest records the expected renderer schema while physical status + * is derived from the managed files below. + */ +export const V3_ADAPTER_VERSION = '3'; + +export const V3_ADAPTER_MANAGED_MARKER = + ''; + +const V3_CODEX_START_MARKER = ''; +const V3_CODEX_END_MARKER = ''; +const V3_ZCODE_START_MARKER = ''; +const V3_ZCODE_END_MARKER = ''; +const V3_COPILOT_START_MARKER = ''; +const V3_COPILOT_END_MARKER = ''; + +export interface V3AdapterCapabilities { + nativeModeEntry: boolean; + sessionHook: false; + promptHook: false; + sessionIdentity: 'explicit-required'; +} + +export interface V3PlatformAdapterStatus { + version: string; + installed: boolean; + ready: boolean; + target: string; + detail: string; + capabilities: V3AdapterCapabilities; +} + +/** + * A physical file touched by the V3 bootstrap renderer. Codex and ZCode + * deliberately share the AGENTS target, so activation journals these files + * rather than individual platform installs. + */ +export type V3AdapterFileTarget = + | 'claude-skill' + | 'cursor-rule' + | 'agents' + | 'copilot-instructions'; + +export interface V3AdapterFilePlan { + target: V3AdapterFileTarget; + beforeContent: string | null; + targetContent: string; +} + +export interface V3StagedAdapter { + platform: PlatformName; + /** The corresponding live target, relative to the project root. */ + target: string; + /** The generated candidate, kept under V3 staging rather than the live target. */ + stagingTarget: string; +} + +const V3_ADAPTER_FILE_TARGETS: V3AdapterFileTarget[] = [ + 'claude-skill', + 'cursor-rule', + 'agents', + 'copilot-instructions', +]; + +/** + * Calculates exact file replacements without publishing them. This lets a + * migration journal bind the combined AGENTS.md result before its first + * visible write and preserves all user-authored content outside our blocks. + */ +export async function planV3AdapterFiles( + projectRoot: string, +): Promise { + const root = path.resolve(projectRoot); + const existing = new Map(); + for (const target of V3_ADAPTER_FILE_TARGETS) { + existing.set(target, await readAdapterTarget(root, target)); + } + const agents = existing.get('agents') ?? ''; + const nextAgents = replaceManagedV3BlockText( + replaceManagedV3BlockText( + agents, + V3_CODEX_START_MARKER, + V3_CODEX_END_MARKER, + renderV3Bootstrap('codex'), + ), + V3_ZCODE_START_MARKER, + V3_ZCODE_END_MARKER, + renderV3Bootstrap('zcode'), + ); + const plans: V3AdapterFilePlan[] = [ + managedFilePlan( + 'claude-skill', + existing.get('claude-skill') ?? null, + renderClaudeSkill(renderV3Bootstrap('claude-code')), + ), + managedFilePlan( + 'cursor-rule', + existing.get('cursor-rule') ?? null, + renderCursorRule(renderV3Bootstrap('cursor')), + ), + { + target: 'agents', + beforeContent: existing.get('agents') ?? null, + targetContent: nextAgents, + }, + { + target: 'copilot-instructions', + beforeContent: existing.get('copilot-instructions') ?? null, + targetContent: replaceManagedV3BlockText( + existing.get('copilot-instructions') ?? '', + V3_COPILOT_START_MARKER, + V3_COPILOT_END_MARKER, + renderV3Bootstrap('copilot'), + ), + }, + ]; + return plans; +} + +/** Publishes one precomputed fixed-target replacement with atomic rename. */ +export async function applyV3AdapterFilePlan( + projectRoot: string, + plan: V3AdapterFilePlan, +): Promise { + const root = path.resolve(projectRoot); + if (!V3_ADAPTER_FILE_TARGETS.includes(plan.target)) { + throw new Error('MANCODE_V3_ADAPTER_TARGET_INVALID'); + } + if (typeof plan.targetContent !== 'string' || !plan.targetContent.trim()) { + throw new Error('MANCODE_V3_ADAPTER_TARGET_INVALID'); + } + const target = v3AdapterTargetPath(root, plan.target); + await mkdir(path.dirname(target), { recursive: true }); + await atomicWrite(target, plan.targetContent); +} + +/** + * Renders a complete adapter candidate under V3 staging. Shadow integration + * may inspect this exact replacement without changing a live managed file. + */ +export async function stageV3Adapter( + projectRoot: string, + platform: PlatformName, +): Promise { + const root = path.resolve(projectRoot); + const target = targetFor(platform); + const content = await renderV3AdapterCandidate(root, platform); + const stagingTarget = path.join( + '.mancode', + 'staging', + 'adapters', + 'v3', + platform, + target, + ); + const destination = path.join(root, stagingTarget); + await mkdir(path.dirname(destination), { recursive: true }); + await atomicWrite(destination, content); + return { platform, target, stagingTarget }; +} + +export function v3AdapterTargetPath( + projectRoot: string, + target: V3AdapterFileTarget, +): string { + const root = path.resolve(projectRoot); + switch (target) { + case 'claude-skill': + return path.join(root, '.claude', 'skills', 'mancode-v3', 'SKILL.md'); + case 'cursor-rule': + return path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'); + case 'agents': + return path.join(root, 'AGENTS.md'); + case 'copilot-instructions': + return path.join(root, '.github', 'copilot-instructions.md'); + } +} + +/** + * Writes only stable V3 bootstrap instructions. In particular, this never + * creates legacy authority or copies task state into an adapter file. + */ +export async function installV3Adapter( + projectRoot: string, + platform: PlatformName, +): Promise { + const root = path.resolve(projectRoot); + const content = renderV3Bootstrap(platform); + switch (platform) { + case 'claude-code': + await writeManagedFile( + path.join(root, '.claude', 'skills', 'mancode-v3', 'SKILL.md'), + renderClaudeSkill(content), + ); + break; + case 'cursor': + await writeManagedFile( + path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'), + renderCursorRule(content), + ); + break; + case 'codex': + await replaceManagedV3Block( + path.join(root, 'AGENTS.md'), + V3_CODEX_START_MARKER, + V3_CODEX_END_MARKER, + content, + ); + break; + case 'copilot': + await replaceManagedV3Block( + path.join(root, '.github', 'copilot-instructions.md'), + V3_COPILOT_START_MARKER, + V3_COPILOT_END_MARKER, + content, + ); + break; + case 'zcode': + await replaceManagedV3Block( + path.join(root, 'AGENTS.md'), + V3_ZCODE_START_MARKER, + V3_ZCODE_END_MARKER, + content, + ); + break; + } + return inspectV3Adapter(root, platform); +} + +/** Physical adapter status intentionally does not infer hook approval. */ +export async function inspectV3Adapter( + projectRoot: string, + platform: PlatformName, +): Promise { + const root = path.resolve(projectRoot); + const target = targetFor(platform); + const installed = await adapterTargetPresent(root, platform); + return { + version: V3_ADAPTER_VERSION, + installed, + ready: installed, + target, + detail: installed + ? 'V3 bootstrap is present; session identity is explicit-required.' + : 'V3 bootstrap is not installed.', + capabilities: capabilitiesFor(platform), + }; +} + +/** Actual on-disk inventory for compatibility gates; never trust manifest echo. */ +export async function inspectV3AdapterVersions( + projectRoot: string, +): Promise> { + const platforms: PlatformName[] = [ + 'claude-code', + 'codex', + 'cursor', + 'copilot', + 'zcode', + ]; + const entries = await Promise.all( + platforms.map(async (platform) => { + const status = await inspectV3Adapter(projectRoot, platform); + return [platform, status.ready ? status.version : 'missing'] as const; + }), + ); + return Object.fromEntries(entries) as Record; +} + +/** Removes only the V3 bootstrap owned by this renderer, never V3 authority. */ +export async function removeV3Adapter( + projectRoot: string, + platform: PlatformName, +): Promise { + const root = path.resolve(projectRoot); + switch (platform) { + case 'claude-code': + await removeManagedFile( + path.join(root, '.claude', 'skills', 'mancode-v3', 'SKILL.md'), + ); + return; + case 'cursor': + await removeManagedFile( + path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'), + ); + return; + case 'codex': + await removeManagedV3Block( + path.join(root, 'AGENTS.md'), + V3_CODEX_START_MARKER, + V3_CODEX_END_MARKER, + ); + return; + case 'copilot': + await removeManagedV3Block( + path.join(root, '.github', 'copilot-instructions.md'), + V3_COPILOT_START_MARKER, + V3_COPILOT_END_MARKER, + ); + return; + case 'zcode': + await removeManagedV3Block( + path.join(root, 'AGENTS.md'), + V3_ZCODE_START_MARKER, + V3_ZCODE_END_MARKER, + ); + } +} + +export function renderV3Bootstrap(platform: PlatformName): string { + const platformLabel = platformLabelFor(platform); + const modeEntry = capabilitiesFor(platform).nativeModeEntry + ? 'Use the platform mode entry only as a shortcut; resolve a Context Pack first.' + : 'This platform has no native V3 mode entry; use the CLI commands explicitly.'; + return [ + '# mancode V3 bootstrap', + '', + V3_ADAPTER_MANAGED_MARKER, + '', + `- Platform: ${platformLabel}. This file is a non-authoritative bootstrap.`, + '- Locate the project root before running mancode commands.', + `- Create or supply an explicit session: \`mancode context session new --client ${platform}\` or \`--session \`.`, + '- Read current task context with `mancode context show --purpose orient --session `; for anonymous diagnosis, include an explicit `--task `.', + '- For a mode entry, request the matching Context Pack purpose: `plan`, `implement`, `review`, `verify`, or `handoff`.', + '- Perform mutations only through `mancode workflow`, `mancode team`, and `mancode context` commands with their required revision and session arguments.', + '- Do not persist task, mode, or session state in this adapter file or any legacy state file.', + `- ${modeEntry}`, + `- No approved session or prompt hook is assumed. After a real-host spike is recorded for ${platform}, a verified host may provide MANCODE_HOST_SESSION_KEY; otherwise mutations require an explicit \`--session\`.`, + ].join('\n'); +} + +async function renderV3AdapterCandidate( + root: string, + platform: PlatformName, +): Promise { + switch (platform) { + case 'claude-code': { + const existing = await readAdapterTarget(root, 'claude-skill'); + return managedFilePlan( + 'claude-skill', + existing, + renderClaudeSkill(renderV3Bootstrap(platform)), + ).targetContent; + } + case 'cursor': { + const existing = await readAdapterTarget(root, 'cursor-rule'); + return managedFilePlan( + 'cursor-rule', + existing, + renderCursorRule(renderV3Bootstrap(platform)), + ).targetContent; + } + case 'codex': { + const existing = (await readAdapterTarget(root, 'agents')) ?? ''; + return replaceManagedV3BlockText( + existing, + V3_CODEX_START_MARKER, + V3_CODEX_END_MARKER, + renderV3Bootstrap(platform), + ); + } + case 'copilot': { + const existing = + (await readAdapterTarget(root, 'copilot-instructions')) ?? ''; + return replaceManagedV3BlockText( + existing, + V3_COPILOT_START_MARKER, + V3_COPILOT_END_MARKER, + renderV3Bootstrap(platform), + ); + } + case 'zcode': { + const existing = (await readAdapterTarget(root, 'agents')) ?? ''; + return replaceManagedV3BlockText( + existing, + V3_ZCODE_START_MARKER, + V3_ZCODE_END_MARKER, + renderV3Bootstrap(platform), + ); + } + } +} + +function renderClaudeSkill(content: string): string { + return [ + '---', + 'name: mancode-v3', + 'description: "Stable bootstrap for mancode V3 context and workflow commands."', + '---', + '', + content, + '', + ].join('\n'); +} + +function renderCursorRule(content: string): string { + return [ + '---', + 'description: "Stable bootstrap for mancode V3 context and workflow commands."', + 'alwaysApply: true', + 'globs: "**/*"', + '---', + '', + content, + '', + ].join('\n'); +} + +async function adapterTargetPresent( + root: string, + platform: PlatformName, +): Promise { + switch (platform) { + case 'claude-code': + return managedFilePresent( + path.join(root, '.claude', 'skills', 'mancode-v3', 'SKILL.md'), + ); + case 'cursor': + return managedFilePresent( + path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'), + ); + case 'codex': + return managedBlockPresent( + path.join(root, 'AGENTS.md'), + V3_CODEX_START_MARKER, + V3_CODEX_END_MARKER, + ); + case 'copilot': + return managedBlockPresent( + path.join(root, '.github', 'copilot-instructions.md'), + V3_COPILOT_START_MARKER, + V3_COPILOT_END_MARKER, + ); + case 'zcode': + return managedBlockPresent( + path.join(root, 'AGENTS.md'), + V3_ZCODE_START_MARKER, + V3_ZCODE_END_MARKER, + ); + } +} + +async function writeManagedFile( + filePath: string, + content: string, +): Promise { + const existing = await readTextIfExists(filePath); + if (existing !== null && !existing.includes(V3_ADAPTER_MANAGED_MARKER)) { + throw new Error('MANCODE_V3_ADAPTER_TARGET_USER_AUTHORED'); + } + await mkdir(path.dirname(filePath), { recursive: true }); + await atomicWrite(filePath, content); +} + +async function removeManagedFile(filePath: string): Promise { + const existing = await readTextIfExists(filePath); + if (existing?.includes(V3_ADAPTER_MANAGED_MARKER)) { + await rm(filePath, { force: true }); + } +} + +async function replaceManagedV3Block( + filePath: string, + startMarker: string, + endMarker: string, + content: string, +): Promise { + const existing = (await readTextIfExists(filePath)) ?? ''; + const block = [startMarker, content, endMarker].join('\n'); + await mkdir(path.dirname(filePath), { recursive: true }); + await atomicWrite( + filePath, + replaceManagedBlock(existing, block, startMarker, endMarker), + ); +} + +function replaceManagedV3BlockText( + existing: string, + startMarker: string, + endMarker: string, + content: string, +): string { + return replaceManagedBlock( + existing, + [startMarker, content, endMarker].join('\n'), + startMarker, + endMarker, + ); +} + +function managedFilePlan( + target: V3AdapterFileTarget, + beforeContent: string | null, + targetContent: string, +): V3AdapterFilePlan { + if ( + beforeContent !== null && + !beforeContent.includes(V3_ADAPTER_MANAGED_MARKER) + ) { + throw new Error('MANCODE_V3_ADAPTER_TARGET_USER_AUTHORED'); + } + return { target, beforeContent, targetContent }; +} + +async function readAdapterTarget( + root: string, + target: V3AdapterFileTarget, +): Promise { + const filePath = v3AdapterTargetPath(root, target); + try { + const entry = await lstat(filePath); + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return null; + throw error; + } + return readFile(filePath, 'utf8'); +} + +async function removeManagedV3Block( + filePath: string, + startMarker: string, + endMarker: string, +): Promise { + const existing = await readTextIfExists(filePath); + if (existing === null || !hasManagedBlock(existing, startMarker, endMarker)) { + return; + } + const cleaned = removeManagedBlock(existing, startMarker, endMarker); + if (cleaned.trim()) { + await atomicWrite(filePath, `${cleaned.trimEnd()}\n`); + } else { + await rm(filePath, { force: true }); + } +} + +async function managedFilePresent(filePath: string): Promise { + const content = await readTextIfExists(filePath); + return content?.includes(V3_ADAPTER_MANAGED_MARKER) ?? false; +} + +async function managedBlockPresent( + filePath: string, + startMarker: string, + endMarker: string, +): Promise { + const content = await readTextIfExists(filePath); + return content !== null && hasManagedBlock(content, startMarker, endMarker); +} + +async function readTextIfExists(filePath: string): Promise { + try { + return await readFile(filePath, 'utf8'); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return null; + throw error; + } +} + +async function atomicWrite(filePath: string, content: string): Promise { + const temporary = path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`, + ); + try { + await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' }); + await rename(temporary, filePath); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +function targetFor(platform: PlatformName): string { + switch (platform) { + case 'claude-code': + return '.claude/skills/mancode-v3/SKILL.md'; + case 'cursor': + return '.cursor/rules/mancode-v3.mdc'; + case 'codex': + case 'zcode': + return 'AGENTS.md'; + case 'copilot': + return '.github/copilot-instructions.md'; + } +} + +function platformLabelFor(platform: PlatformName): string { + switch (platform) { + case 'claude-code': + return 'Claude Code'; + case 'cursor': + return 'Cursor'; + case 'codex': + return 'Codex'; + case 'copilot': + return 'GitHub Copilot'; + case 'zcode': + return 'ZCode'; + } +} + +function capabilitiesFor(platform: PlatformName): V3AdapterCapabilities { + return { + nativeModeEntry: platform === 'claude-code' || platform === 'cursor', + sessionHook: false, + promptHook: false, + sessionIdentity: 'explicit-required', + }; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} diff --git a/src/runtime/atomic-file.ts b/src/runtime/atomic-file.ts new file mode 100644 index 0000000..a447eb6 --- /dev/null +++ b/src/runtime/atomic-file.ts @@ -0,0 +1,63 @@ +import { rename } from 'node:fs/promises'; + +const RETRIABLE_WINDOWS_RENAME_CODES = new Set(['EACCES', 'EBUSY', 'EPERM']); + +export interface AtomicReplaceOptions { + maxAttempts?: number; + retryDelayMs?: number; +} + +/** + * Replaces a sibling file that has already been fully written. Windows may + * transiently reject the rename while another process holds the destination. + */ +export async function replaceFileAtomically( + temporary: string, + target: string, + options: AtomicReplaceOptions = {}, +): Promise { + const maxAttempts = options.maxAttempts ?? 12; + const retryDelayMs = options.retryDelayMs ?? 25; + if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1) { + throw new Error('MANCODE_ATOMIC_REPLACE_ATTEMPTS_INVALID'); + } + if (!Number.isSafeInteger(retryDelayMs) || retryDelayMs < 0) { + throw new Error('MANCODE_ATOMIC_REPLACE_DELAY_INVALID'); + } + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await rename(temporary, target); + return; + } catch (error) { + if ( + process.platform !== 'win32' || + !isRetriableWindowsRenameError(error) || + attempt === maxAttempts + ) { + throw error; + } + await delay(retryDelayMs * attempt); + } + } +} + +function isRetriableWindowsRenameError( + error: unknown, +): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as NodeJS.ErrnoException).code === 'string' && + RETRIABLE_WINDOWS_RENAME_CODES.has( + (error as NodeJS.ErrnoException).code ?? '', + ) + ); +} + +async function delay(milliseconds: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} diff --git a/src/runtime/beta-gate.ts b/src/runtime/beta-gate.ts new file mode 100644 index 0000000..e70e845 --- /dev/null +++ b/src/runtime/beta-gate.ts @@ -0,0 +1,123 @@ +import { evaluateCompatibilityGate } from '../context/compatibility.js'; +import { scanLegacyAuthority } from '../context/layout.js'; +import { V3ContextStore } from '../context/store.js'; +import type { PlatformName } from '../installers/registry.js'; +import { + type V3PlatformAdapterStatus, + inspectV3Adapter, + inspectV3AdapterVersions, +} from '../installers/v3-adapter.js'; +import { VERSION } from '../version.js'; +import { listUnfinishedOperationRecoveries } from './operation-recovery-executor.js'; +import { listPlatformSessionSpikes } from './platform-spike-store.js'; +import { + type PlatformSpikeFreezeStatus, + platformSpikeFreezeStatus, +} from './platform-spike.js'; +import { readProjectRuntimeContext } from './project-runtime.js'; + +export interface V3BetaGateResult { + schemaVersion: 1; + ready: boolean; + blockers: string[]; + activationState: string; + compatibility: ReturnType; + sessionEvidence: PlatformSpikeFreezeStatus; + adapters: Record; + runtimeBinding: 'ready' | 'registration_required'; + unfinishedOperations: Array<{ + operationId: string; + type: string; + recoveryAction: string; + }>; +} + +/** + * Conservative local release readiness check. The five host spikes remain + * operator-collected evidence, so this gate refuses to infer them from an + * adapter installation or an environment variable alone. + */ +export async function evaluateV3BetaGate( + projectRoot: string, +): Promise { + const store = new V3ContextStore(projectRoot); + const [ + snapshot, + legacy, + adapterVersions, + adapterEntries, + spikes, + unfinished, + ] = await Promise.all([ + store.readProjectSnapshot(), + scanLegacyAuthority(projectRoot), + inspectV3AdapterVersions(projectRoot), + Promise.all( + BETA_PLATFORMS.map( + async (platform) => + [platform, await inspectV3Adapter(projectRoot, platform)] as const, + ), + ), + listPlatformSessionSpikes(projectRoot), + listUnfinishedOperationRecoveries(projectRoot), + ]); + const compatibility = evaluateCompatibilityGate({ + manifest: snapshot.manifest, + expectedSchemaEpoch: snapshot.manifest.epoch, + readerVersion: VERSION, + writerVersion: VERSION, + adapterVersions, + currentLegacyBaseline: legacy.baseline, + legacyAuthorityPresent: legacy.authorityPresent, + operation: 'v3_business_write', + }); + const sessionEvidence = platformSpikeFreezeStatus(spikes); + const adapters = Object.fromEntries(adapterEntries) as Record< + PlatformName, + V3PlatformAdapterStatus + >; + const runtimeBinding = await readProjectRuntimeContext(projectRoot) + .then(() => 'ready' as const) + .catch(() => 'registration_required' as const); + const blockers = [ + ...(snapshot.manifest.activationState === 'v3_active' + ? [] + : ['MANCODE_BETA_V3_ACTIVATION_REQUIRED']), + ...compatibility.failures, + ...(sessionEvidence.ready + ? [] + : ['MANCODE_BETA_PLATFORM_SESSION_SPIKE_REQUIRED']), + ...(Object.values(adapters).every((adapter) => adapter.ready) + ? [] + : ['MANCODE_BETA_ADAPTER_SHADOW_OR_INSTALL_REQUIRED']), + ...(runtimeBinding === 'ready' + ? [] + : ['MANCODE_BETA_RUNTIME_BINDING_REQUIRED']), + ...(unfinished.length === 0 + ? [] + : ['MANCODE_BETA_OPERATION_RECOVERY_REQUIRED']), + ]; + return { + schemaVersion: 1, + ready: blockers.length === 0, + blockers: [...new Set(blockers)].sort(), + activationState: snapshot.manifest.activationState, + compatibility, + sessionEvidence, + adapters, + runtimeBinding, + unfinishedOperations: unfinished.map((recovery) => ({ + operationId: recovery.journal.operationId, + type: recovery.journal.type, + recoveryAction: recovery.recoveryAction, + })), + }; +} + +const BETA_PLATFORMS: PlatformName[] = [ + 'claude-code', + 'codex', + 'cursor', + 'copilot', + 'zcode', +]; diff --git a/src/runtime/claim-store.ts b/src/runtime/claim-store.ts new file mode 100644 index 0000000..19c340d --- /dev/null +++ b/src/runtime/claim-store.ts @@ -0,0 +1,214 @@ +import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { assertUlid } from '../context/ids.js'; +import { + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { + type ClaimV1, + assertClaimTransition, + parseClaim, +} from '../team/claims.js'; +import { replaceFileAtomically } from './atomic-file.js'; +import { type EntityHomeStore, claimDirectory } from './entity-home-store.js'; + +/** Creates one immutable claim identity, or proves a retry is byte-equivalent. */ +export async function createClaim( + store: EntityHomeStore, + value: ClaimV1, +): Promise { + const claim = assertClaimMatchesStore(store, parseClaim(value)); + const target = claimPath(store, claim.claimId); + await mkdir(path.dirname(target), { recursive: true }); + await assertSafeClaimDirectory(store); + try { + await writeFile(target, serialize(claim), { encoding: 'utf8', flag: 'wx' }); + await assertSafeClaimFile(target); + return claim; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readClaim(store, claim.claimId); + if ( + existing !== null && + digestCanonicalJson(existing) === digestCanonicalJson(claim) + ) { + return existing; + } + throw new Error('MANCODE_CLAIM_ID_CONFLICT'); + } +} + +/** Reads a single coordination-authority claim without accepting links. */ +export async function readClaim( + store: EntityHomeStore, + claimId: string, +): Promise { + assertUlid(claimId, 'claimId'); + const target = claimPath(store, claimId); + try { + await assertSafeClaimDirectory(store); + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_CLAIM_PATH_UNSAFE'); + } + const claim = assertClaimMatchesStore( + store, + parseClaim(JSON.parse(await readFile(target, 'utf8'))), + ); + if (claim.claimId !== claimId) { + throw new Error('MANCODE_CLAIM_CORRUPT'); + } + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_CLAIM_PATH_UNSAFE'); + } + return claim; + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_CLAIM_CORRUPT'); + } + throw error; + } +} + +/** + * Performs the revision CAS required for a mutable claim transition. The + * caller must already hold the canonical task and claim entity locks. + */ +export async function updateClaim( + store: EntityHomeStore, + nextValue: ClaimV1, + expectedRevision: number, +): Promise { + const next = assertClaimMatchesStore(store, parseClaim(nextValue)); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) { + throw new Error('MANCODE_CLAIM_REVISION_INVALID'); + } + const previous = await readClaim(store, next.claimId); + if (previous === null) throw new Error('MANCODE_CLAIM_NOT_FOUND'); + if (previous.revision !== expectedRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + assertClaimTransition(previous, next); + await atomicWriteClaim(store, next); + return next; +} + +/** Lists all coordination claims, optionally narrowed to one shared task. */ +export async function listClaims( + store: EntityHomeStore, + taskRef?: TaskRef, +): Promise { + const requested = taskRef === undefined ? null : parseTaskRefValue(taskRef); + if (requested?.namespace === 'local') { + throw new Error('claims may only target shared TaskRefs'); + } + let entries: string[]; + try { + await assertSafeClaimDirectory(store); + entries = await readdir(claimDirectory(store)); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const claims: ClaimV1[] = []; + for (const entry of entries.sort(compareUtf8)) { + if (!entry.endsWith('.json')) continue; + const claimId = entry.slice(0, -'.json'.length); + try { + assertUlid(claimId, 'claim filename'); + } catch { + throw new Error('MANCODE_CLAIM_CORRUPT'); + } + const claim = await readClaim(store, claimId); + if (claim === null) throw new Error('MANCODE_CLAIM_CHANGED_DURING_READ'); + if (requested === null || sameTaskRef(claim.taskRef, requested)) { + claims.push(claim); + } + } + return claims; +} + +export function claimPath(store: EntityHomeStore, claimId: string): string { + assertUlid(claimId, 'claimId'); + return path.join(claimDirectory(store), `${claimId}.json`); +} + +async function atomicWriteClaim( + store: EntityHomeStore, + claim: ClaimV1, +): Promise { + const target = claimPath(store, claim.claimId); + await mkdir(path.dirname(target), { recursive: true }); + await assertSafeClaimDirectory(store); + const temporary = path.join( + path.dirname(target), + `.${claim.claimId}.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, serialize(claim), { + encoding: 'utf8', + flag: 'wx', + }); + await replaceFileAtomically(temporary, target); + await assertSafeClaimFile(target); +} + +function assertClaimMatchesStore( + store: EntityHomeStore, + claim: ClaimV1, +): ClaimV1 { + if (claim.workspaceId !== store.workspaceId) { + throw new Error('MANCODE_CLAIM_WORKSPACE_MISMATCH'); + } + return claim; +} + +async function assertSafeClaimFile(target: string): Promise { + const stat = await lstat(target); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('MANCODE_CLAIM_PATH_UNSAFE'); + } +} + +async function assertSafeClaimDirectory(store: EntityHomeStore): Promise { + const directory = claimDirectory(store); + const stat = await lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('MANCODE_CLAIM_PATH_UNSAFE'); + } +} + +function serialize(claim: ClaimV1): string { + return `${JSON.stringify(claim, null, 2)}\n`; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/diagnostics.ts b/src/runtime/diagnostics.ts new file mode 100644 index 0000000..b6b512b --- /dev/null +++ b/src/runtime/diagnostics.ts @@ -0,0 +1,320 @@ +import { + lstat, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; + +export type ClaimConflictDiagnosticLevel = + | 'info' + | 'warning' + | 'blocker' + | 'unknown'; + +export type LocalDiagnosticEvent = + | { kind: 'context_stale' } + | { kind: 'revision_conflict' } + | { kind: 'claim_conflict'; level: ClaimConflictDiagnosticLevel } + | { kind: 'repair_operation' } + | { kind: 'migration_split_brain' } + | { kind: 'adapter_capability_downgrade' }; + +export interface LocalDiagnosticsConfigV1 { + schemaVersion: 1; + enabled: boolean; + updatedAt: string; +} + +/** This file intentionally has no free-form values, paths, identities, or text. */ +export interface LocalDiagnosticsV1 { + schemaVersion: 1; + contextStaleCount: number; + revisionConflictCount: number; + claimConflictCounts: Record; + repairOperationCount: number; + migrationSplitBrainDetectionCount: number; + adapterCapabilityDowngradeCount: number; + updatedAt: string; +} + +const CLAIM_CONFLICT_LEVELS: ClaimConflictDiagnosticLevel[] = [ + 'info', + 'warning', + 'blocker', + 'unknown', +]; + +/** Missing configuration means local aggregate diagnostics are enabled. */ +export async function readLocalDiagnosticsConfig( + projectRoot: string, +): Promise { + const stored = await readJsonOrNull( + localDiagnosticsConfigPath(projectRoot), + parseLocalDiagnosticsConfig, + ); + return ( + stored ?? { + schemaVersion: 1, + enabled: true, + updatedAt: new Date(0).toISOString(), + } + ); +} + +export async function readLocalDiagnostics( + projectRoot: string, +): Promise { + return readJsonOrNull( + localDiagnosticsPath(projectRoot), + parseLocalDiagnostics, + ); +} + +/** Disabling immediately removes all collected local aggregates. */ +export async function setLocalDiagnosticsEnabled( + projectRoot: string, + enabled: boolean, + now: Date = new Date(), +): Promise { + const config: LocalDiagnosticsConfigV1 = { + schemaVersion: 1, + enabled, + updatedAt: now.toISOString(), + }; + await writeJsonAtomic(localDiagnosticsConfigPath(projectRoot), config); + if (!enabled) { + await rm(localDiagnosticsPath(projectRoot), { force: true }); + } + return config; +} + +/** + * Records one fixed metric. The event type deliberately has no field that can + * carry project content, a filesystem path, or actor identity. + */ +export async function recordLocalDiagnostic( + projectRoot: string, + event: LocalDiagnosticEvent, + now: Date = new Date(), +): Promise { + const config = await readLocalDiagnosticsConfig(projectRoot); + if (!config.enabled) return null; + const current = + (await readLocalDiagnostics(projectRoot)) ?? emptyLocalDiagnostics(now); + const next = incrementDiagnostic(current, event, now); + await writeJsonAtomic(localDiagnosticsPath(projectRoot), next); + return next; +} + +export function localDiagnosticsPath(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'diagnostics.json', + ); +} + +export function localDiagnosticsConfigPath(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'diagnostics-config.json', + ); +} + +export function parseLocalDiagnosticsConfig( + value: unknown, +): LocalDiagnosticsConfigV1 { + assertRecord(value, 'local diagnostics config'); + assertKnownKeys( + value, + ['schemaVersion', 'enabled', 'updatedAt'], + 'local diagnostics config', + ); + if (value.schemaVersion !== 1 || typeof value.enabled !== 'boolean') { + throw new Error('local diagnostics config is invalid'); + } + return { + schemaVersion: 1, + enabled: value.enabled, + updatedAt: parseTimestamp( + value.updatedAt, + 'local diagnostics config updatedAt', + ), + }; +} + +export function parseLocalDiagnostics(value: unknown): LocalDiagnosticsV1 { + assertRecord(value, 'local diagnostics'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'contextStaleCount', + 'revisionConflictCount', + 'claimConflictCounts', + 'repairOperationCount', + 'migrationSplitBrainDetectionCount', + 'adapterCapabilityDowngradeCount', + 'updatedAt', + ], + 'local diagnostics', + ); + if (value.schemaVersion !== 1) { + throw new Error('local diagnostics schemaVersion is invalid'); + } + assertRecord( + value.claimConflictCounts, + 'local diagnostics claimConflictCounts', + ); + assertKnownKeys( + value.claimConflictCounts, + CLAIM_CONFLICT_LEVELS, + 'local diagnostics claimConflictCounts', + ); + const claimConflictCounts = {} as Record< + ClaimConflictDiagnosticLevel, + number + >; + for (const level of CLAIM_CONFLICT_LEVELS) { + claimConflictCounts[level] = parseCount( + value.claimConflictCounts[level], + `local diagnostics claim conflict ${level}`, + ); + } + return { + schemaVersion: 1, + contextStaleCount: parseCount( + value.contextStaleCount, + 'local diagnostics contextStaleCount', + ), + revisionConflictCount: parseCount( + value.revisionConflictCount, + 'local diagnostics revisionConflictCount', + ), + claimConflictCounts, + repairOperationCount: parseCount( + value.repairOperationCount, + 'local diagnostics repairOperationCount', + ), + migrationSplitBrainDetectionCount: parseCount( + value.migrationSplitBrainDetectionCount, + 'local diagnostics migrationSplitBrainDetectionCount', + ), + adapterCapabilityDowngradeCount: parseCount( + value.adapterCapabilityDowngradeCount, + 'local diagnostics adapterCapabilityDowngradeCount', + ), + updatedAt: parseTimestamp(value.updatedAt, 'local diagnostics updatedAt'), + }; +} + +function emptyLocalDiagnostics(now: Date): LocalDiagnosticsV1 { + return { + schemaVersion: 1, + contextStaleCount: 0, + revisionConflictCount: 0, + claimConflictCounts: { info: 0, warning: 0, blocker: 0, unknown: 0 }, + repairOperationCount: 0, + migrationSplitBrainDetectionCount: 0, + adapterCapabilityDowngradeCount: 0, + updatedAt: now.toISOString(), + }; +} + +function incrementDiagnostic( + current: LocalDiagnosticsV1, + event: LocalDiagnosticEvent, + now: Date, +): LocalDiagnosticsV1 { + const next: LocalDiagnosticsV1 = { + ...current, + claimConflictCounts: { ...current.claimConflictCounts }, + updatedAt: now.toISOString(), + }; + switch (event.kind) { + case 'context_stale': + next.contextStaleCount += 1; + return next; + case 'revision_conflict': + next.revisionConflictCount += 1; + return next; + case 'claim_conflict': + next.claimConflictCounts[event.level] += 1; + return next; + case 'repair_operation': + next.repairOperationCount += 1; + return next; + case 'migration_split_brain': + next.migrationSplitBrainDetectionCount += 1; + return next; + case 'adapter_capability_downgrade': + next.adapterCapabilityDowngradeCount += 1; + return next; + } +} + +async function readJsonOrNull( + target: string, + parser: (value: unknown) => T, +): Promise { + try { + const entry = await lstat(target); + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error('MANCODE_DIAGNOSTICS_PATH_UNSAFE'); + } + return parser(JSON.parse(await readFile(target, 'utf8'))); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_DIAGNOSTICS_CORRUPT'); + } + throw error; + } +} + +async function writeJsonAtomic(target: string, value: unknown): Promise { + await mkdir(path.dirname(target), { recursive: true }); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`, + ); + try { + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +function parseCount(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/entity-home-store.ts b/src/runtime/entity-home-store.ts new file mode 100644 index 0000000..589c658 --- /dev/null +++ b/src/runtime/entity-home-store.ts @@ -0,0 +1,176 @@ +import path from 'node:path'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { type TaskRef, parseTaskRefValue } from '../context/task-ref.js'; + +export type EntityHomeStoreKind = + | 'checkout_local' + | 'workspace_common_dir' + | 'non_git_shared'; + +export interface EntityHomeStoreContext { + projectRoot: string; + workspaceId: Ulid; + checkoutId: Ulid; + gitCommonDir: string | null; + repositoryBindingId: Ulid | null; +} + +export interface EntityHomeStore { + kind: EntityHomeStoreKind; + storeId: string; + root: string; + workspaceId: Ulid; + checkoutId: Ulid | null; + repositoryBindingId: Ulid | null; +} + +export function resolveTaskEntityHomeStore( + context: EntityHomeStoreContext, + taskRef: TaskRef, +): EntityHomeStore { + const normalized = normalizeContext(context); + const task = parseTaskRefValue(taskRef); + return task.namespace === 'local' + ? localTaskHomeStore(normalized) + : coordinationEntityHomeStore(normalized); +} + +/** The checkout-local operation store is addressable without a TaskRef. */ +export function resolveLocalEntityHomeStore( + context: EntityHomeStoreContext, +): EntityHomeStore { + return localTaskHomeStore(normalizeContext(context)); +} + +/** Claims, handoffs, shared task operations, and task-head fences share this home. */ +export function resolveCoordinationEntityHomeStore( + context: EntityHomeStoreContext, +): EntityHomeStore { + return coordinationEntityHomeStore(normalizeContext(context)); +} + +export function operationDirectory(store: EntityHomeStore): string { + return path.join(store.root, 'operations'); +} + +export function reservationDirectory(store: EntityHomeStore): string { + return path.join(store.root, 'reservations'); +} + +export function lockDirectory(store: EntityHomeStore): string { + return path.join(store.root, 'locks'); +} + +export function claimDirectory(store: EntityHomeStore): string { + assertCoordinationStore(store, 'claim'); + return path.join(store.root, 'claims'); +} + +export function handoffDirectory(store: EntityHomeStore): string { + assertCoordinationStore(store, 'handoff'); + return path.join(store.root, 'handoffs'); +} + +export function taskHeadDirectory(store: EntityHomeStore): string { + assertCoordinationStore(store, 'task head fence'); + return path.join(store.root, 'task-heads'); +} + +function normalizeContext( + context: EntityHomeStoreContext, +): EntityHomeStoreContext { + assertUlid(context.workspaceId, 'entity home store workspaceId'); + assertUlid(context.checkoutId, 'entity home store checkoutId'); + if (typeof context.projectRoot !== 'string' || !context.projectRoot.trim()) { + throw new Error('entity home store projectRoot is required'); + } + if (context.gitCommonDir === null) { + if (context.repositoryBindingId !== null) { + assertUlid( + context.repositoryBindingId, + 'entity home store repositoryBindingId', + ); + } + return { + ...context, + projectRoot: path.resolve(context.projectRoot), + gitCommonDir: null, + }; + } + if ( + typeof context.gitCommonDir !== 'string' || + !context.gitCommonDir.trim() + ) { + throw new Error('entity home store gitCommonDir must be a path or null'); + } + if (context.repositoryBindingId === null) { + throw new Error( + 'git coordination requires an entity home store repositoryBindingId', + ); + } + assertUlid( + context.repositoryBindingId, + 'entity home store repositoryBindingId', + ); + return { + ...context, + projectRoot: path.resolve(context.projectRoot), + gitCommonDir: path.resolve(context.gitCommonDir), + }; +} + +function localTaskHomeStore(context: EntityHomeStoreContext): EntityHomeStore { + return { + kind: 'checkout_local', + storeId: `checkout:${context.checkoutId}:${context.workspaceId}`, + root: path.join(context.projectRoot, '.mancode', 'local', 'runtime'), + workspaceId: context.workspaceId, + checkoutId: context.checkoutId, + repositoryBindingId: context.repositoryBindingId, + }; +} + +function coordinationEntityHomeStore( + context: EntityHomeStoreContext, +): EntityHomeStore { + if (context.gitCommonDir === null) { + return { + kind: 'non_git_shared', + storeId: `non-git:${context.workspaceId}`, + root: path.join( + context.projectRoot, + '.mancode', + 'runtime', + 'non-git', + context.workspaceId, + ), + workspaceId: context.workspaceId, + checkoutId: null, + repositoryBindingId: context.repositoryBindingId, + }; + } + if (context.repositoryBindingId === null) { + throw new Error('git coordination requires a repositoryBindingId'); + } + return { + kind: 'workspace_common_dir', + storeId: `workspace:${context.repositoryBindingId}:${context.workspaceId}`, + root: path.join( + context.gitCommonDir, + 'mancode', + 'workspaces', + context.workspaceId, + ), + workspaceId: context.workspaceId, + checkoutId: null, + repositoryBindingId: context.repositoryBindingId, + }; +} + +function assertCoordinationStore(store: EntityHomeStore, entity: string): void { + if (store.kind === 'checkout_local') { + throw new Error( + `${entity} requires a shared coordination entity home store`, + ); + } +} diff --git a/src/runtime/handoff-store.ts b/src/runtime/handoff-store.ts new file mode 100644 index 0000000..3e4a7e6 --- /dev/null +++ b/src/runtime/handoff-store.ts @@ -0,0 +1,203 @@ +import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { assertUlid } from '../context/ids.js'; +import { + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { + type HandoffV1, + assertHandoffTransition, + parseHandoff, +} from '../team/handoff.js'; +import { replaceFileAtomically } from './atomic-file.js'; +import { type EntityHomeStore, handoffDirectory } from './entity-home-store.js'; + +/** Creates one immutable handoff identity, or proves a retry is identical. */ +export async function createHandoff( + store: EntityHomeStore, + value: HandoffV1, +): Promise { + const handoff = parseHandoff(value); + const target = handoffPath(store, handoff.handoffId); + await mkdir(path.dirname(target), { recursive: true }); + await assertSafeHandoffDirectory(store); + try { + await writeFile(target, serialize(handoff), { + encoding: 'utf8', + flag: 'wx', + }); + await assertSafeHandoffFile(target); + return handoff; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readHandoff(store, handoff.handoffId); + if ( + existing !== null && + digestCanonicalJson(existing) === digestCanonicalJson(handoff) + ) { + return existing; + } + throw new Error('MANCODE_HANDOFF_ID_CONFLICT'); + } +} + +/** Reads one coordination-authority handoff without accepting a symlink. */ +export async function readHandoff( + store: EntityHomeStore, + handoffId: string, +): Promise { + assertUlid(handoffId, 'handoffId'); + const target = handoffPath(store, handoffId); + try { + await assertSafeHandoffDirectory(store); + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_HANDOFF_PATH_UNSAFE'); + } + const handoff = parseHandoff(JSON.parse(await readFile(target, 'utf8'))); + if (handoff.handoffId !== handoffId) { + throw new Error('MANCODE_HANDOFF_CORRUPT'); + } + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_HANDOFF_PATH_UNSAFE'); + } + return handoff; + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) + throw new Error('MANCODE_HANDOFF_CORRUPT'); + throw error; + } +} + +/** Performs the revision CAS required for a mutable handoff transition. */ +export async function updateHandoff( + store: EntityHomeStore, + nextValue: HandoffV1, + expectedRevision: number, + actorId: string, +): Promise { + const next = parseHandoff(nextValue); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) { + throw new Error('MANCODE_HANDOFF_REVISION_INVALID'); + } + const previous = await readHandoff(store, next.handoffId); + if (previous === null) throw new Error('MANCODE_HANDOFF_NOT_FOUND'); + if (previous.revision !== expectedRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + assertHandoffTransition(previous, next, actorId as HandoffV1['fromActorId']); + await atomicWriteHandoff(store, next); + return next; +} + +/** Lists coordination handoffs, optionally narrowed to one shared task. */ +export async function listHandoffs( + store: EntityHomeStore, + taskRef?: TaskRef, +): Promise { + const requested = taskRef === undefined ? null : parseTaskRefValue(taskRef); + if (requested?.namespace === 'local') { + throw new Error('handoffs may only target shared TaskRefs'); + } + let entries: string[]; + try { + await assertSafeHandoffDirectory(store); + entries = await readdir(handoffDirectory(store)); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const handoffs: HandoffV1[] = []; + for (const entry of entries.sort(compareUtf8)) { + if (!entry.endsWith('.json')) continue; + const handoffId = entry.slice(0, -'.json'.length); + try { + assertUlid(handoffId, 'handoff filename'); + } catch { + throw new Error('MANCODE_HANDOFF_CORRUPT'); + } + const handoff = await readHandoff(store, handoffId); + if (handoff === null) + throw new Error('MANCODE_HANDOFF_CHANGED_DURING_READ'); + if (requested === null || sameTaskRef(handoff.taskRef, requested)) { + handoffs.push(handoff); + } + } + return handoffs; +} + +export function handoffPath(store: EntityHomeStore, handoffId: string): string { + assertUlid(handoffId, 'handoffId'); + return path.join(handoffDirectory(store), `${handoffId}.json`); +} + +async function atomicWriteHandoff( + store: EntityHomeStore, + handoff: HandoffV1, +): Promise { + const target = handoffPath(store, handoff.handoffId); + await mkdir(path.dirname(target), { recursive: true }); + await assertSafeHandoffDirectory(store); + const temporary = path.join( + path.dirname(target), + `.${handoff.handoffId}.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, serialize(handoff), { + encoding: 'utf8', + flag: 'wx', + }); + await replaceFileAtomically(temporary, target); + await assertSafeHandoffFile(target); +} + +async function assertSafeHandoffFile(target: string): Promise { + const stat = await lstat(target); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('MANCODE_HANDOFF_PATH_UNSAFE'); + } +} + +async function assertSafeHandoffDirectory( + store: EntityHomeStore, +): Promise { + const stat = await lstat(handoffDirectory(store)); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('MANCODE_HANDOFF_PATH_UNSAFE'); + } +} + +function serialize(handoff: HandoffV1): string { + return `${JSON.stringify(handoff, null, 2)}\n`; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/local-lock.ts b/src/runtime/local-lock.ts new file mode 100644 index 0000000..8c5cba4 --- /dev/null +++ b/src/runtime/local-lock.ts @@ -0,0 +1,462 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { type EntityHomeStore, lockDirectory } from './entity-home-store.js'; + +export interface LocalLockOwnerV1 { + schemaVersion: 1; + operationId: Ulid; + processId: number; + storeId: string; + entityLockKey: string; + acquiredAt: string; + /** + * A bounded lease makes an abandoned lock diagnosable and renewable. Older + * lock files omit it; they remain readable but are never stolen blindly. + */ + leaseExpiresAt: string | null; +} + +export interface AcquireLocalLockInput { + operationId: Ulid; + entityLockKey: string; + processId?: number; + now?: Date; + leaseMs?: number; +} + +export interface LocalLockHandle { + readonly storeId: string; + readonly entityLockKey: string; + readonly owner: LocalLockOwnerV1; + renew(now?: Date): Promise; + release(): Promise; +} + +/** + * A durable multi-store operation holds each store's canonical locks in one + * deterministic global order. Secondary operation reservations describe the + * same lock keys for recovery after the original process exits. + */ +export interface OperationEntityLockTarget { + store: EntityHomeStore; + entityLockKeys: string[]; +} + +const ENTITY_LOCK_KEY_PATTERN = /^[a-z][a-z0-9_-]*:[^\0/\\]+$/; +const DEFAULT_LOCK_LEASE_MS = 30_000; +const MINIMUM_LOCK_LEASE_MS = 1_000; +const MAXIMUM_LOCK_LEASE_MS = 5 * 60_000; + +export async function acquireLocalLock( + store: EntityHomeStore, + input: AcquireLocalLockInput, +): Promise { + const owner = createLockOwner(store, input); + const directory = lockPath(store, owner.entityLockKey); + await mkdir(lockDirectory(store), { recursive: true }); + let acquiredDirectory = false; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await mkdir(directory); + acquiredDirectory = true; + break; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + if (!(await reclaimExpiredDeadLock(store, owner.entityLockKey, owner))) { + throw new Error('MANCODE_LOCK_HELD'); + } + } + } + if (!acquiredDirectory) throw new Error('MANCODE_LOCK_HELD'); + try { + await writeFile( + path.join(directory, 'owner.json'), + `${JSON.stringify(owner, null, 2)}\n`, + { encoding: 'utf8', flag: 'wx' }, + ); + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } + let released = false; + let currentOwner = owner; + return { + storeId: store.storeId, + entityLockKey: owner.entityLockKey, + get owner(): LocalLockOwnerV1 { + return currentOwner; + }, + async renew(now: Date = new Date()): Promise { + if (released) throw new Error('MANCODE_LOCK_OWNERSHIP_LOST'); + const next = renewLockOwner(currentOwner, now); + const current = await readLocalLock(store, currentOwner.entityLockKey); + if (!sameLockOwner(current, currentOwner)) { + throw new Error('MANCODE_LOCK_OWNERSHIP_LOST'); + } + await atomicWriteLockOwner(directory, next); + currentOwner = next; + }, + async release(): Promise { + if (released) return; + const current = await readLocalLock(store, currentOwner.entityLockKey); + if (!sameLockOwner(current, currentOwner)) { + throw new Error('MANCODE_LOCK_OWNERSHIP_LOST'); + } + await rm(directory, { recursive: true, force: false }); + released = true; + }, + }; +} + +/** Acquires every store-local lock in UTF-8 key order to avoid lock cycles. */ +export async function acquireEntityLocks( + store: EntityHomeStore, + operationId: Ulid, + entityLockKeys: string[], + options: { processId?: number; now?: Date; leaseMs?: number } = {}, +): Promise { + assertUlid(operationId, 'local lock operationId'); + const orderedKeys = normalizeEntityLockKeys(entityLockKeys); + const locks: LocalLockHandle[] = []; + try { + for (const entityLockKey of orderedKeys) { + locks.push( + await acquireLocalLock(store, { + operationId, + entityLockKey, + processId: options.processId, + now: options.now, + leaseMs: options.leaseMs, + }), + ); + } + return locks; + } catch (error) { + await Promise.allSettled( + [...locks].reverse().map((lock) => lock.release()), + ); + throw error; + } +} + +/** + * Acquires canonical locks across one or more entity-home stores. Stores are + * ordered before their locally ordered keys, avoiding source/destination + * lock cycles for operations such as local-to-shared promotion. + */ +export async function acquireOperationEntityLocks( + operationId: Ulid, + targets: OperationEntityLockTarget[], + options: { processId?: number; now?: Date; leaseMs?: number } = {}, +): Promise { + assertUlid(operationId, 'operation lock operationId'); + if (!Array.isArray(targets) || targets.length === 0) { + throw new Error('operation lock targets must not be empty'); + } + const byStoreId = new Map(); + for (const target of targets) { + const normalizedKeys = normalizeEntityLockKeys(target.entityLockKeys); + const existing = byStoreId.get(target.store.storeId); + if (existing !== undefined) { + throw new Error('operation lock targets must not repeat a store'); + } + byStoreId.set(target.store.storeId, { + store: target.store, + entityLockKeys: normalizedKeys, + }); + } + const locks: LocalLockHandle[] = []; + try { + for (const target of [...byStoreId.values()].sort((left, right) => + compareUtf8(left.store.storeId, right.store.storeId), + )) { + locks.push( + ...(await acquireEntityLocks( + target.store, + operationId, + target.entityLockKeys, + options, + )), + ); + } + return locks; + } catch (error) { + await Promise.allSettled( + [...locks].reverse().map((lock) => lock.release()), + ); + throw error; + } +} + +export async function readLocalLock( + store: EntityHomeStore, + entityLockKey: string, +): Promise { + assertEntityLockKey(entityLockKey); + try { + const raw = await readFile( + path.join(lockPath(store, entityLockKey), 'owner.json'), + 'utf8', + ); + const owner = parseLocalLockOwner(JSON.parse(raw)); + if ( + owner.storeId !== store.storeId || + owner.entityLockKey !== entityLockKey + ) { + throw new Error('MANCODE_LOCK_CORRUPT'); + } + return owner; + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) throw new Error('MANCODE_LOCK_CORRUPT'); + throw error; + } +} + +export function entityLockPath( + store: EntityHomeStore, + entityLockKey: string, +): string { + return lockPath(store, entityLockKey); +} + +export function parseLocalLockOwner(value: unknown): LocalLockOwnerV1 { + assertRecord(value, 'local lock owner'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'processId', + 'storeId', + 'entityLockKey', + 'acquiredAt', + 'leaseExpiresAt', + ], + 'local lock owner', + ); + if (value.schemaVersion !== 1) { + throw new Error('local lock owner schemaVersion must be 1'); + } + assertUlid(value.operationId, 'local lock operationId'); + assertEntityLockKey(value.entityLockKey); + if ( + typeof value.processId !== 'number' || + !Number.isSafeInteger(value.processId) || + value.processId < 1 + ) { + throw new Error('local lock processId must be a positive integer'); + } + if (typeof value.storeId !== 'string' || !value.storeId.trim()) { + throw new Error('local lock storeId is required'); + } + return { + schemaVersion: 1, + operationId: value.operationId, + processId: value.processId, + storeId: value.storeId, + entityLockKey: value.entityLockKey, + acquiredAt: parseTimestamp(value.acquiredAt, 'local lock acquiredAt'), + leaseExpiresAt: + value.leaseExpiresAt === undefined + ? null + : value.leaseExpiresAt === null + ? null + : parseTimestamp(value.leaseExpiresAt, 'local lock leaseExpiresAt'), + }; +} + +export function normalizeEntityLockKeys(entityLockKeys: string[]): string[] { + if (!Array.isArray(entityLockKeys) || entityLockKeys.length === 0) { + throw new Error('entity lock keys must be a non-empty array'); + } + const keys = new Set(); + for (const key of entityLockKeys) { + assertEntityLockKey(key); + if (keys.has(key)) throw new Error('entity lock keys must not repeat'); + keys.add(key); + } + return [...keys].sort(compareUtf8); +} + +function createLockOwner( + store: EntityHomeStore, + input: AcquireLocalLockInput, +): LocalLockOwnerV1 { + assertUlid(input.operationId, 'local lock operationId'); + assertEntityLockKey(input.entityLockKey); + const processId = input.processId ?? process.pid; + if (!Number.isSafeInteger(processId) || processId < 1) { + throw new Error('local lock processId must be a positive integer'); + } + const now = input.now ?? new Date(); + const leaseMs = parseLeaseMs(input.leaseMs); + return { + schemaVersion: 1, + operationId: input.operationId, + processId, + storeId: store.storeId, + entityLockKey: input.entityLockKey, + acquiredAt: now.toISOString(), + leaseExpiresAt: new Date(now.getTime() + leaseMs).toISOString(), + }; +} + +function renewLockOwner(owner: LocalLockOwnerV1, now: Date): LocalLockOwnerV1 { + if (owner.leaseExpiresAt === null) { + throw new Error('MANCODE_LOCK_LEASE_UNAVAILABLE'); + } + return { + ...owner, + leaseExpiresAt: new Date(now.getTime() + lockLeaseMs(owner)).toISOString(), + }; +} + +/** + * A crashed process must not leave an unrecoverable directory lock behind. + * We reclaim only an expired lease whose process is demonstrably gone. An + * expired but live process remains protected: stealing it could create two + * writers during a long operation. + */ +async function reclaimExpiredDeadLock( + store: EntityHomeStore, + entityLockKey: string, + contender: LocalLockOwnerV1, +): Promise { + const existing = await readLocalLock(store, entityLockKey); + if ( + existing === null || + existing.leaseExpiresAt === null || + Date.parse(existing.leaseExpiresAt) >= Date.parse(contender.acquiredAt) || + processIsAlive(existing.processId) + ) { + return false; + } + const directory = lockPath(store, entityLockKey); + const staleDirectory = `${directory}.stale.${process.pid}.${Date.now()}`; + try { + await rename(directory, staleDirectory); + } catch (error) { + if (isNotFound(error) || isAlreadyExists(error)) return true; + throw error; + } + await rm(staleDirectory, { recursive: true, force: true }); + return true; +} + +function processIsAlive(processId: number): boolean { + if (processId === process.pid) return true; + try { + process.kill(processId, 0); + return true; + } catch (error) { + return !( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ESRCH' + ); + } +} + +async function atomicWriteLockOwner( + directory: string, + owner: LocalLockOwnerV1, +): Promise { + const target = path.join(directory, 'owner.json'); + const temporary = path.join( + directory, + `.owner.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, `${JSON.stringify(owner, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + await rename(temporary, target); +} + +function parseLeaseMs(value: number | undefined): number { + if (value === undefined) return DEFAULT_LOCK_LEASE_MS; + if ( + !Number.isSafeInteger(value) || + value < MINIMUM_LOCK_LEASE_MS || + value > MAXIMUM_LOCK_LEASE_MS + ) { + throw new Error('local lock leaseMs is invalid'); + } + return value; +} + +function lockLeaseMs(owner: LocalLockOwnerV1): number { + if (owner.leaseExpiresAt === null) return DEFAULT_LOCK_LEASE_MS; + const duration = + Date.parse(owner.leaseExpiresAt) - Date.parse(owner.acquiredAt); + return duration >= MINIMUM_LOCK_LEASE_MS && duration <= MAXIMUM_LOCK_LEASE_MS + ? duration + : DEFAULT_LOCK_LEASE_MS; +} + +function lockPath(store: EntityHomeStore, entityLockKey: string): string { + assertEntityLockKey(entityLockKey); + const fileName = createHash('sha256') + .update(entityLockKey, 'utf8') + .digest('hex'); + return path.join(lockDirectory(store), `${fileName}.lock`); +} + +function assertEntityLockKey(value: unknown): asserts value is string { + if ( + typeof value !== 'string' || + !ENTITY_LOCK_KEY_PATTERN.test(value) || + value.includes('..') + ) { + throw new Error('entity lock key is invalid'); + } +} + +function sameLockOwner( + current: LocalLockOwnerV1 | null, + expected: LocalLockOwnerV1, +): boolean { + return ( + current !== null && + current.operationId === expected.operationId && + current.processId === expected.processId && + current.storeId === expected.storeId && + current.entityLockKey === expected.entityLockKey && + current.acquiredAt === expected.acquiredAt && + current.leaseExpiresAt === expected.leaseExpiresAt + ); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/operation-crash-injection.ts b/src/runtime/operation-crash-injection.ts new file mode 100644 index 0000000..bf69b0d --- /dev/null +++ b/src/runtime/operation-crash-injection.ts @@ -0,0 +1,83 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { OperationType } from './operation-journal.js'; + +export interface OperationCrashInjection { + operationType: OperationType; + crashAfter: 'prepared' | string; +} + +interface ActiveCrashInjection extends OperationCrashInjection { + triggered: boolean; + deferredCrashAfter: string | null; +} + +const activeInjection = new AsyncLocalStorage(); + +/** + * Test harness only: simulate process loss immediately after a journal state + * becomes durable. The async-local scope prevents one concurrent test from + * interrupting another operation. + */ +export async function withOperationCrashInjectionForTesting( + injection: OperationCrashInjection, + operation: () => Promise, +): Promise { + if (!injection.crashAfter) { + throw new Error('MANCODE_TEST_CRASH_POINT_INVALID'); + } + return activeInjection.run( + { ...injection, triggered: false, deferredCrashAfter: null }, + operation, + ); +} + +export function throwIfOperationCrashInjected( + operationType: OperationType, + crashAfter: 'prepared' | string, +): void { + const injection = activeInjection.getStore(); + if ( + injection === undefined || + injection.triggered || + injection.operationType !== operationType || + injection.crashAfter !== crashAfter + ) { + return; + } + injection.triggered = true; + throw new Error('MANCODE_TEST_OPERATION_CRASH_INJECTED'); +} + +/** Arms a business-write crash until the caller has made its visible change. */ +export function armOperationCrashAfterVisibleWrite( + operationType: OperationType, + crashAfter: string, +): void { + const injection = activeInjection.getStore(); + if ( + injection === undefined || + injection.triggered || + injection.operationType !== operationType || + injection.crashAfter !== crashAfter + ) { + return; + } + injection.deferredCrashAfter = crashAfter; +} + +/** Throws before the next journal transition, after the preceding write. */ +export function throwIfDeferredOperationCrashInjected( + operationType: OperationType, +): void { + const injection = activeInjection.getStore(); + if ( + injection === undefined || + injection.triggered || + injection.operationType !== operationType || + injection.deferredCrashAfter === null + ) { + return; + } + injection.triggered = true; + throw new Error('MANCODE_TEST_OPERATION_CRASH_INJECTED'); +} diff --git a/src/runtime/operation-definition.ts b/src/runtime/operation-definition.ts new file mode 100644 index 0000000..94ed6be --- /dev/null +++ b/src/runtime/operation-definition.ts @@ -0,0 +1,740 @@ +import type { AuthorizationAction } from '../team/authorization.js'; +import type { OperationJournalV1, OperationType } from './operation-journal.js'; + +export type OperationStepVisibility = + | 'preparation' + | 'business_write' + | 'commit'; +export type CrashRecovery = + | 'safe_abort' + | 'forward_repair' + | 'projection_retry'; + +export interface OperationStepDefinition { + id: string; + visibility: OperationStepVisibility; + expectedRevisionPrefixes: string[]; + requiredLockPrefixes: string[]; + /** Required only when the operation's locked snapshot contains the entity. */ + optionalExpectedRevisionPrefixes: string[]; + /** Optional entities still require their own lock when they are present. */ + optionalRequiredLockPrefixes: string[]; + crashRecovery: CrashRecovery; +} + +export interface OperationDefinitionV1 { + schemaVersion: 1; + type: OperationType; + authorizationActions: readonly AuthorizationAction[]; + primaryCommitStep: string; + requiresSharedTaskHeadFence: boolean; + allowsSecondaryReservations: boolean; + steps: OperationStepDefinition[]; +} + +/** + * Authorization is bound to a concrete durable operation, rather than merely + * to any valid active session. Local and shared variants intentionally retain + * distinct actions where the same operation type can target either namespace. + */ +export const OPERATION_AUTHORIZATION_ACTIONS: Record< + OperationType, + readonly AuthorizationAction[] +> = { + workflow_create: ['local_workflow_mutation', 'shared_create_publish_promote'], + requirements_finalize: [ + 'local_workflow_mutation', + 'shared_metadata_plan_mutation', + ], + plan_revision: ['local_workflow_mutation', 'shared_metadata_plan_mutation'], + review_remediation: [ + 'local_workflow_mutation', + 'shared_ledger_evidence', + 'review_skip_or_waiver', + ], + verification_record: ['local_workflow_mutation', 'shared_ledger_evidence'], + task_complete: [ + 'local_workflow_mutation', + 'task_complete_scope_change_child_merge', + ], + publish_promote: ['shared_create_publish_promote'], + handoff_transition: ['handoff_offer_cancel', 'handoff_accept_reject'], + handoff_accept: ['handoff_accept_reject'], + scope_change_reclaim: ['task_complete_scope_change_child_merge'], + claim_create: ['claim_create'], + claim_renew_release: ['claim_renew_release_transfer'], + claim_transfer: ['claim_renew_release_transfer'], + claim_reclaim: ['claim_reclaim'], + claim_revalidation: ['claim_renew_release_transfer'], + checkpoint_create: [ + 'local_workflow_mutation', + 'shared_metadata_plan_mutation', + ], + solo_handoff: ['local_workflow_mutation'], + child_result_merge: [ + 'local_workflow_mutation', + 'task_complete_scope_change_child_merge', + ], + task_head_reconcile: ['task_head_reconcile'], + transport_migrate: ['team_policy_config_transport'], + greenfield_initialize: ['team_policy_config_transport'], + v3_activate: ['team_policy_config_transport'], +}; + +export interface OperationCrashFixture { + operationType: OperationType; + crashAfter: 'prepared' | string; + expectedRecovery: CrashRecovery; +} + +const prepare = ( + id: string, + expectedRevisionPrefixes: string[] = [], + requiredLockPrefixes: string[] = [], + optional: { + expectedRevisionPrefixes?: string[]; + requiredLockPrefixes?: string[]; + } = {}, +): OperationStepDefinition => ({ + id, + visibility: 'preparation', + expectedRevisionPrefixes, + requiredLockPrefixes, + optionalExpectedRevisionPrefixes: optional.expectedRevisionPrefixes ?? [], + optionalRequiredLockPrefixes: optional.requiredLockPrefixes ?? [], + crashRecovery: 'safe_abort', +}); + +const write = ( + id: string, + expectedRevisionPrefixes: string[], + requiredLockPrefixes: string[], + optional: { + expectedRevisionPrefixes?: string[]; + requiredLockPrefixes?: string[]; + } = {}, +): OperationStepDefinition => ({ + id, + visibility: 'business_write', + expectedRevisionPrefixes, + requiredLockPrefixes, + optionalExpectedRevisionPrefixes: optional.expectedRevisionPrefixes ?? [], + optionalRequiredLockPrefixes: optional.requiredLockPrefixes ?? [], + crashRecovery: 'forward_repair', +}); + +const commit = ( + id: string, + expectedRevisionPrefixes: string[], + requiredLockPrefixes: string[], + optional: { + expectedRevisionPrefixes?: string[]; + requiredLockPrefixes?: string[]; + } = {}, +): OperationStepDefinition => ({ + id, + visibility: 'commit', + expectedRevisionPrefixes, + requiredLockPrefixes, + optionalExpectedRevisionPrefixes: optional.expectedRevisionPrefixes ?? [], + optionalRequiredLockPrefixes: optional.requiredLockPrefixes ?? [], + crashRecovery: 'forward_repair', +}); + +export const OPERATION_DEFINITIONS: Record< + OperationType, + OperationDefinitionV1 +> = { + workflow_create: definition( + 'workflow_create', + 'publish-locator', + false, + false, + [ + prepare('validate'), + // The staging directory is private to this operation and has no task + // locator/fence visibility. A crash here is compensable by deleting + // verified staging, rather than forcing a fictitious forward repair. + prepare('write-staging-aggregate', ['task:'], ['task:']), + prepare('validate-aggregate', ['task:'], ['task:']), + write('publish-task-directory', ['task:'], ['task:']), + write('publish-locator', ['locator:'], ['task:', 'locator:']), + commit('commit', ['task:', 'locator:'], ['task:', 'locator:']), + ], + ), + requirements_finalize: definition( + 'requirements_finalize', + 'update-metadata', + true, + false, + [ + prepare('validate', ['task:'], ['task:']), + write('write-requirements', ['requirements:', 'task:'], ['task:']), + write( + 'mark-review-verification-stale', + ['review:', 'verification:', 'task:'], + ['task:'], + ), + write('update-metadata', ['task:'], ['task:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ], + ), + plan_revision: definition('plan_revision', 'update-metadata', true, false, [ + prepare('validate', ['task:'], ['task:']), + write('write-plan', ['plan:', 'task:'], ['task:']), + write('update-metadata', ['task:'], ['task:']), + write( + 'mark-review-verification-stale', + ['review:', 'verification:', 'task:'], + ['task:'], + ), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ]), + review_remediation: definition( + 'review_remediation', + 'update-metadata', + true, + false, + [ + prepare('validate', ['task:', 'review:'], ['task:']), + write('write-review-ledger', ['review:', 'task:'], ['task:']), + write('mark-verification-stale', ['verification:', 'task:'], ['task:']), + write('update-metadata', ['task:'], ['task:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ], + ), + verification_record: definition( + 'verification_record', + 'update-metadata', + true, + false, + [ + prepare('validate', ['task:', 'verification:'], ['task:']), + write('write-verification-ledger', ['verification:', 'task:'], ['task:']), + write('update-metadata', ['task:'], ['task:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ], + ), + task_complete: definition( + 'task_complete', + 'write-completed-metadata', + true, + false, + [ + prepare('validate-completion-gate', ['task:'], ['task:'], { + expectedRevisionPrefixes: ['claim:'], + }), + write('mark-operation-pending', ['task:'], ['task:']), + write('release-or-transfer-claims', ['task:'], ['task:'], { + expectedRevisionPrefixes: ['claim:'], + requiredLockPrefixes: ['claim:'], + }), + write('write-completed-metadata', ['task:'], ['task:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ], + ), + publish_promote: definition( + 'publish_promote', + 'publish-destination', + true, + true, + [ + prepare('validate-privacy-and-paths', ['task:'], ['task:']), + // The destination staging directory remains private until its atomic + // rename, so it is safely compensable just like workflow creation. + prepare('stage-destination', ['task:'], ['task:']), + write('mark-source-operation-pending', ['task:'], ['task:']), + write( + 'publish-destination', + ['task:', 'locator:'], + ['task:', 'locator:'], + ), + write('write-source-successor', ['task:'], ['task:']), + write('publish-destination-locator', ['locator:'], ['locator:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ], + ), + handoff_transition: definition( + 'handoff_transition', + 'write-handoff', + false, + false, + [ + prepare('validate', ['task:', 'handoff:'], ['task:', 'handoff:']), + write('write-handoff', ['task:', 'handoff:'], ['task:', 'handoff:']), + commit('commit', ['task:', 'handoff:'], ['task:', 'handoff:']), + ], + ), + handoff_accept: definition('handoff_accept', 'accept-handoff', true, false, [ + prepare( + 'validate', + ['task:', 'handoff:', 'checkpoint:'], + ['task:', 'handoff:', 'checkpoint:'], + { + expectedRevisionPrefixes: ['claim:'], + requiredLockPrefixes: ['claim:'], + }, + ), + write('mark-task-operation-pending', ['task:'], ['task:']), + write('create-pending-successor-claims', ['task:'], ['task:'], { + expectedRevisionPrefixes: ['claim:'], + requiredLockPrefixes: ['claim:'], + }), + write('transfer-old-claims', ['task:'], ['task:'], { + expectedRevisionPrefixes: ['claim:'], + requiredLockPrefixes: ['claim:'], + }), + write('update-owner-and-checkpoint', ['task:', 'checkpoint:'], ['task:']), + write('activate-successor-claims', ['task:'], ['task:'], { + expectedRevisionPrefixes: ['claim:'], + requiredLockPrefixes: ['claim:'], + }), + write('accept-handoff', ['handoff:', 'task:'], ['task:', 'handoff:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ]), + scope_change_reclaim: definition( + 'scope_change_reclaim', + 'activate-successor-claims', + true, + false, + [ + prepare('validate', ['task:'], ['task:'], { + expectedRevisionPrefixes: ['claim:'], + requiredLockPrefixes: ['claim:'], + }), + write('mark-task-operation-pending', ['task:'], ['task:']), + write( + 'write-scope-changed-checkpoint', + ['checkpoint:', 'task:'], + ['task:'], + ), + write('create-pending-successor-claims', ['task:'], ['task:'], { + expectedRevisionPrefixes: ['claim:'], + requiredLockPrefixes: ['claim:'], + }), + write('terminate-old-claims', ['task:'], ['task:'], { + expectedRevisionPrefixes: ['claim:'], + requiredLockPrefixes: ['claim:'], + }), + write('update-metadata-scope', ['task:'], ['task:']), + write('activate-successor-claims', ['task:'], ['task:'], { + expectedRevisionPrefixes: ['claim:'], + requiredLockPrefixes: ['claim:'], + }), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ], + ), + claim_create: definition( + 'claim_create', + 'create-active-claim', + false, + false, + [ + prepare('validate', ['task:'], ['task:']), + write('create-active-claim', ['claim:', 'task:'], ['task:', 'claim:']), + commit('commit', ['claim:', 'task:'], ['task:', 'claim:']), + ], + ), + claim_renew_release: definition( + 'claim_renew_release', + 'update-claim', + false, + false, + [ + prepare('validate', ['task:', 'claim:'], ['task:', 'claim:']), + write('update-claim', ['task:', 'claim:'], ['task:', 'claim:']), + commit('commit', ['task:', 'claim:'], ['task:', 'claim:']), + ], + ), + claim_transfer: definition( + 'claim_transfer', + 'activate-successor-claim', + false, + false, + [ + prepare('validate', ['task:', 'claim:'], ['task:', 'claim:']), + write( + 'create-pending-successor-claim', + ['task:', 'claim:'], + ['task:', 'claim:'], + ), + write( + 'transfer-predecessor-claim', + ['task:', 'claim:'], + ['task:', 'claim:'], + ), + write( + 'activate-successor-claim', + ['task:', 'claim:'], + ['task:', 'claim:'], + ), + commit('commit', ['task:', 'claim:'], ['task:', 'claim:']), + ], + ), + claim_reclaim: definition('claim_reclaim', 'expire-claim', false, false, [ + prepare('validate', ['task:', 'claim:'], ['task:', 'claim:']), + write('expire-claim', ['task:', 'claim:'], ['task:', 'claim:']), + commit('commit', ['task:', 'claim:'], ['task:', 'claim:']), + ]), + claim_revalidation: definition( + 'claim_revalidation', + 'update-claim-validation', + true, + false, + [ + prepare('validate', ['task:', 'claim:'], ['task:', 'claim:']), + write('mark-task-operation-pending', ['task:'], ['task:']), + write('write-base-changed-checkpoint', ['task:'], ['task:'], { + expectedRevisionPrefixes: ['checkpoint:'], + }), + write( + 'update-claim-validation', + ['claim:', 'task:'], + ['task:', 'claim:'], + ), + write('complete-task-validation', ['task:'], ['task:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ], + ), + checkpoint_create: definition( + 'checkpoint_create', + 'update-metadata-checkpoint-ref', + true, + false, + [ + prepare('validate', ['task:'], ['task:']), + write('mark-task-operation-pending', ['task:'], ['task:']), + write('write-checkpoint', ['checkpoint:', 'task:'], ['task:']), + write('update-metadata-checkpoint-ref', ['task:'], ['task:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ], + ), + solo_handoff: definition( + 'solo_handoff', + 'write-workflow-assignment', + false, + false, + [ + prepare('validate', ['task:'], ['task:']), + write('write-workflow-assignment', ['task:'], ['task:']), + // The session pointer is a compensable local projection, rather than + // journaled workflow authority. It is intentionally not part of the + // operation CAS/lock set: a failed or later-replaced pointer must never + // block repair of the durable solo assignment. + write('update-session-pointer', ['task:'], ['task:']), + commit('commit', ['task:'], ['task:']), + ], + ), + child_result_merge: definition( + 'child_result_merge', + 'update-parent-metadata', + true, + false, + [ + prepare('validate-parent-snapshot', ['task:', 'checkpoint:'], ['task:']), + write('mark-parent-operation-pending', ['task:'], ['task:']), + write('write-merge-checkpoint', ['checkpoint:', 'task:'], ['task:']), + write('update-parent-metadata', ['task:'], ['task:']), + write('update-task-head-fence', ['task_head:', 'task:'], ['task:']), + commit('commit', ['task_head:', 'task:'], ['task:']), + ], + ), + task_head_reconcile: definition( + 'task_head_reconcile', + 'adopt-task-head-fence', + true, + false, + [ + prepare( + 'validate-clean-store-and-git-reachability', + ['task:', 'task_head:'], + ['task:', 'task_head:'], + ), + prepare( + 'confirm-adoption', + ['task:', 'task_head:'], + ['task:', 'task_head:'], + ), + write( + 'adopt-task-head-fence', + ['task_head:', 'task:'], + ['task:', 'task_head:'], + ), + commit('commit', ['task_head:', 'task:'], ['task:', 'task_head:']), + ], + ), + transport_migrate: definition( + 'transport_migrate', + 'switch-config-authority', + true, + true, + [ + prepare( + 'freeze-shared-coordination-writes', + ['config:', 'task_head:'], + ['config:'], + ), + prepare( + 'validate-old-authority', + ['config:', 'task_head:', 'claim:', 'handoff:'], + ['config:'], + ), + write( + 'stage-new-authority', + ['config:', 'task_head:', 'claim:', 'handoff:'], + ['config:'], + ), + write( + 'establish-new-epoch', + ['config:', 'task_head:', 'claim:', 'handoff:'], + ['config:'], + ), + write('switch-config-authority', ['config:'], ['config:']), + commit('commit', ['config:'], ['config:']), + ], + ), + greenfield_initialize: definition( + 'greenfield_initialize', + 'publish-v3-root', + false, + false, + [ + prepare('verify-no-legacy-authority'), + // The named staging root is still private and can be verified then + // deleted before the atomic .mancode publication boundary. + prepare( + 'write-initializing-staging-root', + ['schema:', 'config:'], + ['schema:'], + ), + prepare( + 'write-v3-config-policy-adapters', + ['schema:', 'config:'], + ['schema:'], + ), + write('publish-v3-root', ['schema:', 'config:'], ['schema:']), + write('register-workspace-binding', ['binding:'], ['binding:']), + write('activate-v3-manifest', ['schema:'], ['schema:']), + commit('commit', ['schema:'], ['schema:']), + ], + ), + v3_activate: definition('v3_activate', 'activate-manifest', true, true, [ + prepare( + 'validate-staged-migration', + ['schema:', 'config:', 'stage:'], + ['schema:', 'config:', 'stage:'], + { + expectedRevisionPrefixes: ['task:', 'task_head:', 'adapter:'], + requiredLockPrefixes: ['task:', 'task_head:', 'adapter:'], + }, + ), + write( + 'mark-manifest-activating', + ['schema:', 'stage:'], + ['schema:', 'stage:'], + ), + write('replace-managed-adapters', ['adapter:'], ['adapter:']), + write('promote-staged-tasks', [], [], { + expectedRevisionPrefixes: ['task:', 'task_head:'], + requiredLockPrefixes: ['task:', 'task_head:'], + }), + write( + 'record-adapter-inventory-and-baseline', + ['schema:', 'config:'], + ['schema:', 'config:'], + ), + write('activate-manifest', ['schema:', 'stage:'], ['schema:', 'stage:']), + commit('commit', ['schema:', 'stage:'], ['schema:', 'stage:']), + ]), +}; + +export const OPERATION_CRASH_FIXTURES: Record< + OperationType, + OperationCrashFixture[] +> = Object.fromEntries( + Object.values(OPERATION_DEFINITIONS).map((definition) => [ + definition.type, + [ + { + operationType: definition.type, + crashAfter: 'prepared', + expectedRecovery: 'safe_abort', + }, + ...definition.steps.map((step) => ({ + operationType: definition.type, + crashAfter: step.id, + expectedRecovery: step.crashRecovery, + })), + ], + ]), +) as Record; + +export function getOperationDefinition( + operationType: OperationType, +): OperationDefinitionV1 { + return OPERATION_DEFINITIONS[operationType]; +} + +export function assertOperationJournalMatchesDefinition( + journal: OperationJournalV1, +): void { + const definition = getOperationDefinition(journal.type); + assertOperationAuthorizationAction(journal); + const actualSteps = journal.steps.map((step) => step.id); + const expectedSteps = definition.steps.map((step) => step.id); + if ( + actualSteps.length !== expectedSteps.length || + actualSteps.some((step, index) => step !== expectedSteps[index]) + ) { + throw new Error( + `operation ${journal.type} steps do not match its machine-readable definition`, + ); + } + const expectedRevisionPrefixes = new Set( + definition.steps.flatMap((step) => step.expectedRevisionPrefixes), + ); + const lockPrefixes = new Set( + definition.steps.flatMap((step) => step.requiredLockPrefixes), + ); + const optionalExpectedRevisionPrefixes = new Set( + definition.steps.flatMap((step) => step.optionalExpectedRevisionPrefixes), + ); + const optionalLockPrefixes = new Set( + definition.steps.flatMap((step) => step.optionalRequiredLockPrefixes), + ); + const sharedTask = journal.entityLocks.some((key) => + key.startsWith('task:shared:'), + ); + if (!sharedTask) { + expectedRevisionPrefixes.delete('task_head:'); + } + assertPrefixCoverage( + Object.keys(journal.expectedRevisions), + expectedRevisionPrefixes, + 'expected revisions', + ); + assertPrefixCoverage(journal.entityLocks, lockPrefixes, 'entity locks'); + assertOptionalPrefixPairs( + Object.keys(journal.expectedRevisions), + journal.entityLocks, + optionalExpectedRevisionPrefixes, + optionalLockPrefixes, + ); + if (definition.requiresSharedTaskHeadFence && sharedTask) { + assertPrefixCoverage( + Object.keys(journal.expectedRevisions), + new Set(['task_head:']), + 'expected revisions', + ); + assertPrefixCoverage( + journal.entityLocks, + new Set(['task_head:']), + 'entity locks', + ); + } +} + +/** A store may enforce the authorization binding before it knows every step. */ +export function assertOperationAuthorizationAction( + journal: Pick, +): void { + const definition = getOperationDefinition(journal.type); + if ( + !definition.authorizationActions.includes(journal.authorizationBasis.action) + ) { + throw new Error('MANCODE_OPERATION_AUTHORIZATION_ACTION_MISMATCH'); + } +} + +function definition( + type: OperationType, + primaryCommitStep: string, + requiresSharedTaskHeadFence: boolean, + allowsSecondaryReservations: boolean, + steps: OperationStepDefinition[], +): OperationDefinitionV1 { + if (!steps.some((step) => step.id === primaryCommitStep)) { + throw new Error(`operation ${type} has no primary commit step`); + } + for (const step of steps) { + for (const prefix of step.optionalExpectedRevisionPrefixes) { + if (step.expectedRevisionPrefixes.includes(prefix)) { + throw new Error( + `operation ${type} repeats optional expected revision prefix ${prefix}`, + ); + } + } + for (const prefix of step.optionalRequiredLockPrefixes) { + if (step.requiredLockPrefixes.includes(prefix)) { + throw new Error( + `operation ${type} repeats optional lock prefix ${prefix}`, + ); + } + } + } + return { + schemaVersion: 1, + type, + authorizationActions: OPERATION_AUTHORIZATION_ACTIONS[type], + primaryCommitStep, + requiresSharedTaskHeadFence, + allowsSecondaryReservations, + steps, + }; +} + +function assertPrefixCoverage( + values: string[], + prefixes: Set, + label: string, +): void { + for (const prefix of prefixes) { + if (!values.some((value) => value.startsWith(prefix))) { + throw new Error(`operation journal ${label} are missing ${prefix}`); + } + } +} + +/** + * Some operations (such as completing a team task) may have zero affected + * claims. Once one is present, however, its revision guard and canonical lock + * are inseparable: otherwise an operation could journal a claim CAS without + * actually excluding a concurrent claim mutation. + */ +function assertOptionalPrefixPairs( + expectedRevisions: string[], + entityLocks: string[], + optionalExpectedRevisionPrefixes: Set, + optionalLockPrefixes: Set, +): void { + for (const prefix of optionalExpectedRevisionPrefixes) { + if ( + expectedRevisions.some((value) => value.startsWith(prefix)) && + !entityLocks.some((value) => value.startsWith(prefix)) + ) { + throw new Error( + `operation journal entity locks are missing optional ${prefix}`, + ); + } + } + for (const prefix of optionalLockPrefixes) { + if ( + entityLocks.some((value) => value.startsWith(prefix)) && + !expectedRevisions.some((value) => value.startsWith(prefix)) + ) { + throw new Error( + `operation journal expected revisions are missing optional ${prefix}`, + ); + } + } +} diff --git a/src/runtime/operation-journal.ts b/src/runtime/operation-journal.ts new file mode 100644 index 0000000..413c0d1 --- /dev/null +++ b/src/runtime/operation-journal.ts @@ -0,0 +1,505 @@ +import { + digestCanonicalJson, + sortUtf8StringSet, +} from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { + type AuthorizationBasisV1, + parseAuthorizationBasis, +} from '../team/authorization.js'; + +export type OperationType = + | 'workflow_create' + | 'requirements_finalize' + | 'plan_revision' + | 'review_remediation' + | 'verification_record' + | 'task_complete' + | 'publish_promote' + | 'handoff_transition' + | 'handoff_accept' + | 'scope_change_reclaim' + | 'claim_create' + | 'claim_renew_release' + | 'claim_transfer' + | 'claim_reclaim' + | 'claim_revalidation' + | 'checkpoint_create' + | 'solo_handoff' + | 'child_result_merge' + | 'task_head_reconcile' + | 'transport_migrate' + | 'greenfield_initialize' + | 'v3_activate'; + +export type OperationState = + | 'prepared' + | 'applying' + | 'committed' + | 'repair_required' + | 'aborted'; + +export type OperationStepState = 'pending' | 'completed'; + +export interface OperationStep { + id: string; + state: OperationStepState; +} + +export interface SecondaryReservation { + storeId: string; + entityKeys: string[]; + journalDigest: string; +} + +export interface OperationJournalV1 { + schemaVersion: 1; + operationId: Ulid; + type: OperationType; + state: OperationState; + primaryStoreId: string; + checkoutId: Ulid; + secondaryReservations: SecondaryReservation[]; + actorId: Ulid; + sessionId: Ulid; + authorizationBasis: AuthorizationBasisV1; + /** Digest binding this immutable journal to its exact forward-repair targets. */ + recoveryPayloadDigest?: string; + entityLocks: string[]; + expectedRevisions: Record; + steps: OperationStep[]; + startedAt: string; + updatedAt: string; +} + +export interface OperationTransitionOptions { + /** True only before any external write, or after verified compensation. */ + canAbort: boolean; +} + +const OPERATION_TYPES = new Set([ + 'workflow_create', + 'requirements_finalize', + 'plan_revision', + 'review_remediation', + 'verification_record', + 'task_complete', + 'publish_promote', + 'handoff_transition', + 'handoff_accept', + 'scope_change_reclaim', + 'claim_create', + 'claim_renew_release', + 'claim_transfer', + 'claim_reclaim', + 'claim_revalidation', + 'checkpoint_create', + 'solo_handoff', + 'child_result_merge', + 'task_head_reconcile', + 'transport_migrate', + 'greenfield_initialize', + 'v3_activate', +]); +const OPERATION_STATES = new Set([ + 'prepared', + 'applying', + 'committed', + 'repair_required', + 'aborted', +]); +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; +const ENTITY_KEY_PATTERN = /^[a-z][a-z0-9_-]*:[^\0]+$/; + +export function parseOperationJournal(value: unknown): OperationJournalV1 { + assertRecord(value, 'operation journal'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'type', + 'state', + 'primaryStoreId', + 'checkoutId', + 'secondaryReservations', + 'actorId', + 'sessionId', + 'authorizationBasis', + 'recoveryPayloadDigest', + 'entityLocks', + 'expectedRevisions', + 'steps', + 'startedAt', + 'updatedAt', + ], + 'operation journal', + ); + if (value.schemaVersion !== 1) { + throw new Error('operation journal schemaVersion must be 1'); + } + assertUlid(value.operationId, 'operationId'); + assertUlid(value.checkoutId, 'operation checkoutId'); + assertUlid(value.actorId, 'operation actorId'); + assertUlid(value.sessionId, 'operation sessionId'); + if ( + typeof value.type !== 'string' || + !OPERATION_TYPES.has(value.type as OperationType) + ) { + throw new Error('operation journal type is invalid'); + } + if ( + typeof value.state !== 'string' || + !OPERATION_STATES.has(value.state as OperationState) + ) { + throw new Error('operation journal state is invalid'); + } + const authorizationBasis = parseAuthorizationBasis(value.authorizationBasis); + if ( + authorizationBasis.actorId !== value.actorId || + authorizationBasis.sessionId !== value.sessionId + ) { + throw new Error( + 'operation journal authorization basis must match actorId and sessionId', + ); + } + const journal: OperationJournalV1 = { + schemaVersion: 1, + operationId: value.operationId, + type: value.type as OperationType, + state: value.state as OperationState, + primaryStoreId: parseStoreId(value.primaryStoreId, 'primaryStoreId'), + checkoutId: value.checkoutId, + secondaryReservations: parseSecondaryReservations( + value.secondaryReservations, + ), + actorId: value.actorId, + sessionId: value.sessionId, + authorizationBasis, + recoveryPayloadDigest: + value.recoveryPayloadDigest === undefined + ? undefined + : parseDigest(value.recoveryPayloadDigest, 'recoveryPayloadDigest'), + entityLocks: parseEntityKeySet(value.entityLocks, 'entityLocks'), + expectedRevisions: parseExpectedRevisions(value.expectedRevisions), + steps: parseSteps(value.steps), + startedAt: parseTimestamp(value.startedAt, 'startedAt'), + updatedAt: parseTimestamp(value.updatedAt, 'updatedAt'), + }; + assertStepCompletionOrder(journal.steps); + if ( + journal.state === 'prepared' && + journal.steps.some((step) => step.state !== 'pending') + ) { + throw new Error('prepared operation journals cannot have completed steps'); + } + if ( + journal.state === 'committed' && + journal.steps.some((step) => step.state !== 'completed') + ) { + throw new Error( + 'committed operation journals require every step to be completed', + ); + } + return journal; +} + +export function assertOperationJournalTransition( + previous: OperationJournalV1, + next: OperationJournalV1, + options: OperationTransitionOptions, +): void { + assertJournalIdentityIsStable(previous, next); + assertStepProgresses(previous.steps, next.steps); + if (previous.state === next.state) return; + if (!allowedOperationTransitions(previous.state).has(next.state)) { + throw new Error( + `invalid operation state transition: ${previous.state} -> ${next.state}`, + ); + } + if ( + next.state === 'committed' && + next.steps.some((step) => step.state !== 'completed') + ) { + throw new Error( + 'committed operation journals require every step to be completed', + ); + } + if (next.state === 'aborted' && !options.canAbort) { + throw new Error('operation journal cannot abort after an external write'); + } +} + +/** Digest of the complete current journal, useful for durable integrity checks. */ +export function operationJournalDigest(journal: OperationJournalV1): string { + return digestCanonicalJson(journal); +} + +/** + * A secondary reservation cannot hash the complete journal because the + * journal embeds that reservation's digest. This stable identity projection + * deliberately covers only immutable journal fields and reservation targets. + */ +export function operationReservationJournalDigest( + journal: OperationJournalV1, +): string { + const identity: Record = { + schemaVersion: journal.schemaVersion, + operationId: journal.operationId, + type: journal.type, + primaryStoreId: journal.primaryStoreId, + checkoutId: journal.checkoutId, + actorId: journal.actorId, + sessionId: journal.sessionId, + authorizationBasis: journal.authorizationBasis, + entityLocks: sortUtf8StringSet(journal.entityLocks), + expectedRevisions: journal.expectedRevisions, + secondaryReservationTargets: journal.secondaryReservations + .map((reservation) => ({ + storeId: reservation.storeId, + entityKeys: sortUtf8StringSet(reservation.entityKeys), + })) + .sort((left, right) => compareUtf8(left.storeId, right.storeId)), + startedAt: journal.startedAt, + }; + // Legacy reservation records were created before recovery payloads existed. + // Leaving this field absent preserves their historical identity digest. + if (journal.recoveryPayloadDigest !== undefined) { + identity.recoveryPayloadDigest = journal.recoveryPayloadDigest; + } + return digestCanonicalJson(identity); +} + +export function assertOperationReservationTopology( + journal: OperationJournalV1, +): void { + const expectedDigest = operationReservationJournalDigest(journal); + for (const reservation of journal.secondaryReservations) { + if (reservation.storeId === journal.primaryStoreId) { + throw new Error( + 'operation secondary reservation cannot target primaryStoreId', + ); + } + if (reservation.journalDigest !== expectedDigest) { + throw new Error( + 'operation secondary reservation journalDigest does not match the prepared journal identity', + ); + } + } +} + +export function withOperationReservationDigests( + journal: OperationJournalV1, +): OperationJournalV1 { + const journalDigest = operationReservationJournalDigest(journal); + return { + ...journal, + secondaryReservations: journal.secondaryReservations.map((reservation) => ({ + ...reservation, + journalDigest, + })), + }; +} + +function parseSecondaryReservations(value: unknown): SecondaryReservation[] { + if (!Array.isArray(value)) { + throw new Error('operation secondaryReservations must be an array'); + } + const storeIds = new Set(); + return value.map((reservation) => { + assertRecord(reservation, 'operation secondary reservation'); + assertKnownKeys( + reservation, + ['storeId', 'entityKeys', 'journalDigest'], + 'operation secondary reservation', + ); + const storeId = parseStoreId( + reservation.storeId, + 'secondary reservation storeId', + ); + if (storeIds.has(storeId)) { + throw new Error( + 'operation secondaryReservations must not repeat a store', + ); + } + storeIds.add(storeId); + if ( + typeof reservation.journalDigest !== 'string' || + !DIGEST_PATTERN.test(reservation.journalDigest) + ) { + throw new Error( + 'operation secondary reservation journalDigest is invalid', + ); + } + return { + storeId, + entityKeys: parseEntityKeySet( + reservation.entityKeys, + 'secondary reservation entityKeys', + ), + journalDigest: reservation.journalDigest, + }; + }); +} + +function parseExpectedRevisions(value: unknown): Record { + assertRecord(value, 'operation expectedRevisions'); + const parsed: Record = {}; + for (const [entityKey, revision] of Object.entries(value)) { + assertEntityKey(entityKey, 'operation expected revision key'); + if ( + typeof revision !== 'number' || + !Number.isSafeInteger(revision) || + revision < 0 + ) { + throw new Error( + 'operation expected revisions must be non-negative integers', + ); + } + parsed[entityKey] = revision; + } + return parsed; +} + +function parseSteps(value: unknown): OperationStep[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error('operation steps must be a non-empty array'); + } + const ids = new Set(); + return value.map((step) => { + assertRecord(step, 'operation step'); + assertKnownKeys(step, ['id', 'state'], 'operation step'); + if (typeof step.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(step.id)) { + throw new Error('operation step id is invalid'); + } + if (ids.has(step.id)) + throw new Error('operation steps must not repeat an id'); + ids.add(step.id); + if (step.state !== 'pending' && step.state !== 'completed') { + throw new Error('operation step state is invalid'); + } + return { id: step.id, state: step.state }; + }); +} + +function parseEntityKeySet(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${label} must be a non-empty array`); + } + const keys = new Set(); + for (const entityKey of value) { + if (typeof entityKey !== 'string') + throw new Error(`${label} must contain strings`); + assertEntityKey(entityKey, label); + if (keys.has(entityKey)) + throw new Error(`${label} must not contain duplicates`); + keys.add(entityKey); + } + return [...value] as string[]; +} + +function parseStoreId(value: unknown, label: string): string { + if (typeof value !== 'string' || !/^[a-z][a-z0-9_-]*:[^\0]+$/.test(value)) { + throw new Error(`${label} is invalid`); + } + return value; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`operation ${label} must be a sha256 digest`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`operation ${label} must be an ISO timestamp`); + } + return value; +} + +function assertJournalIdentityIsStable( + previous: OperationJournalV1, + next: OperationJournalV1, +): void { + if ( + previous.operationId !== next.operationId || + previous.type !== next.type || + previous.primaryStoreId !== next.primaryStoreId || + previous.checkoutId !== next.checkoutId || + previous.actorId !== next.actorId || + previous.sessionId !== next.sessionId || + JSON.stringify(previous.authorizationBasis) !== + JSON.stringify(next.authorizationBasis) || + previous.recoveryPayloadDigest !== next.recoveryPayloadDigest || + previous.startedAt !== next.startedAt || + JSON.stringify(previous.secondaryReservations) !== + JSON.stringify(next.secondaryReservations) || + JSON.stringify(previous.entityLocks) !== JSON.stringify(next.entityLocks) || + JSON.stringify(previous.expectedRevisions) !== + JSON.stringify(next.expectedRevisions) + ) { + throw new Error('operation journal identity fields are immutable'); + } +} + +function assertStepProgresses( + previous: OperationStep[], + next: OperationStep[], +): void { + if (previous.length !== next.length) { + throw new Error('operation journal steps are immutable'); + } + for (const [index, previousStep] of previous.entries()) { + const nextStep = next[index]; + if (!nextStep || previousStep.id !== nextStep.id) { + throw new Error('operation journal steps are immutable'); + } + if (previousStep.state === 'completed' && nextStep.state !== 'completed') { + throw new Error('completed operation steps cannot become pending'); + } + } +} + +function assertStepCompletionOrder(steps: OperationStep[]): void { + let pendingSeen = false; + for (const step of steps) { + if (step.state === 'pending') { + pendingSeen = true; + continue; + } + if (pendingSeen) { + throw new Error( + 'operation journal completed steps must form a contiguous prefix', + ); + } + } +} + +function assertEntityKey(value: string, label: string): void { + if (!ENTITY_KEY_PATTERN.test(value) || value.includes('..')) { + throw new Error(`${label} is invalid`); + } +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function allowedOperationTransitions( + from: OperationState, +): Set { + switch (from) { + case 'prepared': + return new Set(['applying', 'aborted']); + case 'applying': + return new Set(['committed', 'repair_required', 'aborted']); + case 'repair_required': + return new Set(['committed']); + case 'committed': + case 'aborted': + return new Set(); + } +} diff --git a/src/runtime/operation-recovery-executor.ts b/src/runtime/operation-recovery-executor.ts new file mode 100644 index 0000000..fa34967 --- /dev/null +++ b/src/runtime/operation-recovery-executor.ts @@ -0,0 +1,1328 @@ +import type { Stats } from 'node:fs'; +import { + lstat, + mkdir, + readFile, + readdir, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { + assertSchemaManifestTransition, + parseSchemaManifest, +} from '../context/manifest.js'; +import { parseMigrationStage } from '../context/migrate.js'; +import { V3ContextStore } from '../context/store.js'; +import { taskRootPath } from '../context/task-locator.js'; +import { type TaskRef, sameTaskRef } from '../context/task-ref.js'; +import { + applyV3AdapterFilePlan, + v3AdapterTargetPath, +} from '../installers/v3-adapter.js'; +import { + assertProjectConfigTransition, + assertTeamPolicyTransition, + parseProjectConfig, + parseTeamPolicy, +} from '../team/policy.js'; +import { createClaim, readClaim, updateClaim } from './claim-store.js'; +import { recordLocalDiagnostic } from './diagnostics.js'; +import { + type EntityHomeStore, + resolveCoordinationEntityHomeStore, + resolveLocalEntityHomeStore, +} from './entity-home-store.js'; +import { createHandoff, readHandoff, updateHandoff } from './handoff-store.js'; +import { + type LocalLockHandle, + acquireOperationEntityLocks, +} from './local-lock.js'; +import { getOperationDefinition } from './operation-definition.js'; +import type { OperationJournalV1 } from './operation-journal.js'; +import { + type OperationRecoveryActionV1, + type OperationRecoveryPayloadV1, + adapterFileContentDigest, + assertOperationRecoveryPayloadCoversJournal, + migrationStageContentDigest, + migrationTaskDirectoryDigest, + operationRecoveryPayloadDigest, + parseOperationRecoveryPayload, + projectAuthorityContentDigest, + recoveryActionResourceKey, + recoveryActionTargetDigest, + taskAuthorityContentDigest, + workflowTaskDirectoryDigest, +} from './operation-recovery-payload.js'; +import { readOperationRecoveryPayload } from './operation-recovery-store.js'; +import { + readOperationReservation, + removeOperationReservation, +} from './operation-reservation.js'; +import { + listUnfinishedOperationJournals, + readOperationJournal, + updateOperationJournal, +} from './operation-store.js'; +import { readProjectRuntimeContext } from './project-runtime.js'; +import { assertRecoveryActor, planOperationRecovery } from './reconciler.js'; +import { readSession } from './session.js'; +import { readTaskHeadFence, replaceTaskHeadFence } from './task-head-store.js'; +import { + readTaskAuthorityFileAtRoot, + readTaskCheckpointAtRoot, + writeTaskAuthorityFileAtRoot, + writeTaskCheckpointAtRoot, +} from './task-operation.js'; + +export type OperationRecoveryExecutionState = + | 'already_terminal' + | 'aborted' + | 'repaired' + | 'repair_required'; + +export interface ExecuteOperationRecoveryInput { + projectRoot: string; + operationId: Ulid; + actorId: Ulid; + sessionId: Ulid; + mode?: 'repair' | 'abort'; + now?: Date; +} + +export interface ExecutedOperationRecovery { + state: OperationRecoveryExecutionState; + journal: OperationJournalV1; + reason: string; +} + +export interface InspectedOperationRecovery { + journal: OperationJournalV1; + recoveryAction: ReturnType['action']; + recoveryReason: ReturnType['reason']; + payloadBound: boolean; +} + +export async function inspectOperationRecovery( + projectRoot: string, + operationId: Ulid, +): Promise { + assertUlid(operationId, 'operation recovery operationId'); + const stores = await knownOperationStores(projectRoot); + const located = await locateJournal(stores, operationId); + if (located === null) throw new Error('MANCODE_OPERATION_JOURNAL_NOT_FOUND'); + const plan = planOperationRecovery({ + journal: located.journal, + reservations: await readReservations(located.journal, stores), + }); + return { + journal: located.journal, + recoveryAction: plan.action, + recoveryReason: plan.reason, + payloadBound: located.journal.recoveryPayloadDigest !== undefined, + }; +} + +export async function listUnfinishedOperationRecoveries( + projectRoot: string, +): Promise { + const stores = await knownOperationStores(projectRoot); + const journals = ( + await Promise.all( + stores.map(async (store) => + ( + await listUnfinishedOperationJournals(store) + ).map((journal) => ({ + journal, + })), + ), + ) + ).flat(); + return Promise.all( + journals.map(async ({ journal }) => { + const plan = planOperationRecovery({ + journal, + reservations: await readReservations(journal, stores), + }); + return { + journal, + recoveryAction: plan.action, + recoveryReason: plan.reason, + payloadBound: journal.recoveryPayloadDigest !== undefined, + }; + }), + ); +} + +/** + * Applies only exact, journal-bound recovery targets under the original + * canonical locks. It never substitutes a different actor, session, plan, + * claim, or handoff after a crash. + */ +export async function executeOperationRecovery( + input: ExecuteOperationRecoveryInput, +): Promise { + assertUlid(input.operationId, 'operation recovery operationId'); + assertUlid(input.actorId, 'operation recovery actorId'); + assertUlid(input.sessionId, 'operation recovery sessionId'); + const now = input.now ?? new Date(); + const mode = input.mode ?? 'repair'; + const stores = await knownOperationStores(input.projectRoot); + const located = await locateJournal(stores, input.operationId); + if (located === null) throw new Error('MANCODE_OPERATION_JOURNAL_NOT_FOUND'); + const session = await readSession(input.projectRoot, input.sessionId); + if ( + session === null || + session.status !== 'active' || + session.actorId !== input.actorId + ) { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + const initialPlan = planOperationRecovery({ + journal: located.journal, + reservations: await readReservations(located.journal, stores), + }); + assertRecoveryActor(initialPlan, input.actorId, input.sessionId); + if ( + located.journal.state === 'committed' || + located.journal.state === 'aborted' + ) { + return { + state: 'already_terminal', + journal: located.journal, + reason: 'terminal', + }; + } + + const locks = await acquireRecoveryOperationLocks( + located.store, + located.journal, + stores, + now, + ); + try { + const journal = await readOperationJournal( + located.store, + input.operationId, + ); + if (journal === null) + throw new Error('MANCODE_OPERATION_JOURNAL_NOT_FOUND'); + const plan = planOperationRecovery({ + journal, + reservations: await readReservations(journal, stores), + }); + assertRecoveryActor(plan, input.actorId, input.sessionId); + if (journal.state === 'committed' || journal.state === 'aborted') { + return { state: 'already_terminal', journal, reason: 'terminal' }; + } + const payload = await loadBoundPayload(located.store, journal); + if (payload === null) { + if (plan.action === 'safe_abort') { + const aborted = await abortOperation( + located.store, + journal, + stores, + now, + ); + return { + state: 'aborted', + journal: aborted, + reason: 'no_external_write', + }; + } + if (mode === 'abort') throw new Error('MANCODE_OPERATION_ABORT_UNSAFE'); + const blocked = await markRepairRequired(located.store, journal, now); + return { + state: 'repair_required', + journal: blocked, + reason: 'MANCODE_OPERATION_RECOVERY_PAYLOAD_REQUIRED', + }; + } + assertOperationRecoveryPayloadCoversJournal(journal, payload); + try { + await removePrivateWorkflowStaging(input.projectRoot, journal, payload); + } catch (error) { + const blocked = await markRepairRequired(located.store, journal, now); + return { + state: 'repair_required', + journal: blocked, + reason: + error instanceof Error && + error.message === 'MANCODE_OPERATION_RECOVERY_STAGING_UNSAFE' + ? 'MANCODE_OPERATION_RECOVERY_STAGING_UNSAFE' + : 'MANCODE_OPERATION_RECOVERY_FAILED', + }; + } + if (await allActionsAtInitialState(input.projectRoot, stores, payload)) { + const aborted = await abortOperation(located.store, journal, stores, now); + return { + state: 'aborted', + journal: aborted, + reason: 'no_external_write', + }; + } + if (mode === 'abort') throw new Error('MANCODE_OPERATION_ABORT_UNSAFE'); + try { + const repaired = await applyPayload( + input.projectRoot, + located.store, + stores, + journal, + payload, + now, + ); + try { + await removeReservations(repaired, stores); + } catch { + // The primary journal is already committed. Retaining a stale + // reservation is diagnosable, but it must not be rewritten back to a + // non-terminal state after the durable commit point. + } + await recordLocalDiagnostic(input.projectRoot, { + kind: 'repair_operation', + }).catch(() => undefined); + return { + state: 'repaired', + journal: repaired, + reason: 'forward_repair', + }; + } catch (error) { + const blocked = await markRepairRequired(located.store, journal, now); + return { + state: 'repair_required', + journal: blocked, + reason: + error instanceof Error && + error.message === 'MANCODE_OPERATION_RECOVERY_CONFLICT' + ? 'MANCODE_OPERATION_RECOVERY_CONFLICT' + : 'MANCODE_OPERATION_RECOVERY_FAILED', + }; + } + } finally { + await releaseLocks(locks); + } +} + +async function knownOperationStores( + projectRoot: string, +): Promise { + const runtime = await readProjectRuntimeContext(projectRoot); + const stores = [ + resolveLocalEntityHomeStore(runtime.entityHomeStoreContext), + resolveCoordinationEntityHomeStore(runtime.entityHomeStoreContext), + ]; + return stores.filter( + (store, index) => + stores.findIndex((candidate) => candidate.storeId === store.storeId) === + index, + ); +} + +async function locateJournal( + stores: EntityHomeStore[], + operationId: Ulid, +): Promise<{ store: EntityHomeStore; journal: OperationJournalV1 } | null> { + const found = ( + await Promise.all( + stores.map(async (store) => ({ + store, + journal: await readOperationJournal(store, operationId), + })), + ) + ).filter( + ( + candidate, + ): candidate is { store: EntityHomeStore; journal: OperationJournalV1 } => + candidate.journal !== null, + ); + if (found.length > 1) throw new Error('MANCODE_OPERATION_JOURNAL_CONFLICT'); + return found[0] ?? null; +} + +async function readReservations( + journal: OperationJournalV1, + stores: EntityHomeStore[], +) { + const byId = new Map(stores.map((store) => [store.storeId, store])); + return Promise.all( + journal.secondaryReservations.map(async (reservation) => { + const store = byId.get(reservation.storeId); + return store === undefined + ? null + : readOperationReservation(store, journal.operationId); + }), + ).then((values) => values.filter((value) => value !== null)); +} + +async function acquireRecoveryOperationLocks( + primaryStore: EntityHomeStore, + journal: OperationJournalV1, + stores: EntityHomeStore[], + now: Date, +): Promise { + const byId = new Map(stores.map((store) => [store.storeId, store])); + const secondaryTargets = journal.secondaryReservations.map((reservation) => { + const store = byId.get(reservation.storeId); + if (store === undefined) { + throw new Error('MANCODE_OPERATION_RESERVATION_STORE_UNAVAILABLE'); + } + return { store, entityLockKeys: reservation.entityKeys }; + }); + return acquireOperationEntityLocks( + journal.operationId, + [ + { store: primaryStore, entityLockKeys: journal.entityLocks }, + ...secondaryTargets, + ], + { now }, + ); +} + +async function loadBoundPayload( + store: EntityHomeStore, + journal: OperationJournalV1, +): Promise { + if (journal.recoveryPayloadDigest === undefined) return null; + const payload = await readOperationRecoveryPayload( + store, + journal.operationId, + ); + if (payload === null) { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_MISSING'); + } + const parsed = parseOperationRecoveryPayload(payload); + if ( + parsed.operationId !== journal.operationId || + parsed.type !== journal.type || + parsed.primaryStoreId !== journal.primaryStoreId || + operationRecoveryPayloadDigest(parsed) !== journal.recoveryPayloadDigest + ) { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_MISMATCH'); + } + return parsed; +} + +async function allActionsAtInitialState( + projectRoot: string, + stores: EntityHomeStore[], + payload: OperationRecoveryPayloadV1, +): Promise { + const initialByResource = new Map(); + for (const action of payload.actions) { + const key = recoveryActionResourceKey(action); + if (!initialByResource.has(key)) initialByResource.set(key, action); + } + for (const action of initialByResource.values()) { + const current = await currentActionDigest(projectRoot, stores, action); + if (current !== action.beforeDigest) return false; + } + return true; +} + +async function applyPayload( + projectRoot: string, + primaryStore: EntityHomeStore, + stores: EntityHomeStore[], + initialJournal: OperationJournalV1, + payload: OperationRecoveryPayloadV1, + now: Date, +): Promise { + let journal = initialJournal; + const definition = getOperationDefinition(journal.type); + for (const [index, action] of payload.actions.entries()) { + const stepIndex = definition.steps.findIndex( + (step) => step.id === action.stepId, + ); + if (stepIndex < 0) { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_STEP_INVALID'); + } + const current = await currentActionDigest(projectRoot, stores, action); + const target = recoveryActionTargetDigest(action); + const laterTarget = payload.actions + .slice(index + 1) + .some( + (candidate) => + recoveryActionResourceKey(candidate) === + recoveryActionResourceKey(action) && + recoveryActionTargetDigest(candidate) === current, + ); + if (current !== target && current !== action.beforeDigest && !laterTarget) { + throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + } + journal = await completeJournalThrough( + primaryStore, + journal, + stepIndex, + now, + ); + if (current === target || laterTarget) continue; + await applyAction( + projectRoot, + stores, + journal.operationId, + journal.actorId, + action, + ); + } + const applying = await completeJournalThrough( + primaryStore, + journal, + journal.steps.length - 1, + now, + ); + return updateOperationJournal( + primaryStore, + { + ...applying, + state: 'committed', + updatedAt: now.toISOString(), + }, + { canAbort: false }, + ); +} + +async function completeJournalThrough( + store: EntityHomeStore, + initial: OperationJournalV1, + lastIndex: number, + now: Date, +): Promise { + let journal = initial; + for (let index = 0; index <= lastIndex; index += 1) { + if (journal.steps[index]?.state === 'completed') continue; + journal = await updateOperationJournal( + store, + { + ...journal, + state: journal.state === 'prepared' ? 'applying' : journal.state, + steps: journal.steps.map((step, stepIndex) => + stepIndex === index ? { ...step, state: 'completed' as const } : step, + ), + updatedAt: now.toISOString(), + }, + { canAbort: false }, + ); + } + return journal; +} + +async function applyAction( + projectRoot: string, + stores: EntityHomeStore[], + operationId: Ulid, + actorId: Ulid, + action: OperationRecoveryActionV1, +): Promise { + switch (action.kind) { + case 'task_authority_file': { + const root = await taskRoot(projectRoot, action.taskRef); + await writeTaskAuthorityFileAtRoot( + root, + operationId, + action.fileName, + action.targetContent, + ); + return; + } + case 'workflow_task_directory': + await publishWorkflowDirectory(projectRoot, operationId, action); + return; + case 'migration_task_directory': + await publishMigrationDirectory(projectRoot, operationId, action); + return; + case 'project_authority_file': + assertProjectAuthorityTransition(action); + await writeProjectAuthorityFile( + projectRoot, + operationId, + action.fileName, + action.targetContent, + ); + return; + case 'migration_stage_file': + assertMigrationStageTransition(action); + await writeMigrationStageFile( + projectRoot, + operationId, + action.stageId, + action.targetContent, + ); + return; + case 'v3_adapter_file': + await applyV3AdapterFilePlan(projectRoot, { + target: action.target, + beforeContent: action.beforeContent, + targetContent: action.targetContent, + }); + return; + case 'checkpoint': { + const root = await taskRoot(projectRoot, action.checkpoint.taskRef); + await writeTaskCheckpointAtRoot(root, action.checkpoint); + return; + } + case 'task_head_fence': + await replaceTaskHeadFence( + taskEntityHomeStore(stores, action.fence.taskRef), + action.fence, + ); + return; + case 'claim': { + const store = coordinationEntityHomeStore(stores); + const current = await readClaim(store, action.claim.claimId); + if (current === null) { + await createClaim(store, action.claim); + } else { + await updateClaim(store, action.claim, current.revision); + } + return; + } + case 'handoff': { + const store = coordinationEntityHomeStore(stores); + const current = await readHandoff(store, action.handoff.handoffId); + if (current === null) { + await createHandoff(store, action.handoff); + } else { + await updateHandoff(store, action.handoff, current.revision, actorId); + } + return; + } + } +} + +function assertProjectAuthorityTransition( + action: Extract< + OperationRecoveryActionV1, + { kind: 'project_authority_file' } + >, +): void { + if (action.beforeContent === null) { + throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + } + switch (action.fileName) { + case 'schema.json': + assertSchemaManifestTransition( + parseSchemaManifest(JSON.parse(action.beforeContent)), + parseSchemaManifest(JSON.parse(action.targetContent)), + ); + return; + case 'shared/config.json': + assertProjectConfigTransition( + parseProjectConfig(JSON.parse(action.beforeContent)), + parseProjectConfig(JSON.parse(action.targetContent)), + 'ordinary', + ); + return; + case 'shared/team/policy.json': + assertTeamPolicyTransition( + parseTeamPolicy(JSON.parse(action.beforeContent)), + parseTeamPolicy(JSON.parse(action.targetContent)), + ); + } +} + +function assertMigrationStageTransition( + action: Extract, +): void { + if (action.beforeContent === null) { + throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + } + const previous = parseMigrationStage(JSON.parse(action.beforeContent)); + const next = parseMigrationStage(JSON.parse(action.targetContent)); + if ( + previous.stageId !== action.stageId || + next.stageId !== action.stageId || + previous.state !== 'staged' || + next.state !== 'activated' || + next.revision !== previous.revision + 1 || + previous.sourceInventoryDigest !== next.sourceInventoryDigest || + previous.sourceBaseline.stateDigest !== next.sourceBaseline.stateDigest || + previous.sourceBaseline.workflowIndexDigest !== + next.sourceBaseline.workflowIndexDigest + ) { + throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + } +} + +async function currentActionDigest( + projectRoot: string, + stores: EntityHomeStore[], + action: OperationRecoveryActionV1, +): Promise { + switch (action.kind) { + case 'task_authority_file': { + const content = await readTaskAuthorityFileAtRoot( + await taskRoot(projectRoot, action.taskRef), + action.fileName, + ); + return content === null + ? null + : taskAuthorityContentDigest(action.fileName, content); + } + case 'workflow_task_directory': + return currentWorkflowDirectoryDigest(projectRoot, action); + case 'migration_task_directory': + return currentMigrationDirectoryDigest(projectRoot, action); + case 'project_authority_file': { + const content = await readProjectAuthorityFile( + projectRoot, + action.fileName, + ); + return content === null + ? null + : projectAuthorityContentDigest(action.fileName, content); + } + case 'migration_stage_file': { + const content = await readMigrationStageFile(projectRoot, action.stageId); + return content === null ? null : migrationStageContentDigest(content); + } + case 'v3_adapter_file': { + const content = await readAdapterFile(projectRoot, action.target); + return adapterFileContentDigest(action.target, content); + } + case 'checkpoint': { + const checkpoint = await readTaskCheckpointAtRoot( + await taskRoot(projectRoot, action.checkpoint.taskRef), + action.checkpoint.checkpointId, + ); + return checkpoint === null ? null : digestCanonicalJson(checkpoint); + } + case 'task_head_fence': { + const fence = await readTaskHeadFence( + taskEntityHomeStore(stores, action.fence.taskRef), + action.fence.taskRef, + ); + return fence === null ? null : digestCanonicalJson(fence); + } + case 'claim': { + const claim = await readClaim( + coordinationEntityHomeStore(stores), + action.claim.claimId, + ); + return claim === null ? null : digestCanonicalJson(claim); + } + case 'handoff': { + const handoff = await readHandoff( + coordinationEntityHomeStore(stores), + action.handoff.handoffId, + ); + return handoff === null ? null : digestCanonicalJson(handoff); + } + } +} + +async function currentWorkflowDirectoryDigest( + projectRoot: string, + action: Extract< + OperationRecoveryActionV1, + { kind: 'workflow_task_directory' } + >, +): Promise { + const root = taskRootPath(projectRoot, action.taskRef); + try { + const entry = await lstat(root); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } + const entries = await readdir(root); + const expectedNames = new Set( + action.files.map((file) => file.fileName), + ); + if ( + entries.length !== expectedNames.size || + entries.some((entry) => !expectedNames.has(entry)) + ) { + return digestCanonicalJson({ + unexpectedEntries: [...entries].sort((left, right) => + Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')), + ), + }); + } + const files = [] as typeof action.files; + for (const target of action.files) { + const content = await readTaskAuthorityFileAtRoot(root, target.fileName); + if (content === null) + return digestCanonicalJson({ missing: target.fileName }); + files.push({ fileName: target.fileName, content }); + } + return workflowTaskDirectoryDigest({ ...action, files }); +} + +async function currentMigrationDirectoryDigest( + projectRoot: string, + action: Extract< + OperationRecoveryActionV1, + { kind: 'migration_task_directory' } + >, +): Promise { + const root = taskRootPath(projectRoot, action.taskRef); + try { + const entry = await lstat(root); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } + const expectedRootEntries = new Set([ + ...action.files.map((file) => file.fileName), + ...(action.reports.length > 0 ? ['reports'] : []), + ]); + const entries = await readdir(root); + if ( + entries.length !== expectedRootEntries.size || + entries.some((entry) => !expectedRootEntries.has(entry)) + ) { + return digestCanonicalJson({ + unexpectedEntries: [...entries].sort((left, right) => + Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')), + ), + }); + } + const files = [] as typeof action.files; + for (const target of action.files) { + const content = await readTaskAuthorityFileAtRoot(root, target.fileName); + if (content === null) + return digestCanonicalJson({ missing: target.fileName }); + files.push({ fileName: target.fileName, content }); + } + const reports = [] as typeof action.reports; + const reportNames = new Set(action.reports.map(migrationReportFileName)); + if (action.reports.length > 0) { + const reportsDirectory = path.join(root, 'reports'); + const reportDirectoryEntry = await lstat(reportsDirectory); + if ( + !reportDirectoryEntry.isDirectory() || + reportDirectoryEntry.isSymbolicLink() + ) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const entries = await readdir(reportsDirectory); + if ( + entries.length !== reportNames.size || + entries.some((entry) => !reportNames.has(entry)) + ) { + return digestCanonicalJson({ + unexpectedReports: [...entries].sort((left, right) => + Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')), + ), + }); + } + } + for (const target of action.reports) { + const content = await readMigrationReport(root, target); + if (content === null) { + return digestCanonicalJson({ + missing: `${target.kind}:${target.artifactId}`, + }); + } + reports.push({ ...target, content }); + } + return migrationTaskDirectoryDigest({ ...action, files, reports }); +} + +async function publishWorkflowDirectory( + projectRoot: string, + operationId: Ulid, + action: Extract< + OperationRecoveryActionV1, + { kind: 'workflow_task_directory' } + >, +): Promise { + const target = taskRootPath(projectRoot, action.taskRef); + const parent = await ensureSafeTaskParent(projectRoot, action.taskRef); + const existing = await currentWorkflowDirectoryDigest(projectRoot, action); + if (existing === workflowTaskDirectoryDigest(action)) return; + if (existing !== null) throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + const staging = path.join( + parent, + `.${action.taskRef.taskId}.${operationId}.recovery.staging`, + ); + try { + await mkdir(staging); + for (const file of action.files) { + await writeFile(path.join(staging, file.fileName), file.content, { + encoding: 'utf8', + flag: 'wx', + }); + } + await rename(staging, target); + } catch (error) { + await rm(staging, { recursive: true, force: true }); + if (isAlreadyExists(error)) { + const after = await currentWorkflowDirectoryDigest(projectRoot, action); + if (after === workflowTaskDirectoryDigest(action)) return; + throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + } + throw error; + } +} + +async function publishMigrationDirectory( + projectRoot: string, + operationId: Ulid, + action: Extract< + OperationRecoveryActionV1, + { kind: 'migration_task_directory' } + >, +): Promise { + const target = taskRootPath(projectRoot, action.taskRef); + const parent = await ensureSafeTaskParent(projectRoot, action.taskRef); + const existing = await currentMigrationDirectoryDigest(projectRoot, action); + if (existing === migrationTaskDirectoryDigest(action)) return; + if (existing !== null) throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + const staging = path.join( + parent, + `.${action.taskRef.taskId}.${operationId}.recovery.staging`, + ); + try { + await mkdir(staging); + for (const file of action.files) { + await writeFile(path.join(staging, file.fileName), file.content, { + encoding: 'utf8', + flag: 'wx', + }); + } + if (action.reports.length > 0) { + const reports = path.join(staging, 'reports'); + await mkdir(reports); + for (const report of action.reports) { + await writeFile( + path.join(reports, migrationReportFileName(report)), + report.content, + { encoding: 'utf8', flag: 'wx' }, + ); + } + } + await rename(staging, target); + } catch (error) { + await rm(staging, { recursive: true, force: true }); + if (isAlreadyExists(error)) { + const after = await currentMigrationDirectoryDigest(projectRoot, action); + if (after === migrationTaskDirectoryDigest(action)) return; + throw new Error('MANCODE_OPERATION_RECOVERY_CONFLICT'); + } + throw error; + } +} + +function migrationReportFileName( + report: Extract< + OperationRecoveryActionV1, + { kind: 'migration_task_directory' } + >['reports'][number], +): string { + return report.kind === 'review_report' + ? `${report.artifactId}.md` + : `evidence-${report.artifactId}.md`; +} + +async function readMigrationReport( + taskRoot: string, + report: Extract< + OperationRecoveryActionV1, + { kind: 'migration_task_directory' } + >['reports'][number], +): Promise { + const reportsDirectory = path.join(taskRoot, 'reports'); + const target = path.join(reportsDirectory, migrationReportFileName(report)); + try { + const directory = await lstat(reportsDirectory); + if (!directory.isDirectory() || directory.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const content = await readFile(target, 'utf8'); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return content; + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +async function readProjectAuthorityFile( + projectRoot: string, + fileName: Extract< + OperationRecoveryActionV1, + { kind: 'project_authority_file' } + >['fileName'], +): Promise { + return readSafeFixedFile(projectRoot, fileName.split('/')); +} + +async function writeProjectAuthorityFile( + projectRoot: string, + operationId: Ulid, + fileName: Extract< + OperationRecoveryActionV1, + { kind: 'project_authority_file' } + >['fileName'], + content: string, +): Promise { + await writeSafeFixedFile( + projectRoot, + operationId, + fileName.split('/'), + content, + ); +} + +async function readMigrationStageFile( + projectRoot: string, + stageId: Ulid, +): Promise { + return readSafeFixedFile(projectRoot, [ + 'local', + 'migration', + 'stages', + `${stageId}.json`, + ]); +} + +async function readAdapterFile( + projectRoot: string, + target: Extract< + OperationRecoveryActionV1, + { kind: 'v3_adapter_file' } + >['target'], +): Promise { + const filePath = v3AdapterTargetPath(projectRoot, target); + try { + const entry = await lstat(filePath); + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return await readFile(filePath, 'utf8'); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +async function ensureSafeTaskParent( + projectRoot: string, + taskRef: TaskRef, +): Promise { + let current = path.resolve(projectRoot); + const root = await lstat(current); + if (!root.isDirectory() || root.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + for (const segment of ['.mancode', taskRef.namespace, 'workflows']) { + current = path.join(current, segment); + try { + const entry = await lstat(current); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (!isNotFound(error)) throw error; + await mkdir(current); + } + } + return current; +} + +async function writeMigrationStageFile( + projectRoot: string, + operationId: Ulid, + stageId: Ulid, + content: string, +): Promise { + await writeSafeFixedFile( + projectRoot, + operationId, + ['local', 'migration', 'stages', `${stageId}.json`], + content, + ); +} + +async function readSafeFixedFile( + projectRoot: string, + segments: string[], +): Promise { + const target = path.join(projectRoot, '.mancode', ...segments); + try { + await assertSafeFixedParent(projectRoot, segments.slice(0, -1), false); + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const content = await readFile(target, 'utf8'); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return content; + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +async function writeSafeFixedFile( + projectRoot: string, + operationId: Ulid, + segments: string[], + content: string, +): Promise { + if (content.includes('\0') || segments.length === 0) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const parent = await assertSafeFixedParent( + projectRoot, + segments.slice(0, -1), + true, + ); + const fileName = segments.at(-1); + if (fileName === undefined || !/^[A-Za-z0-9._-]+$/.test(fileName)) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const target = path.join(parent, fileName); + const temporary = path.join( + parent, + `.${fileName}.${operationId}.${process.pid}.tmp`, + ); + await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' }); + try { + await rename(temporary, target); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } + const written = await lstat(target); + if (!written.isFile() || written.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } +} + +async function assertSafeFixedParent( + projectRoot: string, + segments: string[], + create: boolean, +): Promise { + let current = path.join(projectRoot, '.mancode'); + const root = await lstat(current); + if (!root.isDirectory() || root.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + for (const segment of segments) { + if (!/^[A-Za-z0-9._-]+$/.test(segment)) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + current = path.join(current, segment); + try { + const entry = await lstat(current); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (!isNotFound(error) || !create) throw error; + await mkdir(current); + } + } + return current; +} + +/** + * A workflow-directory staging path is deliberately private: it is not an + * authority artifact until the atomic rename publishes the task directory. + * Recovery removes only that exact operation-owned path before either an + * abort or a forward repair. A symlink or non-directory requires manual + * repair instead of deleting an untrusted path. + */ +async function removePrivateWorkflowStaging( + projectRoot: string, + journal: OperationJournalV1, + payload: OperationRecoveryPayloadV1, +): Promise { + const actions = payload.actions.filter( + ( + candidate, + ): candidate is Extract< + OperationRecoveryActionV1, + { kind: 'workflow_task_directory' | 'migration_task_directory' } + > => + candidate.kind === 'workflow_task_directory' || + candidate.kind === 'migration_task_directory', + ); + for (const action of actions) { + await removePrivateTaskDirectoryStaging(projectRoot, journal, action); + } +} + +async function removePrivateTaskDirectoryStaging( + projectRoot: string, + journal: OperationJournalV1, + action: Extract< + OperationRecoveryActionV1, + { kind: 'workflow_task_directory' | 'migration_task_directory' } + >, +): Promise { + const parent = path.dirname(taskRootPath(projectRoot, action.taskRef)); + let parentEntry: Stats; + try { + parentEntry = await lstat(parent); + } catch (error) { + if (isNotFound(error)) return; + throw error; + } + if (!parentEntry.isDirectory() || parentEntry.isSymbolicLink()) { + throw new Error('MANCODE_OPERATION_RECOVERY_STAGING_UNSAFE'); + } + for (const suffix of ['staging', 'recovery.staging']) { + const staging = path.join( + parent, + `.${action.taskRef.taskId}.${journal.operationId}.${suffix}`, + ); + let stagingEntry: Stats; + try { + stagingEntry = await lstat(staging); + } catch (error) { + if (isNotFound(error)) continue; + throw error; + } + if (!stagingEntry.isDirectory() || stagingEntry.isSymbolicLink()) { + throw new Error('MANCODE_OPERATION_RECOVERY_STAGING_UNSAFE'); + } + await rm(staging, { recursive: true, force: false }); + } +} + +async function taskRoot( + projectRoot: string, + taskRef: TaskRef, +): Promise { + const location = await new V3ContextStore(projectRoot).locateTask(taskRef); + if (!sameTaskRef(location.taskRef, taskRef)) { + throw new Error('MANCODE_CONTEXT_TASK_LOCATION_MISMATCH'); + } + return location.taskRoot; +} + +function taskEntityHomeStore( + stores: EntityHomeStore[], + taskRef: TaskRef, +): EntityHomeStore { + const expectedKind = taskRef.namespace === 'local' ? 'checkout_local' : null; + const store = stores.find((candidate) => + expectedKind === null + ? candidate.kind !== 'checkout_local' + : candidate.kind === expectedKind, + ); + if (store === undefined) { + throw new Error('MANCODE_OPERATION_RESERVATION_STORE_UNAVAILABLE'); + } + return store; +} + +function coordinationEntityHomeStore( + stores: EntityHomeStore[], +): EntityHomeStore { + const store = stores.find((candidate) => candidate.kind !== 'checkout_local'); + if (store === undefined) { + throw new Error('MANCODE_OPERATION_RESERVATION_STORE_UNAVAILABLE'); + } + return store; +} + +async function abortOperation( + store: EntityHomeStore, + journal: OperationJournalV1, + stores: EntityHomeStore[], + now: Date, +): Promise { + const aborted = await updateOperationJournal( + store, + { ...journal, state: 'aborted', updatedAt: now.toISOString() }, + { canAbort: true }, + ); + await removeReservations(aborted, stores); + return aborted; +} + +async function markRepairRequired( + store: EntityHomeStore, + journal: OperationJournalV1, + now: Date, +): Promise { + if (journal.state === 'repair_required') return journal; + const applying = + journal.state === 'prepared' + ? await updateOperationJournal( + store, + { ...journal, state: 'applying', updatedAt: now.toISOString() }, + { canAbort: true }, + ) + : journal; + return updateOperationJournal( + store, + { ...applying, state: 'repair_required', updatedAt: now.toISOString() }, + { canAbort: false }, + ); +} + +async function removeReservations( + journal: OperationJournalV1, + stores: EntityHomeStore[], +): Promise { + const byId = new Map(stores.map((store) => [store.storeId, store])); + for (const reservation of journal.secondaryReservations) { + const store = byId.get(reservation.storeId); + if (store === undefined) { + throw new Error('MANCODE_OPERATION_RESERVATION_STORE_UNAVAILABLE'); + } + await removeOperationReservation( + store, + journal.operationId, + journal.primaryStoreId, + ); + } +} + +async function releaseLocks(locks: LocalLockHandle[]): Promise { + await Promise.allSettled([...locks].reverse().map((lock) => lock.release())); +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/operation-recovery-payload.ts b/src/runtime/operation-recovery-payload.ts new file mode 100644 index 0000000..9e8b957 --- /dev/null +++ b/src/runtime/operation-recovery-payload.ts @@ -0,0 +1,1330 @@ +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { parseSchemaManifest } from '../context/manifest.js'; +import { + type RequirementsLedgerV1, + parseRequirementsLedger, +} from '../context/requirements-ledger.js'; +import { + type ReviewLedgerV1, + parseReviewLedger, +} from '../context/review-ledger.js'; +import { type TaskRef, parseTaskRefValue } from '../context/task-ref.js'; +import { sameTaskRef } from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { + type VerificationLedgerV1, + parseVerificationLedger, +} from '../context/verification-ledger.js'; +import { + type WorkflowMetadataV3, + parseWorkflowMetadata, +} from '../context/workflow-metadata.js'; +import type { V3AdapterFileTarget } from '../installers/v3-adapter.js'; +import { + type CheckpointV1, + checkpointDigest, + parseCheckpoint, +} from '../team/checkpoints.js'; +import { type ClaimV1, parseClaim } from '../team/claims.js'; +import { type HandoffV1, parseHandoff } from '../team/handoff.js'; +import { parseProjectConfig, parseTeamPolicy } from '../team/policy.js'; +import { getOperationDefinition } from './operation-definition.js'; +import type { OperationJournalV1, OperationType } from './operation-journal.js'; +import { type TaskHeadFenceV1, parseTaskHeadFence } from './task-head-fence.js'; + +export const TASK_AUTHORITY_FILE_NAMES = [ + 'metadata.json', + 'requirements.json', + 'review-ledger.json', + 'verification-ledger.json', + 'plan.md', +] as const; + +export type TaskAuthorityFileName = (typeof TASK_AUTHORITY_FILE_NAMES)[number]; + +export interface TaskAuthorityFileRecoveryAction { + kind: 'task_authority_file'; + stepId: string; + taskRef: TaskRef; + fileName: TaskAuthorityFileName; + beforeDigest: string | null; + targetContent: string; +} + +export interface WorkflowTaskDirectoryRecoveryAction { + kind: 'workflow_task_directory'; + stepId: string; + taskRef: TaskRef; + /** Authority files published atomically as the new task directory. */ + files: Array<{ + fileName: TaskAuthorityFileName; + content: string; + }>; + beforeDigest: null; +} + +/** + * Migration promotes an already-audited candidate, including the report + * artifacts referenced by its ledgers. Keeping this distinct from ordinary + * workflow creation prevents a generic create operation from acquiring an + * arbitrary artifact-writing surface. + */ +export interface MigrationTaskDirectoryRecoveryAction { + kind: 'migration_task_directory'; + stepId: string; + taskRef: TaskRef; + files: Array<{ + fileName: TaskAuthorityFileName; + content: string; + }>; + reports: Array<{ + kind: 'review_report' | 'evidence_summary'; + artifactId: Ulid; + content: string; + }>; + beforeDigest: null; +} + +/** Fixed V3 project files; recovery never accepts a caller-supplied path. */ +export const PROJECT_AUTHORITY_FILE_NAMES = [ + 'schema.json', + 'shared/config.json', + 'shared/team/policy.json', +] as const; + +export type ProjectAuthorityFileName = + (typeof PROJECT_AUTHORITY_FILE_NAMES)[number]; + +export interface ProjectAuthorityFileRecoveryAction { + kind: 'project_authority_file'; + stepId: string; + fileName: ProjectAuthorityFileName; + beforeDigest: string | null; + beforeContent: string | null; + targetContent: string; +} + +/** A stage is local migration bookkeeping, but its terminal state is durable. */ +export interface MigrationStageFileRecoveryAction { + kind: 'migration_stage_file'; + stepId: string; + stageId: Ulid; + beforeDigest: string | null; + beforeContent: string | null; + targetContent: string; +} + +/** Exact physical adapter replacement, scoped to the four fixed V3 targets. */ +export interface V3AdapterFileRecoveryAction { + kind: 'v3_adapter_file'; + stepId: string; + target: V3AdapterFileTarget; + beforeDigest: string | null; + /** Retained only in the local recovery sidecar for constrained rollback. */ + beforeContent: string | null; + targetContent: string; +} + +export interface CheckpointRecoveryAction { + kind: 'checkpoint'; + stepId: string; + checkpoint: CheckpointV1; + beforeDigest: string | null; +} + +export interface TaskHeadFenceRecoveryAction { + kind: 'task_head_fence'; + stepId: string; + fence: TaskHeadFenceV1; + beforeDigest: string | null; +} + +export interface ClaimRecoveryAction { + kind: 'claim'; + stepId: string; + claim: ClaimV1; + beforeDigest: string | null; +} + +export interface HandoffRecoveryAction { + kind: 'handoff'; + stepId: string; + handoff: HandoffV1; + beforeDigest: string | null; +} + +export type OperationRecoveryActionV1 = + | TaskAuthorityFileRecoveryAction + | WorkflowTaskDirectoryRecoveryAction + | MigrationTaskDirectoryRecoveryAction + | ProjectAuthorityFileRecoveryAction + | MigrationStageFileRecoveryAction + | V3AdapterFileRecoveryAction + | CheckpointRecoveryAction + | TaskHeadFenceRecoveryAction + | ClaimRecoveryAction + | HandoffRecoveryAction; + +/** + * Exact, immutable targets for a durable operation. The journal binds this + * payload's digest before its first business write, so recovery never needs + * to recreate a plan, ledger, claim, or handoff from mutable current state. + */ +export interface OperationRecoveryPayloadV1 { + schemaVersion: 1; + operationId: Ulid; + type: OperationType; + primaryStoreId: string; + actions: OperationRecoveryActionV1[]; + /** Business-write steps intentionally skipped by a namespace-specific path. */ + noOpStepIds: string[]; +} + +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; +const STEP_ID_PATTERN = /^[a-z][a-z0-9-]*$/; + +export function parseOperationRecoveryPayload( + value: unknown, +): OperationRecoveryPayloadV1 { + assertRecord(value, 'operation recovery payload'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'type', + 'primaryStoreId', + 'actions', + 'noOpStepIds', + ], + 'operation recovery payload', + ); + if (value.schemaVersion !== 1) { + throw new Error('operation recovery payload schemaVersion must be 1'); + } + assertUlid(value.operationId, 'operation recovery payload operationId'); + if (typeof value.type !== 'string' || !value.type.trim()) { + throw new Error('operation recovery payload type is required'); + } + if ( + typeof value.primaryStoreId !== 'string' || + !/^[a-z][a-z0-9_-]*:[^\0]+$/.test(value.primaryStoreId) + ) { + throw new Error('operation recovery payload primaryStoreId is invalid'); + } + const actions = parseActions(value.actions); + const noOpStepIds = parseStepIds(value.noOpStepIds, 'noOpStepIds'); + const actionSteps = new Set(actions.map((action) => action.stepId)); + if (noOpStepIds.some((stepId) => actionSteps.has(stepId))) { + throw new Error( + 'operation recovery payload cannot both write and skip the same step', + ); + } + return { + schemaVersion: 1, + operationId: value.operationId, + type: value.type as OperationType, + primaryStoreId: value.primaryStoreId, + actions, + noOpStepIds, + }; +} + +export function operationRecoveryPayloadDigest( + payload: OperationRecoveryPayloadV1, +): string { + return digestCanonicalJson(parseOperationRecoveryPayload(payload)); +} + +/** + * Rejects a prepared journal whose recovery sidecar cannot account for every + * business write in its machine-readable operation definition. This runs + * before the journal becomes durable as well as again during recovery, so a + * new command cannot accidentally create an unrecoverable crash boundary. + */ +export function assertOperationRecoveryPayloadCoversJournal( + journal: Pick< + OperationJournalV1, + 'type' | 'entityLocks' | 'secondaryReservations' + >, + payload: OperationRecoveryPayloadV1, +): void { + if (payload.type !== journal.type) { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_TYPE_MISMATCH'); + } + const definition = getOperationDefinition(journal.type); + const stepIndexes = new Map( + definition.steps.map((step, index) => [step.id, index]), + ); + let priorIndex = -1; + const actionSteps = new Set(); + for (const action of payload.actions) { + const index = stepIndexes.get(action.stepId); + if (index === undefined || index < priorIndex) { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_STEP_INVALID'); + } + priorIndex = index; + actionSteps.add(action.stepId); + assertActionLockCoverage(journal, action); + } + for (const stepId of payload.noOpStepIds) { + const step = definition.steps[stepIndexes.get(stepId) ?? -1]; + if (step?.visibility !== 'business_write') { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_NOOP_INVALID'); + } + } + for (const step of definition.steps) { + if ( + step.visibility === 'business_write' && + !actionSteps.has(step.id) && + !payload.noOpStepIds.includes(step.id) + ) { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_INCOMPLETE'); + } + } + assertMigrationSharedTaskHeadCoverage(journal, payload); +} + +function assertMigrationSharedTaskHeadCoverage( + journal: Pick< + OperationJournalV1, + 'type' | 'entityLocks' | 'secondaryReservations' + >, + payload: OperationRecoveryPayloadV1, +): void { + if (journal.type !== 'v3_activate') return; + const sharedTasks = payload.actions + .filter( + ( + action, + ): action is Extract< + OperationRecoveryActionV1, + { kind: 'migration_task_directory' } + > => + action.kind === 'migration_task_directory' && + action.taskRef.namespace === 'shared', + ) + .map((action) => action.taskRef.taskId); + const fenced = new Set( + payload.actions + .filter( + ( + action, + ): action is Extract< + OperationRecoveryActionV1, + { kind: 'task_head_fence' } + > => action.kind === 'task_head_fence', + ) + .map((action) => action.fence.taskRef.taskId), + ); + if ( + sharedTasks.some( + (taskId) => + !fenced.has(taskId) || + !journal.entityLocks.includes(`task_head:${taskId}`), + ) + ) { + throw new Error('MANCODE_OPERATION_RECOVERY_SHARED_HEAD_MISSING'); + } +} + +export function createTaskAuthorityFileRecoveryAction(input: { + stepId: string; + taskRef: TaskRef; + fileName: TaskAuthorityFileName; + beforeContent: string | null; + targetContent: string; +}): TaskAuthorityFileRecoveryAction { + const action = { + kind: 'task_authority_file' as const, + stepId: input.stepId, + taskRef: parseTaskRefValue(input.taskRef), + fileName: input.fileName, + beforeDigest: + input.beforeContent === null + ? null + : taskAuthorityContentDigest(input.fileName, input.beforeContent), + targetContent: input.targetContent, + }; + return parseTaskAuthorityFileAction(action); +} + +export function createWorkflowTaskDirectoryRecoveryAction(input: { + stepId: string; + taskRef: TaskRef; + files: Array<{ + fileName: TaskAuthorityFileName; + content: string; + }>; +}): WorkflowTaskDirectoryRecoveryAction { + return parseWorkflowTaskDirectoryAction({ + kind: 'workflow_task_directory', + stepId: input.stepId, + taskRef: input.taskRef, + files: input.files, + beforeDigest: null, + }); +} + +export function createMigrationTaskDirectoryRecoveryAction(input: { + stepId: string; + taskRef: TaskRef; + files: Array<{ + fileName: TaskAuthorityFileName; + content: string; + }>; + reports?: Array<{ + kind: 'review_report' | 'evidence_summary'; + artifactId: Ulid; + content: string; + }>; +}): MigrationTaskDirectoryRecoveryAction { + return parseMigrationTaskDirectoryAction({ + kind: 'migration_task_directory', + stepId: input.stepId, + taskRef: input.taskRef, + files: input.files, + reports: input.reports ?? [], + beforeDigest: null, + }); +} + +export function createProjectAuthorityFileRecoveryAction(input: { + stepId: string; + fileName: ProjectAuthorityFileName; + beforeContent: string | null; + targetContent: string; +}): ProjectAuthorityFileRecoveryAction { + return parseProjectAuthorityFileAction({ + kind: 'project_authority_file', + stepId: input.stepId, + fileName: input.fileName, + beforeDigest: + input.beforeContent === null + ? null + : projectAuthorityContentDigest(input.fileName, input.beforeContent), + beforeContent: input.beforeContent, + targetContent: input.targetContent, + }); +} + +export function createMigrationStageFileRecoveryAction(input: { + stepId: string; + stageId: Ulid; + beforeContent: string | null; + targetContent: string; +}): MigrationStageFileRecoveryAction { + return parseMigrationStageFileAction({ + kind: 'migration_stage_file', + stepId: input.stepId, + stageId: input.stageId, + beforeDigest: + input.beforeContent === null + ? null + : migrationStageContentDigest(input.beforeContent), + beforeContent: input.beforeContent, + targetContent: input.targetContent, + }); +} + +export function createV3AdapterFileRecoveryAction(input: { + stepId: string; + target: V3AdapterFileTarget; + beforeContent: string | null; + targetContent: string; +}): V3AdapterFileRecoveryAction { + return parseV3AdapterFileAction({ + kind: 'v3_adapter_file', + stepId: input.stepId, + target: input.target, + beforeDigest: adapterFileContentDigest(input.target, input.beforeContent), + beforeContent: input.beforeContent, + targetContent: input.targetContent, + }); +} + +export function createCheckpointRecoveryAction(input: { + stepId: string; + before: CheckpointV1 | null; + checkpoint: CheckpointV1; +}): CheckpointRecoveryAction { + return parseCheckpointAction({ + kind: 'checkpoint', + stepId: input.stepId, + checkpoint: input.checkpoint, + beforeDigest: input.before === null ? null : checkpointDigest(input.before), + }); +} + +export function createTaskHeadFenceRecoveryAction(input: { + stepId: string; + before: TaskHeadFenceV1 | null; + fence: TaskHeadFenceV1; +}): TaskHeadFenceRecoveryAction { + return parseTaskHeadFenceAction({ + kind: 'task_head_fence', + stepId: input.stepId, + fence: input.fence, + beforeDigest: + input.before === null ? null : digestCanonicalJson(input.before), + }); +} + +export function createClaimRecoveryAction(input: { + stepId: string; + before: ClaimV1 | null; + claim: ClaimV1; +}): ClaimRecoveryAction { + return parseClaimAction({ + kind: 'claim', + stepId: input.stepId, + claim: input.claim, + beforeDigest: + input.before === null ? null : digestCanonicalJson(input.before), + }); +} + +export function createHandoffRecoveryAction(input: { + stepId: string; + before: HandoffV1 | null; + handoff: HandoffV1; +}): HandoffRecoveryAction { + return parseHandoffAction({ + kind: 'handoff', + stepId: input.stepId, + handoff: input.handoff, + beforeDigest: + input.before === null ? null : digestCanonicalJson(input.before), + }); +} + +function assertActionLockCoverage( + journal: Pick, + action: OperationRecoveryActionV1, +): void { + const hasLock = (key: string) => + journal.entityLocks.includes(key) || + journal.secondaryReservations.some((reservation) => + reservation.entityKeys.includes(key), + ); + switch (action.kind) { + case 'task_authority_file': + case 'workflow_task_directory': + case 'migration_task_directory': + if (!hasLock(taskKey(action.taskRef))) { + throw new Error('MANCODE_OPERATION_RECOVERY_LOCK_MISSING'); + } + return; + case 'project_authority_file': + if ( + !hasLock( + action.fileName === 'schema.json' + ? 'schema:project' + : action.fileName === 'shared/config.json' + ? 'config:project' + : 'policy:project', + ) + ) { + throw new Error('MANCODE_OPERATION_RECOVERY_LOCK_MISSING'); + } + return; + case 'migration_stage_file': + if (!hasLock(`stage:${action.stageId}`)) { + throw new Error('MANCODE_OPERATION_RECOVERY_LOCK_MISSING'); + } + return; + case 'v3_adapter_file': + if (!hasLock(`adapter:${action.target}`)) { + throw new Error('MANCODE_OPERATION_RECOVERY_LOCK_MISSING'); + } + return; + case 'checkpoint': + if ( + !hasLock(taskKey(action.checkpoint.taskRef)) || + !hasLock(`checkpoint:${action.checkpoint.checkpointId}`) + ) { + throw new Error('MANCODE_OPERATION_RECOVERY_LOCK_MISSING'); + } + return; + case 'task_head_fence': + if (!hasLock(`task_head:${action.fence.taskRef.taskId}`)) { + throw new Error('MANCODE_OPERATION_RECOVERY_LOCK_MISSING'); + } + return; + case 'claim': + if (!hasLock(`claim:${action.claim.claimId}`)) { + throw new Error('MANCODE_OPERATION_RECOVERY_LOCK_MISSING'); + } + return; + case 'handoff': + if (!hasLock(`handoff:${action.handoff.handoffId}`)) { + throw new Error('MANCODE_OPERATION_RECOVERY_LOCK_MISSING'); + } + return; + } +} + +function taskKey(taskRef: TaskRef): string { + return `task:${taskRef.namespace}:${taskRef.taskId}`; +} + +export function taskAuthorityContentDigest( + fileName: TaskAuthorityFileName, + content: string, +): string { + return digestCanonicalJson(parseTaskAuthorityContent(fileName, content)); +} + +export function projectAuthorityContentDigest( + fileName: ProjectAuthorityFileName, + content: string, +): string { + if (typeof content !== 'string' || content.includes('\0')) { + throw new Error('operation recovery project authority content is invalid'); + } + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error('operation recovery project authority JSON is invalid'); + } + switch (fileName) { + case 'schema.json': + return digestCanonicalJson(parseSchemaManifest(parsed)); + case 'shared/config.json': + return digestCanonicalJson(parseProjectConfig(parsed)); + case 'shared/team/policy.json': + return digestCanonicalJson(parseTeamPolicy(parsed)); + } +} + +function projectAuthorityContentDigestOrNull( + fileName: ProjectAuthorityFileName, + content: string | null, +): string | null { + return content === null + ? null + : projectAuthorityContentDigest(fileName, content); +} + +/** Stage content is parsed again by the migration reader before use. */ +export function migrationStageContentDigest(content: string): string { + if (typeof content !== 'string' || content.includes('\0')) { + throw new Error('operation recovery migration stage content is invalid'); + } + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error('operation recovery migration stage JSON is invalid'); + } + assertRecord(parsed, 'operation recovery migration stage'); + return digestCanonicalJson(parsed); +} + +function migrationStageContentDigestOrNull( + content: string | null, +): string | null { + return content === null ? null : migrationStageContentDigest(content); +} + +export function adapterFileContentDigest( + target: V3AdapterFileTarget, + content: string | null, +): string | null { + if (!V3_ADAPTER_TARGETS.has(target)) { + throw new Error('operation recovery adapter target is invalid'); + } + if ( + content !== null && + (typeof content !== 'string' || content.includes('\0')) + ) { + throw new Error('operation recovery adapter content is invalid'); + } + return content === null ? null : digestCanonicalJson({ target, content }); +} + +export function parseTaskAuthorityContent( + fileName: TaskAuthorityFileName, + content: string, +): + | WorkflowMetadataV3 + | RequirementsLedgerV1 + | ReviewLedgerV1 + | VerificationLedgerV1 + | { content: string } { + if (typeof content !== 'string' || content.includes('\0')) { + throw new Error('operation recovery task authority content is invalid'); + } + if (fileName === 'plan.md') { + if (!content.trim()) { + throw new Error('operation recovery plan content is required'); + } + return { content }; + } + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error('operation recovery task authority JSON is invalid'); + } + switch (fileName) { + case 'metadata.json': + return parseWorkflowMetadata(parsed); + case 'requirements.json': + return parseRequirementsLedger(parsed); + case 'review-ledger.json': + return parseReviewLedger(parsed); + case 'verification-ledger.json': + return parseVerificationLedger(parsed); + } +} + +export function recoveryActionResourceKey( + action: OperationRecoveryActionV1, +): string { + switch (action.kind) { + case 'task_authority_file': + return `task-file:${action.taskRef.namespace}:${action.taskRef.taskId}:${action.fileName}`; + case 'workflow_task_directory': + return `workflow-directory:${action.taskRef.namespace}:${action.taskRef.taskId}`; + case 'migration_task_directory': + return `migration-workflow-directory:${action.taskRef.namespace}:${action.taskRef.taskId}`; + case 'project_authority_file': + return `project-file:${action.fileName}`; + case 'migration_stage_file': + return `migration-stage:${action.stageId}`; + case 'v3_adapter_file': + return `v3-adapter:${action.target}`; + case 'checkpoint': + return `checkpoint:${action.checkpoint.taskRef.namespace}:${action.checkpoint.taskRef.taskId}:${action.checkpoint.checkpointId}`; + case 'task_head_fence': + return `task-head:${action.fence.taskRef.taskId}`; + case 'claim': + return `claim:${action.claim.claimId}`; + case 'handoff': + return `handoff:${action.handoff.handoffId}`; + } +} + +export function recoveryActionTargetDigest( + action: OperationRecoveryActionV1, +): string { + switch (action.kind) { + case 'task_authority_file': + return taskAuthorityContentDigest(action.fileName, action.targetContent); + case 'workflow_task_directory': + return workflowTaskDirectoryDigest(action); + case 'migration_task_directory': + return migrationTaskDirectoryDigest(action); + case 'project_authority_file': + return projectAuthorityContentDigest( + action.fileName, + action.targetContent, + ); + case 'migration_stage_file': + return migrationStageContentDigest(action.targetContent); + case 'v3_adapter_file': + return adapterFileContentDigest( + action.target, + action.targetContent, + ) as string; + case 'checkpoint': + return checkpointDigest(action.checkpoint); + case 'task_head_fence': + return digestCanonicalJson(action.fence); + case 'claim': + return digestCanonicalJson(action.claim); + case 'handoff': + return digestCanonicalJson(action.handoff); + } +} + +function parseActions(value: unknown): OperationRecoveryActionV1[] { + if (!Array.isArray(value)) { + throw new Error('operation recovery payload actions must be an array'); + } + return value.map((action) => { + assertRecord(action, 'operation recovery action'); + switch (action.kind) { + case 'task_authority_file': + return parseTaskAuthorityFileAction(action); + case 'workflow_task_directory': + return parseWorkflowTaskDirectoryAction(action); + case 'migration_task_directory': + return parseMigrationTaskDirectoryAction(action); + case 'project_authority_file': + return parseProjectAuthorityFileAction(action); + case 'migration_stage_file': + return parseMigrationStageFileAction(action); + case 'v3_adapter_file': + return parseV3AdapterFileAction(action); + case 'checkpoint': + return parseCheckpointAction(action); + case 'task_head_fence': + return parseTaskHeadFenceAction(action); + case 'claim': + return parseClaimAction(action); + case 'handoff': + return parseHandoffAction(action); + default: + throw new Error('operation recovery action kind is invalid'); + } + }); +} + +function parseWorkflowTaskDirectoryAction( + value: Record, +): WorkflowTaskDirectoryRecoveryAction { + assertKnownKeys( + value, + ['kind', 'stepId', 'taskRef', 'files', 'beforeDigest'], + 'operation recovery workflow directory action', + ); + if (value.kind !== 'workflow_task_directory' || value.beforeDigest !== null) { + throw new Error('operation recovery workflow directory action is invalid'); + } + if ( + !Array.isArray(value.files) || + (value.files.length !== 4 && value.files.length !== 5) + ) { + throw new Error('operation recovery workflow directory files are invalid'); + } + const files = value.files.map((file) => { + assertRecord(file, 'operation recovery workflow directory file'); + assertKnownKeys( + file, + ['fileName', 'content'], + 'operation recovery workflow directory file', + ); + const fileName = parseTaskAuthorityFileName(file.fileName); + const content = parseText(file.content, 'workflow directory file content'); + taskAuthorityContentDigest(fileName, content); + return { fileName, content }; + }); + const required = new Set([ + 'metadata.json', + 'requirements.json', + 'review-ledger.json', + 'verification-ledger.json', + ]); + const allowed = new Set([...required, 'plan.md']); + if ( + new Set(files.map((file) => file.fileName)).size !== files.length || + files.some((file) => !allowed.has(file.fileName)) || + [...required].some( + (fileName) => !files.some((file) => file.fileName === fileName), + ) + ) { + throw new Error('operation recovery workflow directory files are invalid'); + } + return { + kind: 'workflow_task_directory', + stepId: parseStepId(value.stepId), + taskRef: parseTaskRefValue(value.taskRef), + files, + beforeDigest: null, + }; +} + +function parseMigrationTaskDirectoryAction( + value: Record, +): MigrationTaskDirectoryRecoveryAction { + assertKnownKeys( + value, + ['kind', 'stepId', 'taskRef', 'files', 'reports', 'beforeDigest'], + 'operation recovery migration directory action', + ); + if ( + value.kind !== 'migration_task_directory' || + value.beforeDigest !== null + ) { + throw new Error('operation recovery migration directory action is invalid'); + } + const files = parseTaskDirectoryFiles( + value.files, + 'operation recovery migration directory file', + ); + if (!Array.isArray(value.reports)) { + throw new Error( + 'operation recovery migration directory reports are invalid', + ); + } + const reports = value.reports.map< + MigrationTaskDirectoryRecoveryAction['reports'][number] + >((report) => { + assertRecord(report, 'operation recovery migration directory report'); + assertKnownKeys( + report, + ['kind', 'artifactId', 'content'], + 'operation recovery migration directory report', + ); + const kind = report.kind; + if (kind !== 'review_report' && kind !== 'evidence_summary') { + throw new Error( + 'operation recovery migration directory report is invalid', + ); + } + assertUlid( + report.artifactId, + 'operation recovery migration report artifactId', + ); + return { + kind, + artifactId: report.artifactId, + content: parseText(report.content, 'migration directory report content'), + }; + }); + if ( + new Set(reports.map((report) => `${report.kind}:${report.artifactId}`)) + .size !== reports.length + ) { + throw new Error( + 'operation recovery migration directory reports are invalid', + ); + } + const taskRef = parseTaskRefValue(value.taskRef); + assertMigrationDirectoryTaskConsistency(taskRef, files, reports); + return { + kind: 'migration_task_directory', + stepId: parseStepId(value.stepId), + taskRef, + files, + reports, + beforeDigest: null, + }; +} + +function assertMigrationDirectoryTaskConsistency( + taskRef: TaskRef, + files: Array<{ fileName: TaskAuthorityFileName; content: string }>, + reports: MigrationTaskDirectoryRecoveryAction['reports'], +): void { + const byName = new Map(files.map((file) => [file.fileName, file.content])); + const metadata = parseWorkflowMetadata( + JSON.parse(byName.get('metadata.json') as string), + ); + const requirements = parseRequirementsLedger( + JSON.parse(byName.get('requirements.json') as string), + ); + const review = parseReviewLedger( + JSON.parse(byName.get('review-ledger.json') as string), + ); + const verification = parseVerificationLedger( + JSON.parse(byName.get('verification-ledger.json') as string), + ); + if ( + !sameTaskRef(metadata.taskRef, taskRef) || + !sameTaskRef(requirements.taskRef, taskRef) || + !sameTaskRef(review.taskRef, taskRef) || + !sameTaskRef(verification.taskRef, taskRef) + ) { + throw new Error('operation recovery migration directory taskRef mismatch'); + } + const expected = new Set(); + for (const domain of review.domains) { + if (domain.reportRef?.artifactId !== undefined) { + expected.add(`review_report:${domain.reportRef.artifactId}`); + } + } + for (const check of verification.checks) { + for (const component of [check.automated, check.manual]) { + if (component?.artifactRef?.artifactId !== undefined) { + expected.add(`evidence_summary:${component.artifactRef.artifactId}`); + } + } + } + const actual = new Set( + reports.map((report) => `${report.kind}:${report.artifactId}`), + ); + if ( + actual.size !== expected.size || + [...actual].some((value) => !expected.has(value)) + ) { + throw new Error( + 'operation recovery migration reports do not match ledgers', + ); + } +} + +function parseTaskDirectoryFiles( + value: unknown, + label: string, +): Array<{ fileName: TaskAuthorityFileName; content: string }> { + if (!Array.isArray(value) || (value.length !== 4 && value.length !== 5)) { + throw new Error('operation recovery workflow directory files are invalid'); + } + const files = value.map((file) => { + assertRecord(file, label); + assertKnownKeys(file, ['fileName', 'content'], label); + const fileName = parseTaskAuthorityFileName(file.fileName); + const content = parseText(file.content, 'workflow directory file content'); + taskAuthorityContentDigest(fileName, content); + return { fileName, content }; + }); + const required = new Set([ + 'metadata.json', + 'requirements.json', + 'review-ledger.json', + 'verification-ledger.json', + ]); + const allowed = new Set([...required, 'plan.md']); + if ( + new Set(files.map((file) => file.fileName)).size !== files.length || + files.some((file) => !allowed.has(file.fileName)) || + [...required].some( + (fileName) => !files.some((file) => file.fileName === fileName), + ) + ) { + throw new Error('operation recovery workflow directory files are invalid'); + } + return files; +} + +function parseProjectAuthorityFileAction( + value: Record, +): ProjectAuthorityFileRecoveryAction { + assertKnownKeys( + value, + [ + 'kind', + 'stepId', + 'fileName', + 'beforeDigest', + 'beforeContent', + 'targetContent', + ], + 'operation recovery project authority action', + ); + if (value.kind !== 'project_authority_file') { + throw new Error('operation recovery project authority action is invalid'); + } + const fileName = parseProjectAuthorityFileName(value.fileName); + const beforeContent = + value.beforeContent === null + ? null + : parseText(value.beforeContent, 'project beforeContent'); + const beforeDigest = parseDigestOrNull(value.beforeDigest, 'beforeDigest'); + if ( + beforeDigest !== + projectAuthorityContentDigestOrNull(fileName, beforeContent) + ) { + throw new Error( + 'operation recovery project authority before digest is invalid', + ); + } + const targetContent = parseText(value.targetContent, 'project targetContent'); + projectAuthorityContentDigest(fileName, targetContent); + return { + kind: 'project_authority_file', + stepId: parseStepId(value.stepId), + fileName, + beforeDigest, + beforeContent, + targetContent, + }; +} + +function parseMigrationStageFileAction( + value: Record, +): MigrationStageFileRecoveryAction { + assertKnownKeys( + value, + [ + 'kind', + 'stepId', + 'stageId', + 'beforeDigest', + 'beforeContent', + 'targetContent', + ], + 'operation recovery migration stage action', + ); + if (value.kind !== 'migration_stage_file') { + throw new Error('operation recovery migration stage action is invalid'); + } + assertUlid(value.stageId, 'operation recovery migration stageId'); + const beforeContent = + value.beforeContent === null + ? null + : parseText(value.beforeContent, 'migration stage beforeContent'); + const beforeDigest = parseDigestOrNull(value.beforeDigest, 'beforeDigest'); + if (beforeDigest !== migrationStageContentDigestOrNull(beforeContent)) { + throw new Error( + 'operation recovery migration stage before digest is invalid', + ); + } + const targetContent = parseText( + value.targetContent, + 'migration stage targetContent', + ); + migrationStageContentDigest(targetContent); + const target = JSON.parse(targetContent) as Record; + if (target.stageId !== value.stageId) { + throw new Error('operation recovery migration stage target ID mismatch'); + } + return { + kind: 'migration_stage_file', + stepId: parseStepId(value.stepId), + stageId: value.stageId, + beforeDigest, + beforeContent, + targetContent, + }; +} + +const V3_ADAPTER_TARGETS = new Set([ + 'claude-skill', + 'cursor-rule', + 'agents', + 'copilot-instructions', +]); + +function parseV3AdapterFileAction( + value: Record, +): V3AdapterFileRecoveryAction { + assertKnownKeys( + value, + [ + 'kind', + 'stepId', + 'target', + 'beforeDigest', + 'beforeContent', + 'targetContent', + ], + 'operation recovery V3 adapter action', + ); + if ( + value.kind !== 'v3_adapter_file' || + !V3_ADAPTER_TARGETS.has(value.target as V3AdapterFileTarget) + ) { + throw new Error('operation recovery V3 adapter action is invalid'); + } + const target = value.target as V3AdapterFileTarget; + const beforeContent = + value.beforeContent === null + ? null + : parseText(value.beforeContent, 'V3 adapter beforeContent'); + const beforeDigest = parseDigestOrNull(value.beforeDigest, 'beforeDigest'); + if (beforeDigest !== adapterFileContentDigest(target, beforeContent)) { + throw new Error('operation recovery V3 adapter before digest is invalid'); + } + const targetContent = parseText( + value.targetContent, + 'V3 adapter targetContent', + ); + if (!targetContent.trim()) { + throw new Error('operation recovery V3 adapter target content is invalid'); + } + adapterFileContentDigest(target, targetContent); + return { + kind: 'v3_adapter_file', + stepId: parseStepId(value.stepId), + target, + beforeDigest, + beforeContent, + targetContent, + }; +} + +function parseTaskAuthorityFileAction( + value: Record, +): TaskAuthorityFileRecoveryAction { + assertKnownKeys( + value, + ['kind', 'stepId', 'taskRef', 'fileName', 'beforeDigest', 'targetContent'], + 'operation recovery task authority action', + ); + if (value.kind !== 'task_authority_file') { + throw new Error('operation recovery task authority action kind is invalid'); + } + const fileName = parseTaskAuthorityFileName(value.fileName); + const targetContent = parseText(value.targetContent, 'targetContent'); + taskAuthorityContentDigest(fileName, targetContent); + return { + kind: 'task_authority_file', + stepId: parseStepId(value.stepId), + taskRef: parseTaskRefValue(value.taskRef), + fileName, + beforeDigest: parseDigestOrNull(value.beforeDigest, 'beforeDigest'), + targetContent, + }; +} + +function parseCheckpointAction( + value: Record, +): CheckpointRecoveryAction { + assertKnownKeys( + value, + ['kind', 'stepId', 'checkpoint', 'beforeDigest'], + 'operation recovery checkpoint action', + ); + if (value.kind !== 'checkpoint') { + throw new Error('operation recovery checkpoint action kind is invalid'); + } + return { + kind: 'checkpoint', + stepId: parseStepId(value.stepId), + checkpoint: parseCheckpoint(value.checkpoint), + beforeDigest: parseDigestOrNull(value.beforeDigest, 'beforeDigest'), + }; +} + +function parseTaskHeadFenceAction( + value: Record, +): TaskHeadFenceRecoveryAction { + assertKnownKeys( + value, + ['kind', 'stepId', 'fence', 'beforeDigest'], + 'operation recovery task-head action', + ); + if (value.kind !== 'task_head_fence') { + throw new Error('operation recovery task-head action kind is invalid'); + } + return { + kind: 'task_head_fence', + stepId: parseStepId(value.stepId), + fence: parseTaskHeadFence(value.fence), + beforeDigest: parseDigestOrNull(value.beforeDigest, 'beforeDigest'), + }; +} + +function parseClaimAction(value: Record): ClaimRecoveryAction { + assertKnownKeys( + value, + ['kind', 'stepId', 'claim', 'beforeDigest'], + 'operation recovery claim action', + ); + if (value.kind !== 'claim') { + throw new Error('operation recovery claim action kind is invalid'); + } + return { + kind: 'claim', + stepId: parseStepId(value.stepId), + claim: parseClaim(value.claim), + beforeDigest: parseDigestOrNull(value.beforeDigest, 'beforeDigest'), + }; +} + +function parseHandoffAction( + value: Record, +): HandoffRecoveryAction { + assertKnownKeys( + value, + ['kind', 'stepId', 'handoff', 'beforeDigest'], + 'operation recovery handoff action', + ); + if (value.kind !== 'handoff') { + throw new Error('operation recovery handoff action kind is invalid'); + } + return { + kind: 'handoff', + stepId: parseStepId(value.stepId), + handoff: parseHandoff(value.handoff), + beforeDigest: parseDigestOrNull(value.beforeDigest, 'beforeDigest'), + }; +} + +function parseTaskAuthorityFileName(value: unknown): TaskAuthorityFileName { + if ( + typeof value !== 'string' || + !TASK_AUTHORITY_FILE_NAMES.includes(value as TaskAuthorityFileName) + ) { + throw new Error('operation recovery task authority fileName is invalid'); + } + return value as TaskAuthorityFileName; +} + +function parseProjectAuthorityFileName( + value: unknown, +): ProjectAuthorityFileName { + if ( + typeof value !== 'string' || + !PROJECT_AUTHORITY_FILE_NAMES.includes(value as ProjectAuthorityFileName) + ) { + throw new Error('operation recovery project authority fileName is invalid'); + } + return value as ProjectAuthorityFileName; +} + +function parseStepIds(value: unknown, label: string): string[] { + if (!Array.isArray(value)) { + throw new Error(`operation recovery payload ${label} must be an array`); + } + const ids = value.map((item) => parseStepId(item)); + if (new Set(ids).size !== ids.length) { + throw new Error(`operation recovery payload ${label} must not repeat`); + } + return ids; +} + +function parseStepId(value: unknown): string { + if (typeof value !== 'string' || !STEP_ID_PATTERN.test(value)) { + throw new Error('operation recovery stepId is invalid'); + } + return value; +} + +function parseText(value: unknown, label: string): string { + if (typeof value !== 'string' || value.includes('\0')) { + throw new Error(`operation recovery ${label} is invalid`); + } + return value; +} + +function parseDigestOrNull(value: unknown, label: string): string | null { + if (value === null) return null; + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`operation recovery ${label} is invalid`); + } + return value; +} + +export function workflowTaskDirectoryDigest( + action: WorkflowTaskDirectoryRecoveryAction, +): string { + return digestCanonicalJson({ + taskRef: action.taskRef, + files: [...action.files] + .sort((left, right) => + Buffer.from(left.fileName, 'utf8').compare( + Buffer.from(right.fileName, 'utf8'), + ), + ) + .map((file) => ({ + fileName: file.fileName, + digest: taskAuthorityContentDigest(file.fileName, file.content), + })), + }); +} + +export function migrationTaskDirectoryDigest( + action: MigrationTaskDirectoryRecoveryAction, +): string { + return digestCanonicalJson({ + taskRef: action.taskRef, + files: [...action.files] + .sort((left, right) => + Buffer.from(left.fileName, 'utf8').compare( + Buffer.from(right.fileName, 'utf8'), + ), + ) + .map((file) => ({ + fileName: file.fileName, + digest: taskAuthorityContentDigest(file.fileName, file.content), + })), + reports: [...action.reports] + .sort((left, right) => + Buffer.from(`${left.kind}:${left.artifactId}`, 'utf8').compare( + Buffer.from(`${right.kind}:${right.artifactId}`, 'utf8'), + ), + ) + .map((report) => ({ + kind: report.kind, + artifactId: report.artifactId, + digest: digestCanonicalJson({ content: report.content }), + })), + }); +} diff --git a/src/runtime/operation-recovery-store.ts b/src/runtime/operation-recovery-store.ts new file mode 100644 index 0000000..f90ace1 --- /dev/null +++ b/src/runtime/operation-recovery-store.ts @@ -0,0 +1,106 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { assertUlid } from '../context/ids.js'; +import { + type EntityHomeStore, + operationDirectory, +} from './entity-home-store.js'; +import { + type OperationRecoveryPayloadV1, + operationRecoveryPayloadDigest, + parseOperationRecoveryPayload, +} from './operation-recovery-payload.js'; + +/** Recovery targets are separate from journal files so journal enumeration remains strict. */ +export function operationRecoveryDirectory(store: EntityHomeStore): string { + return path.join(operationDirectory(store), 'recovery'); +} + +export function operationRecoveryPayloadPath( + store: EntityHomeStore, + operationId: string, +): string { + assertUlid(operationId, 'operation recovery payload operationId'); + return path.join(operationRecoveryDirectory(store), `${operationId}.json`); +} + +/** + * Persists recovery targets before the prepared journal is made durable. A + * retry may observe the same target bundle, never a substitute for it. + */ +export async function writeOperationRecoveryPayload( + store: EntityHomeStore, + value: OperationRecoveryPayloadV1, +): Promise { + const payload = parseOperationRecoveryPayload(value); + if (payload.primaryStoreId !== store.storeId) { + throw new Error( + 'operation recovery payload primaryStoreId does not match its home store', + ); + } + const directory = operationRecoveryDirectory(store); + const target = operationRecoveryPayloadPath(store, payload.operationId); + await mkdir(directory, { recursive: true }); + try { + await writeFile(target, serialize(payload), { + encoding: 'utf8', + flag: 'wx', + }); + return payload; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readOperationRecoveryPayload( + store, + payload.operationId, + ); + if ( + existing !== null && + operationRecoveryPayloadDigest(existing) === + operationRecoveryPayloadDigest(payload) + ) { + return existing; + } + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_CONFLICT'); + } +} + +export async function readOperationRecoveryPayload( + store: EntityHomeStore, + operationId: string, +): Promise { + try { + const raw = await readFile( + operationRecoveryPayloadPath(store, operationId), + 'utf8', + ); + return parseOperationRecoveryPayload(JSON.parse(raw)); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_OPERATION_RECOVERY_PAYLOAD_CORRUPT'); + } + throw error; + } +} + +function serialize(payload: OperationRecoveryPayloadV1): string { + return `${JSON.stringify(payload, null, 2)}\n`; +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/operation-reservation.ts b/src/runtime/operation-reservation.ts new file mode 100644 index 0000000..981e6ef --- /dev/null +++ b/src/runtime/operation-reservation.ts @@ -0,0 +1,245 @@ +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + digestCanonicalJson, + sortUtf8StringSet, +} from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { + type EntityHomeStore, + reservationDirectory, +} from './entity-home-store.js'; +import { + type OperationJournalV1, + assertOperationReservationTopology, + operationReservationJournalDigest, +} from './operation-journal.js'; + +export interface OperationReservationV1 { + schemaVersion: 1; + operationId: Ulid; + primaryStoreId: string; + entityKeys: string[]; + journalDigest: string; + createdAt: string; +} + +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; +const ENTITY_KEY_PATTERN = /^[a-z][a-z0-9_-]*:[^\0/\\]+$/; + +export function parseOperationReservation( + value: unknown, +): OperationReservationV1 { + assertRecord(value, 'operation reservation'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'primaryStoreId', + 'entityKeys', + 'journalDigest', + 'createdAt', + ], + 'operation reservation', + ); + if (value.schemaVersion !== 1) { + throw new Error('operation reservation schemaVersion must be 1'); + } + assertUlid(value.operationId, 'operation reservation operationId'); + return { + schemaVersion: 1, + operationId: value.operationId, + primaryStoreId: parseStoreId( + value.primaryStoreId, + 'operation reservation primaryStoreId', + ), + entityKeys: parseEntityKeySet( + value.entityKeys, + 'operation reservation entityKeys', + ), + journalDigest: parseDigest( + value.journalDigest, + 'operation reservation journalDigest', + ), + createdAt: parseTimestamp( + value.createdAt, + 'operation reservation createdAt', + ), + }; +} + +export function createOperationReservation( + journal: OperationJournalV1, + secondaryStoreId: string, + createdAt: string, +): OperationReservationV1 { + if (journal.state !== 'prepared') { + throw new Error( + 'secondary reservations can only be created from a prepared journal', + ); + } + assertOperationReservationTopology(journal); + const reservation = journal.secondaryReservations.find( + (item) => item.storeId === secondaryStoreId, + ); + if (reservation === undefined) { + throw new Error('secondary store is not reserved by the primary journal'); + } + return { + schemaVersion: 1, + operationId: journal.operationId, + primaryStoreId: journal.primaryStoreId, + entityKeys: reservation.entityKeys, + journalDigest: operationReservationJournalDigest(journal), + createdAt: parseTimestamp(createdAt, 'operation reservation createdAt'), + }; +} + +export async function writeOperationReservation( + store: EntityHomeStore, + reservation: OperationReservationV1, +): Promise { + if (reservation.primaryStoreId === store.storeId) { + throw new Error( + 'primary store must not persist its own secondary reservation', + ); + } + const directory = reservationDirectory(store); + const target = reservationPath(store, reservation.operationId); + await mkdir(directory, { recursive: true }); + try { + await writeFile(target, serialize(reservation), { + encoding: 'utf8', + flag: 'wx', + }); + return reservation; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readOperationReservation( + store, + reservation.operationId, + ); + if ( + existing !== null && + digestCanonicalJson(existing) === digestCanonicalJson(reservation) + ) { + return existing; + } + throw new Error('MANCODE_OPERATION_RESERVATION_CONFLICT'); + } +} + +export async function readOperationReservation( + store: EntityHomeStore, + operationId: string, +): Promise { + try { + assertUlid(operationId, 'operation reservation operationId'); + const raw = await readFile(reservationPath(store, operationId), 'utf8'); + return parseOperationReservation(JSON.parse(raw)); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_OPERATION_RESERVATION_CORRUPT'); + } + throw error; + } +} + +/** + * Removes only the exact reservation owned by a terminal primary journal. + * Callers must already hold the primary operation's canonical entity locks. + */ +export async function removeOperationReservation( + store: EntityHomeStore, + operationId: string, + primaryStoreId: string, +): Promise { + const existing = await readOperationReservation(store, operationId); + if (existing === null) return; + if (existing.primaryStoreId !== primaryStoreId) { + throw new Error('MANCODE_OPERATION_RESERVATION_PRIMARY_MISMATCH'); + } + try { + await unlink(reservationPath(store, operationId)); + } catch (error) { + if (isNotFound(error)) return; + throw error; + } +} + +export function reservationPath( + store: EntityHomeStore, + operationId: string, +): string { + assertUlid(operationId, 'operation reservation operationId'); + return path.join(reservationDirectory(store), `${operationId}.json`); +} + +function parseEntityKeySet(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${label} must be a non-empty array`); + } + for (const entityKey of value) { + if (typeof entityKey !== 'string') { + throw new Error(`${label} must contain strings`); + } + assertEntityKey(entityKey, label); + } + const normalized = sortUtf8StringSet(value); + if (normalized.length !== value.length) { + throw new Error(`${label} must not contain duplicates`); + } + return normalized; +} + +function parseStoreId(value: unknown, label: string): string { + if (typeof value !== 'string' || !/^[a-z][a-z0-9_-]*:[^\0]+$/.test(value)) { + throw new Error(`${label} is invalid`); + } + return value; +} + +function assertEntityKey(value: string, label: string): void { + if (!ENTITY_KEY_PATTERN.test(value) || value.includes('..')) { + throw new Error(`${label} is invalid`); + } +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function serialize(reservation: OperationReservationV1): string { + return `${JSON.stringify(reservation, null, 2)}\n`; +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/operation-store.ts b/src/runtime/operation-store.ts new file mode 100644 index 0000000..710eb9d --- /dev/null +++ b/src/runtime/operation-store.ts @@ -0,0 +1,255 @@ +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { assertUlid } from '../context/ids.js'; +import { replaceFileAtomically } from './atomic-file.js'; +import { + type EntityHomeStore, + operationDirectory, +} from './entity-home-store.js'; +import { assertOperationAuthorizationAction } from './operation-definition.js'; +import { + type OperationJournalV1, + type OperationTransitionOptions, + assertOperationJournalTransition, + assertOperationReservationTopology, + operationJournalDigest, + parseOperationJournal, +} from './operation-journal.js'; +import { + type OperationReservationV1, + createOperationReservation, + writeOperationReservation, +} from './operation-reservation.js'; + +export interface PrepareOperationStoresInput { + primaryStore: EntityHomeStore; + journal: OperationJournalV1; + secondaryStores: EntityHomeStore[]; + now?: Date; +} + +export async function createPreparedOperationJournal( + store: EntityHomeStore, + journal: OperationJournalV1, +): Promise { + assertOperationAuthorizationAction(journal); + if (journal.state !== 'prepared') { + throw new Error('only a prepared operation journal may be created'); + } + if (journal.primaryStoreId !== store.storeId) { + throw new Error( + 'operation journal primaryStoreId does not match its home store', + ); + } + assertOperationReservationTopology(journal); + if (journal.type !== 'transport_migrate' && store.kind !== 'checkout_local') { + const migrationInProgress = ( + await listUnfinishedOperationJournals(store) + ).some((candidate) => candidate.type === 'transport_migrate'); + if (migrationInProgress) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_FROZEN'); + } + } + const directory = operationDirectory(store); + const target = operationJournalPath(store, journal.operationId); + await mkdir(directory, { recursive: true }); + try { + await writeFile(target, serialize(journal), { + encoding: 'utf8', + flag: 'wx', + }); + return journal; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readOperationJournal(store, journal.operationId); + if ( + existing !== null && + operationJournalDigest(existing) === operationJournalDigest(journal) + ) { + return existing; + } + throw new Error('MANCODE_OPERATION_JOURNAL_CONFLICT'); + } +} + +/** + * Durable preparation happens before any business entity write. Once this + * returns an error after creating the primary journal, callers must repair + * forward rather than assume no reservation exists. + */ +export async function prepareOperationStores( + input: PrepareOperationStoresInput, +): Promise { + const { primaryStore, journal } = input; + validateSecondaryStores(primaryStore, journal, input.secondaryStores); + await createPreparedOperationJournal(primaryStore, journal); + const createdAt = (input.now ?? new Date()).toISOString(); + const reservations: OperationReservationV1[] = []; + for (const secondaryStore of input.secondaryStores) { + const reservation = createOperationReservation( + journal, + secondaryStore.storeId, + createdAt, + ); + reservations.push( + await writeOperationReservation(secondaryStore, reservation), + ); + } + return reservations; +} + +export async function readOperationJournal( + store: EntityHomeStore, + operationId: string, +): Promise { + try { + assertUlid(operationId, 'operation journal operationId'); + const raw = await readFile( + operationJournalPath(store, operationId), + 'utf8', + ); + return parseOperationJournal(JSON.parse(raw)); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_OPERATION_JOURNAL_CORRUPT'); + } + throw error; + } +} + +export async function updateOperationJournal( + store: EntityHomeStore, + next: OperationJournalV1, + options: OperationTransitionOptions, +): Promise { + const previous = await readOperationJournal(store, next.operationId); + if (previous === null) throw new Error('MANCODE_OPERATION_JOURNAL_NOT_FOUND'); + if ( + previous.primaryStoreId !== store.storeId || + next.primaryStoreId !== store.storeId + ) { + throw new Error( + 'operation journal must be updated in its primary home store', + ); + } + assertOperationJournalTransition(previous, next, options); + await atomicWriteOperationJournal(store, next); + return next; +} + +export async function listUnfinishedOperationJournals( + store: EntityHomeStore, +): Promise { + let entries: string[]; + try { + entries = await readdir(operationDirectory(store)); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const journals: OperationJournalV1[] = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const operationId = entry.slice(0, -'.json'.length); + try { + assertUlid(operationId, 'operation journal filename'); + } catch { + throw new Error('MANCODE_OPERATION_JOURNAL_CORRUPT'); + } + const journal = await readOperationJournal(store, operationId); + if ( + journal !== null && + journal.state !== 'committed' && + journal.state !== 'aborted' + ) { + journals.push(journal); + } + } + return journals.sort((left, right) => + Buffer.from(left.operationId, 'utf8').compare( + Buffer.from(right.operationId, 'utf8'), + ), + ); +} + +export function operationJournalPath( + store: EntityHomeStore, + operationId: string, +): string { + assertUlid(operationId, 'operation journal operationId'); + return path.join(operationDirectory(store), `${operationId}.json`); +} + +async function atomicWriteOperationJournal( + store: EntityHomeStore, + journal: OperationJournalV1, +): Promise { + const target = operationJournalPath(store, journal.operationId); + const temporary = path.join( + operationDirectory(store), + `.${journal.operationId}.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, serialize(journal), { + encoding: 'utf8', + flag: 'wx', + }); + await replaceFileAtomically(temporary, target); +} + +function validateSecondaryStores( + primaryStore: EntityHomeStore, + journal: OperationJournalV1, + secondaryStores: EntityHomeStore[], +): void { + if (journal.primaryStoreId !== primaryStore.storeId) { + throw new Error( + 'operation journal primaryStoreId does not match primaryStore', + ); + } + const expectedStoreIds = new Set( + journal.secondaryReservations.map((reservation) => reservation.storeId), + ); + const suppliedStoreIds = new Set(); + for (const store of secondaryStores) { + if ( + store.storeId === primaryStore.storeId || + suppliedStoreIds.has(store.storeId) + ) { + throw new Error( + 'operation secondary stores must be unique and exclude primaryStore', + ); + } + suppliedStoreIds.add(store.storeId); + } + if ( + expectedStoreIds.size !== suppliedStoreIds.size || + [...expectedStoreIds].some((storeId) => !suppliedStoreIds.has(storeId)) + ) { + throw new Error( + 'operation secondary stores do not match the journal reservations', + ); + } +} + +function serialize(journal: OperationJournalV1): string { + return `${JSON.stringify(journal, null, 2)}\n`; +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/platform-spike-store.ts b/src/runtime/platform-spike-store.ts new file mode 100644 index 0000000..acc6126 --- /dev/null +++ b/src/runtime/platform-spike-store.ts @@ -0,0 +1,109 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { replaceFileAtomically } from './atomic-file.js'; +import { + type PlatformSessionSpikeV1, + SESSION_SPIKE_PLATFORMS, + type SessionSpikePlatform, + parsePlatformSessionSpike, +} from './platform-spike.js'; + +/** + * Session-spike evidence is local operational evidence. It is intentionally + * outside shared authority and contains outcomes only, never host keys. + */ +export function platformSessionSpikeDirectory(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'evidence', + 'platform-session', + ); +} + +export function platformSessionSpikePath( + projectRoot: string, + platform: SessionSpikePlatform, +): string { + assertPlatform(platform); + return path.join( + platformSessionSpikeDirectory(projectRoot), + `${platform}.json`, + ); +} + +export async function readPlatformSessionSpike( + projectRoot: string, + platform: SessionSpikePlatform, +): Promise { + try { + const raw = await readFile( + platformSessionSpikePath(projectRoot, platform), + 'utf8', + ); + return parsePlatformSessionSpike(JSON.parse(raw)); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_PLATFORM_SPIKE_CORRUPT'); + } + throw error; + } +} + +export async function listPlatformSessionSpikes( + projectRoot: string, +): Promise { + const spikes = await Promise.all( + SESSION_SPIKE_PLATFORMS.map((platform) => + readPlatformSessionSpike(projectRoot, platform), + ), + ); + return spikes.filter( + (spike): spike is PlatformSessionSpikeV1 => spike !== null, + ); +} + +export async function writePlatformSessionSpike( + projectRoot: string, + spike: PlatformSessionSpikeV1, +): Promise { + const parsed = parsePlatformSessionSpike(spike); + const directory = platformSessionSpikeDirectory(projectRoot); + await mkdir(directory, { recursive: true }); + const target = platformSessionSpikePath(projectRoot, parsed.platform); + const temporary = path.join( + directory, + `.${parsed.platform}.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, `${JSON.stringify(parsed, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + try { + await replaceFileAtomically(temporary, target); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } + return parsed; +} + +function assertPlatform(value: unknown): asserts value is SessionSpikePlatform { + if ( + typeof value !== 'string' || + !SESSION_SPIKE_PLATFORMS.includes(value as SessionSpikePlatform) + ) { + throw new Error('MANCODE_PLATFORM_SPIKE_PLATFORM_INVALID'); + } +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/platform-spike.ts b/src/runtime/platform-spike.ts new file mode 100644 index 0000000..01a3233 --- /dev/null +++ b/src/runtime/platform-spike.ts @@ -0,0 +1,359 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import type { HostIdentityCapability } from './session-identity.js'; + +const execFileAsync = promisify(execFile); + +export const SESSION_SPIKE_PLATFORMS = [ + 'claude-code', + 'codex', + 'cursor', + 'copilot', + 'zcode', +] as const; + +export type SessionSpikePlatform = (typeof SESSION_SPIKE_PLATFORMS)[number]; +export type HostSessionSource = 'hook_stdin' | 'environment' | 'api' | 'none'; +export type SpikeEvidenceStatus = + | 'proven' + | 'not_proven' + | 'not_tested' + | 'not_applicable'; +export type HookApprovalStatus = + | 'approved' + | 'unapproved' + | 'unknown' + | 'not_applicable'; + +/** + * Persistent evidence intentionally has no raw host session identifier. Raw + * identifiers are compared only while the spike runs and then discarded. + */ +export interface PlatformSessionSpikeV1 { + schemaVersion: 1; + platform: SessionSpikePlatform; + observedAt: string; + hostSessionSource: HostSessionSource; + hostSessionObserved: boolean; + distinctClientWindows: SpikeEvidenceStatus; + commandPropagation: SpikeEvidenceStatus; + subagentInheritance: SpikeEvidenceStatus; + hookApproval: HookApprovalStatus; +} + +export interface PlatformSessionSpikeInput { + platform: SessionSpikePlatform; + observedAt: string; + hostSessionSource: HostSessionSource; + /** Ephemeral only; never copied into PlatformSessionSpikeV1. */ + firstWindowHostSessionKey: string | null; + /** Ephemeral only; proves two same-client windows do not collide. */ + secondWindowHostSessionKey: string | null; + commandPropagation: SpikeEvidenceStatus; + subagentInheritance: SpikeEvidenceStatus; + hookApproval: HookApprovalStatus; +} + +export interface PlatformSessionCapability { + platform: SessionSpikePlatform; + hostIdentity: HostIdentityCapability; + hostSessionSource: HostSessionSource; + commandPropagation: SpikeEvidenceStatus; + subagentInheritance: SpikeEvidenceStatus; + hookApproval: HookApprovalStatus; + reason: string; +} + +export interface PlatformSpikeFreezeStatus { + missingPlatforms: SessionSpikePlatform[]; + explicitRequiredPlatforms: SessionSpikePlatform[]; + ready: boolean; +} + +export interface PlatformSessionSpikeProbeInput { + platform: SessionSpikePlatform; + hostSessionSource: Exclude; + /** Ephemeral host identity from the first same-client window. */ + firstWindowHostSessionKey: string; + /** Ephemeral host identity from a separately opened same-client window. */ + secondWindowHostSessionKey: string; + subagentInheritance: SpikeEvidenceStatus; + hookApproval: HookApprovalStatus; + now?: Date; +} + +export function createPlatformSessionSpike( + input: PlatformSessionSpikeInput, +): PlatformSessionSpikeV1 { + assertSessionSpikePlatform(input.platform); + assertTimestamp(input.observedAt, 'platform session spike observedAt'); + assertHostSessionSource(input.hostSessionSource); + assertSpikeStatus(input.commandPropagation, 'commandPropagation'); + assertSpikeStatus(input.subagentInheritance, 'subagentInheritance'); + assertHookApproval(input.hookApproval); + const first = normalizeEphemeralHostKey(input.firstWindowHostSessionKey); + const second = normalizeEphemeralHostKey(input.secondWindowHostSessionKey); + if (input.hostSessionSource === 'none' && first !== null) { + throw new Error( + 'platform session spike source none cannot carry a host key', + ); + } + if (input.hostSessionSource !== 'none' && first === null) { + throw new Error( + 'platform session spike host source requires an observed key', + ); + } + if (second !== null && first === null) { + throw new Error( + 'platform session spike second window requires a first window', + ); + } + if ( + input.hostSessionSource === 'none' && + input.commandPropagation === 'proven' + ) { + throw new Error( + 'platform session spike cannot prove propagation without a host key', + ); + } + return { + schemaVersion: 1, + platform: input.platform, + observedAt: input.observedAt, + hostSessionSource: input.hostSessionSource, + hostSessionObserved: first !== null, + distinctClientWindows: + second === null + ? 'not_tested' + : first === second + ? 'not_proven' + : 'proven', + commandPropagation: input.commandPropagation, + subagentInheritance: input.subagentInheritance, + hookApproval: input.hookApproval, + }; +} + +export function parsePlatformSessionSpike( + value: unknown, +): PlatformSessionSpikeV1 { + assertRecord(value, 'platform session spike'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'platform', + 'observedAt', + 'hostSessionSource', + 'hostSessionObserved', + 'distinctClientWindows', + 'commandPropagation', + 'subagentInheritance', + 'hookApproval', + ], + 'platform session spike', + ); + if (value.schemaVersion !== 1) { + throw new Error('platform session spike schemaVersion must be 1'); + } + assertSessionSpikePlatform(value.platform); + assertTimestamp(value.observedAt, 'platform session spike observedAt'); + assertHostSessionSource(value.hostSessionSource); + if (typeof value.hostSessionObserved !== 'boolean') { + throw new Error('platform session spike hostSessionObserved is invalid'); + } + assertSpikeStatus(value.distinctClientWindows, 'distinctClientWindows'); + assertSpikeStatus(value.commandPropagation, 'commandPropagation'); + assertSpikeStatus(value.subagentInheritance, 'subagentInheritance'); + assertHookApproval(value.hookApproval); + if (value.hostSessionSource === 'none' && value.hostSessionObserved) { + throw new Error( + 'platform session spike source none cannot observe a host key', + ); + } + return { + schemaVersion: 1, + platform: value.platform, + observedAt: value.observedAt, + hostSessionSource: value.hostSessionSource, + hostSessionObserved: value.hostSessionObserved, + distinctClientWindows: value.distinctClientWindows, + commandPropagation: value.commandPropagation, + subagentInheritance: value.subagentInheritance, + hookApproval: value.hookApproval, + }; +} + +/** Missing evidence is deliberately indistinguishable from insufficient evidence. */ +export function evaluatePlatformSessionCapability( + spike: PlatformSessionSpikeV1, +): PlatformSessionCapability { + const parsed = parsePlatformSessionSpike(spike); + const hostSourceApproved = + parsed.hostSessionSource !== 'hook_stdin' || + parsed.hookApproval === 'approved'; + const hostVerified = + parsed.hostSessionObserved && + parsed.hostSessionSource !== 'none' && + parsed.distinctClientWindows === 'proven' && + parsed.commandPropagation === 'proven' && + hostSourceApproved; + return { + platform: parsed.platform, + hostIdentity: hostVerified ? 'host_verified' : 'explicit_required', + hostSessionSource: parsed.hostSessionSource, + commandPropagation: parsed.commandPropagation, + subagentInheritance: parsed.subagentInheritance, + hookApproval: parsed.hookApproval, + reason: hostVerified + ? 'host identity, distinct windows, and command propagation are proven' + : 'host identity is not fully proven; require --session or MANCODE_SESSION_ID', + }; +} + +export function platformSpikeFreezeStatus( + spikes: readonly PlatformSessionSpikeV1[], +): PlatformSpikeFreezeStatus { + const byPlatform = new Map(); + for (const spike of spikes) { + const parsed = parsePlatformSessionSpike(spike); + if (byPlatform.has(parsed.platform)) { + throw new Error( + `platform session spike is duplicated: ${parsed.platform}`, + ); + } + byPlatform.set(parsed.platform, parsed); + } + const missingPlatforms = SESSION_SPIKE_PLATFORMS.filter( + (platform) => !byPlatform.has(platform), + ); + const explicitRequiredPlatforms = SESSION_SPIKE_PLATFORMS.filter( + (platform) => { + const spike = byPlatform.get(platform); + return ( + spike === undefined || + evaluatePlatformSessionCapability(spike).hostIdentity === + 'explicit_required' + ); + }, + ); + return { + missingPlatforms, + explicitRequiredPlatforms, + ready: + missingPlatforms.length === 0 && explicitRequiredPlatforms.length === 0, + }; +} + +/** + * Runs the portable part of a real-host spike. Callers must obtain the two + * host keys from their adapter environment and must never persist either key. + */ +export async function probePlatformSessionSpike( + input: PlatformSessionSpikeProbeInput, +): Promise { + const commandPropagation = await probeSessionEnvironmentPropagation( + input.firstWindowHostSessionKey, + ); + return createPlatformSessionSpike({ + platform: input.platform, + observedAt: (input.now ?? new Date()).toISOString(), + hostSessionSource: input.hostSessionSource, + firstWindowHostSessionKey: input.firstWindowHostSessionKey, + secondWindowHostSessionKey: input.secondWindowHostSessionKey, + commandPropagation, + subagentInheritance: input.subagentInheritance, + hookApproval: input.hookApproval, + }); +} + +/** + * Executes the command-propagation leg without a shell. Platform adapters + * should call this after obtaining a real host ID, and record only the result. + */ +export async function probeSessionEnvironmentPropagation( + hostSessionKey: string, +): Promise { + const expected = normalizeEphemeralHostKey(hostSessionKey); + if (expected === null) { + throw new Error('platform session spike host key is invalid'); + } + try { + const { stdout } = await execFileAsync(process.execPath, [ + '-e', + 'process.stdout.write((process.env.MANCODE_SPIKE_HOST_SESSION_KEY ?? "").trim())', + ]); + return stdout === expected ? 'proven' : 'not_proven'; + } catch { + return 'not_proven'; + } +} + +function normalizeEphemeralHostKey(value: string | null): string | null { + if (value === null) return null; + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error('platform session spike host key is invalid'); + } + return value.trim(); +} + +function assertSessionSpikePlatform( + value: unknown, +): asserts value is SessionSpikePlatform { + if ( + typeof value !== 'string' || + !SESSION_SPIKE_PLATFORMS.includes(value as SessionSpikePlatform) + ) { + throw new Error('platform session spike platform is invalid'); + } +} + +function assertHostSessionSource( + value: unknown, +): asserts value is HostSessionSource { + if ( + value !== 'hook_stdin' && + value !== 'environment' && + value !== 'api' && + value !== 'none' + ) { + throw new Error('platform session spike hostSessionSource is invalid'); + } +} + +function assertSpikeStatus( + value: unknown, + label: string, +): asserts value is SpikeEvidenceStatus { + if ( + value !== 'proven' && + value !== 'not_proven' && + value !== 'not_tested' && + value !== 'not_applicable' + ) { + throw new Error(`platform session spike ${label} is invalid`); + } +} + +function assertHookApproval( + value: unknown, +): asserts value is HookApprovalStatus { + if ( + value !== 'approved' && + value !== 'unapproved' && + value !== 'unknown' && + value !== 'not_applicable' + ) { + throw new Error('platform session spike hookApproval is invalid'); + } +} + +function assertTimestamp( + value: unknown, + label: string, +): asserts value is string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} is invalid`); + } +} diff --git a/src/runtime/project-runtime.ts b/src/runtime/project-runtime.ts new file mode 100644 index 0000000..0aaace8 --- /dev/null +++ b/src/runtime/project-runtime.ts @@ -0,0 +1,705 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { + type ProjectConfigV1, + parseProjectConfig, + projectConfigIdentityDigest, +} from '../team/policy.js'; +import { + type EntityHomeStoreContext, + resolveCoordinationEntityHomeStore, +} from './entity-home-store.js'; +import { + type CheckoutBindingV1, + type WorkspaceBindingV1, + assertCheckoutBindingMatchesWorkspace, + assertWorkspaceBindingCompatible, + assertWorkspaceBindingMatchesConfig, + parseCheckoutBinding, + parseWorkspaceBinding, +} from './workspace-binding.js'; + +const execFile = promisify(execFileCallback); +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +/** A durable, local-only identity for the checkout hosting this .mancode root. */ +export interface RuntimeCheckoutRecordV1 { + schemaVersion: 1; + workspaceId: Ulid; + checkoutId: Ulid; + repositoryBindingId: Ulid | null; + registeredAt: string; + lastSeenAt: string; +} + +/** Shared through Git's common directory, never through task contents. */ +export interface RepositoryRuntimeBindingV1 { + schemaVersion: 1; + repositoryBindingId: Ulid; + commonDirHash: string; + createdAt: string; +} + +export interface ProjectRuntimeContext { + projectRoot: string; + workspaceId: Ulid; + checkoutId: Ulid; + repositoryBindingId: Ulid | null; + gitCommonDir: string | null; + entityHomeStoreContext: EntityHomeStoreContext; +} + +interface GitCheckoutInfo { + commonDir: string; + gitDir: string; + worktreeRoot: string; +} + +/** + * Registers exactly the runtime identities that V3 needs for local locks and + * common-dir coordination. It creates only .mancode/local data and common-dir + * binding records; it does not create a workflow or touch legacy authority. + */ +export async function ensureProjectRuntimeContext( + projectRoot: string, + now: Date = new Date(), +): Promise { + const root = path.resolve(projectRoot); + const config = await readProjectConfig(root); + const git = await inspectGitCheckout(root); + const timestamp = now.toISOString(); + if (git === null) { + const checkout = await ensureRuntimeCheckoutRecord(root, { + workspaceId: config.workspaceId, + repositoryBindingId: null, + now: timestamp, + }); + return runtimeContext( + root, + config.workspaceId, + checkout.checkoutId, + null, + null, + ); + } + + const repository = await ensureRepositoryRuntimeBinding( + git.commonDir, + timestamp, + ); + const workspace = await ensureWorkspaceBinding( + root, + git, + repository.repositoryBindingId, + config, + timestamp, + ); + const checkout = await ensureRuntimeCheckoutRecord(root, { + workspaceId: config.workspaceId, + repositoryBindingId: repository.repositoryBindingId, + now: timestamp, + }); + await ensureCheckoutBinding(root, git, workspace, checkout, timestamp); + return runtimeContext( + root, + config.workspaceId, + checkout.checkoutId, + repository.repositoryBindingId, + git.commonDir, + ); +} + +/** + * Reads an already-registered runtime binding. Read-only callers must use + * this rather than silently bootstrapping a checkout identity. + */ +export async function readProjectRuntimeContext( + projectRoot: string, +): Promise { + const root = path.resolve(projectRoot); + const config = await readProjectConfig(root); + const checkout = await readRuntimeCheckoutRecord(root); + if (checkout === null || checkout.workspaceId !== config.workspaceId) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + const git = await inspectGitCheckout(root); + if (git === null) { + if (checkout.repositoryBindingId !== null) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + return runtimeContext( + root, + config.workspaceId, + checkout.checkoutId, + null, + null, + ); + } + if (checkout.repositoryBindingId === null) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + const repository = await readRepositoryRuntimeBinding(git.commonDir); + if ( + repository === null || + repository.repositoryBindingId !== checkout.repositoryBindingId + ) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + const workspace = await readWorkspaceBinding( + git.commonDir, + config.workspaceId, + ); + if (workspace === null) throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + assertWorkspaceBindingMatchesConfig(workspace, config); + assertCheckoutBindingMatchesWorkspace( + await requireCheckoutBinding(root), + workspace, + ); + return runtimeContext( + root, + config.workspaceId, + checkout.checkoutId, + checkout.repositoryBindingId, + git.commonDir, + ); +} + +/** Returns a checkout code head only when Git can prove one is available. */ +export async function readCheckoutCodeHead( + projectRoot: string, +): Promise { + const root = path.resolve(projectRoot); + const output = await runGit(root, ['rev-parse', 'HEAD']); + return output === null || !output.trim() ? null : output.trim(); +} + +/** Returns null for non-Git checkouts and detached HEADs. */ +export async function readCheckoutBranch( + projectRoot: string, +): Promise { + const root = path.resolve(projectRoot); + const output = await runGit(root, ['symbolic-ref', '--short', '-q', 'HEAD']); + return output === null || !output.trim() ? null : output.trim(); +} + +export function runtimeCheckoutRecordPath(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'runtime', + 'checkout.json', + ); +} + +export function runtimeCheckoutBindingPath(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'runtime', + 'checkout-binding.json', + ); +} + +export function repositoryRuntimeBindingPath(gitCommonDir: string): string { + return path.join(path.resolve(gitCommonDir), 'mancode', 'repository.json'); +} + +export function workspaceRuntimeBindingPath( + gitCommonDir: string, + workspaceId: Ulid, +): string { + assertUlid(workspaceId, 'workspace runtime binding workspaceId'); + return path.join( + path.resolve(gitCommonDir), + 'mancode', + 'workspaces', + workspaceId, + 'binding.json', + ); +} + +export function parseRuntimeCheckoutRecord( + value: unknown, +): RuntimeCheckoutRecordV1 { + assertRecord(value, 'runtime checkout record'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'workspaceId', + 'checkoutId', + 'repositoryBindingId', + 'registeredAt', + 'lastSeenAt', + ], + 'runtime checkout record', + ); + if (value.schemaVersion !== 1) { + throw new Error('runtime checkout record schemaVersion must be 1'); + } + assertUlid(value.workspaceId, 'runtime checkout record workspaceId'); + assertUlid(value.checkoutId, 'runtime checkout record checkoutId'); + if (value.repositoryBindingId !== null) { + assertUlid( + value.repositoryBindingId, + 'runtime checkout record repositoryBindingId', + ); + } + return { + schemaVersion: 1, + workspaceId: value.workspaceId, + checkoutId: value.checkoutId, + repositoryBindingId: value.repositoryBindingId, + registeredAt: parseTimestamp( + value.registeredAt, + 'runtime checkout record registeredAt', + ), + lastSeenAt: parseTimestamp( + value.lastSeenAt, + 'runtime checkout record lastSeenAt', + ), + }; +} + +export function parseRepositoryRuntimeBinding( + value: unknown, +): RepositoryRuntimeBindingV1 { + assertRecord(value, 'repository runtime binding'); + assertKnownKeys( + value, + ['schemaVersion', 'repositoryBindingId', 'commonDirHash', 'createdAt'], + 'repository runtime binding', + ); + if (value.schemaVersion !== 1) { + throw new Error('repository runtime binding schemaVersion must be 1'); + } + assertUlid(value.repositoryBindingId, 'repository runtime binding ID'); + return { + schemaVersion: 1, + repositoryBindingId: value.repositoryBindingId, + commonDirHash: parseDigest( + value.commonDirHash, + 'repository runtime binding commonDirHash', + ), + createdAt: parseTimestamp( + value.createdAt, + 'repository runtime binding createdAt', + ), + }; +} + +async function readProjectConfig( + projectRoot: string, +): Promise { + try { + return parseProjectConfig( + JSON.parse( + await readFile( + path.join(projectRoot, '.mancode', 'shared', 'config.json'), + 'utf8', + ), + ), + ); + } catch (error) { + if (isNotFound(error)) + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + if (error instanceof SyntaxError) { + throw new Error('MANCODE_CONTEXT_ENTITY_CORRUPT: shared/config.json'); + } + throw error; + } +} + +async function inspectGitCheckout( + projectRoot: string, +): Promise { + const [commonDirRaw, gitDirRaw, worktreeRootRaw] = await Promise.all([ + runGit(projectRoot, [ + 'rev-parse', + '--path-format=absolute', + '--git-common-dir', + ]), + runGit(projectRoot, ['rev-parse', '--path-format=absolute', '--git-dir']), + runGit(projectRoot, [ + 'rev-parse', + '--path-format=absolute', + '--show-toplevel', + ]), + ]); + if (commonDirRaw === null || gitDirRaw === null || worktreeRootRaw === null) { + return null; + } + const [commonDir, gitDir, worktreeRoot] = await Promise.all([ + realpath(resolveGitPath(projectRoot, commonDirRaw.trim())), + realpath(resolveGitPath(projectRoot, gitDirRaw.trim())), + realpath(resolveGitPath(projectRoot, worktreeRootRaw.trim())), + ]); + return { commonDir, gitDir, worktreeRoot }; +} + +async function ensureRepositoryRuntimeBinding( + gitCommonDir: string, + now: string, +): Promise { + const target = repositoryRuntimeBindingPath(gitCommonDir); + const existing = await readRepositoryRuntimeBinding(gitCommonDir); + const commonDirHash = digestPath(gitCommonDir); + if (existing !== null) { + if (existing.commonDirHash !== commonDirHash) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + return existing; + } + const candidate = parseRepositoryRuntimeBinding({ + schemaVersion: 1, + repositoryBindingId: createUlid(), + commonDirHash, + createdAt: now, + }); + return writeExclusiveOrRead( + target, + candidate, + parseRepositoryRuntimeBinding, + (stored, intended) => stored.commonDirHash === intended.commonDirHash, + 'MANCODE_WORKSPACE_BINDING_MISMATCH', + ); +} + +async function ensureWorkspaceBinding( + projectRoot: string, + git: GitCheckoutInfo, + repositoryBindingId: Ulid, + config: ProjectConfigV1, + now: string, +): Promise { + const projectPathFromWorktreeRoot = relativeProjectPath( + await realpath(git.worktreeRoot), + await realpath(projectRoot), + ); + const candidate = parseWorkspaceBinding({ + schemaVersion: 1, + workspaceId: config.workspaceId, + repositoryBindingId, + projectPathFromWorktreeRoot, + configSchemaVersion: config.schemaVersion, + configIdentityDigest: projectConfigIdentityDigest(config), + registeredAt: now, + }); + const target = workspaceRuntimeBindingPath(git.commonDir, config.workspaceId); + const existing = await readWorkspaceBinding( + git.commonDir, + config.workspaceId, + ); + if (existing !== null) { + assertWorkspaceBindingCompatible(existing, candidate); + assertWorkspaceBindingMatchesConfig(existing, config); + return existing; + } + return writeExclusiveOrRead( + target, + candidate, + parseWorkspaceBinding, + (stored, intended) => { + try { + assertWorkspaceBindingCompatible(stored, intended); + return true; + } catch { + return false; + } + }, + 'MANCODE_WORKSPACE_BINDING_MISMATCH', + ); +} + +async function ensureRuntimeCheckoutRecord( + projectRoot: string, + input: { + workspaceId: Ulid; + repositoryBindingId: Ulid | null; + now: string; + }, +): Promise { + const target = runtimeCheckoutRecordPath(projectRoot); + const existing = await readRuntimeCheckoutRecord(projectRoot); + if (existing !== null) { + if ( + existing.workspaceId !== input.workspaceId || + existing.repositoryBindingId !== input.repositoryBindingId + ) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + const updated = { ...existing, lastSeenAt: input.now }; + await writeAtomic(target, updated); + return updated; + } + const candidate = parseRuntimeCheckoutRecord({ + schemaVersion: 1, + workspaceId: input.workspaceId, + checkoutId: createUlid(), + repositoryBindingId: input.repositoryBindingId, + registeredAt: input.now, + lastSeenAt: input.now, + }); + return writeExclusiveOrRead( + target, + candidate, + parseRuntimeCheckoutRecord, + (stored, intended) => + stored.workspaceId === intended.workspaceId && + stored.repositoryBindingId === intended.repositoryBindingId, + 'MANCODE_WORKSPACE_BINDING_MISMATCH', + ); +} + +async function ensureCheckoutBinding( + projectRoot: string, + git: GitCheckoutInfo, + workspace: WorkspaceBindingV1, + checkout: RuntimeCheckoutRecordV1, + now: string, +): Promise { + if (checkout.repositoryBindingId === null) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + const candidate = parseCheckoutBinding({ + schemaVersion: 1, + workspaceId: workspace.workspaceId, + repositoryBindingId: workspace.repositoryBindingId, + checkoutId: checkout.checkoutId, + worktreeGitDirHash: digestPath(git.gitDir), + projectRealpathHash: digestPath(await realpath(projectRoot)), + registeredAt: checkout.registeredAt, + lastSeenAt: now, + }); + const target = runtimeCheckoutBindingPath(projectRoot); + const existing = await readCheckoutBinding(projectRoot); + if (existing !== null) { + assertCheckoutBindingMatchesWorkspace(existing, workspace); + if ( + existing.checkoutId !== candidate.checkoutId || + existing.worktreeGitDirHash !== candidate.worktreeGitDirHash || + existing.projectRealpathHash !== candidate.projectRealpathHash + ) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + const updated = { ...existing, lastSeenAt: now }; + await writeAtomic(target, updated); + return updated; + } + return writeExclusiveOrRead( + target, + candidate, + parseCheckoutBinding, + (stored, intended) => + stored.checkoutId === intended.checkoutId && + stored.worktreeGitDirHash === intended.worktreeGitDirHash && + stored.projectRealpathHash === intended.projectRealpathHash, + 'MANCODE_WORKSPACE_BINDING_MISMATCH', + ); +} + +async function readRuntimeCheckoutRecord( + projectRoot: string, +): Promise { + return readJsonOrNull( + runtimeCheckoutRecordPath(projectRoot), + parseRuntimeCheckoutRecord, + ); +} + +async function requireCheckoutBinding( + projectRoot: string, +): Promise { + const binding = await readCheckoutBinding(projectRoot); + if (binding === null) throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + return binding; +} + +async function readCheckoutBinding( + projectRoot: string, +): Promise { + return readJsonOrNull( + runtimeCheckoutBindingPath(projectRoot), + parseCheckoutBinding, + ); +} + +async function readRepositoryRuntimeBinding( + gitCommonDir: string, +): Promise { + return readJsonOrNull( + repositoryRuntimeBindingPath(gitCommonDir), + parseRepositoryRuntimeBinding, + ); +} + +async function readWorkspaceBinding( + gitCommonDir: string, + workspaceId: Ulid, +): Promise { + return readJsonOrNull( + workspaceRuntimeBindingPath(gitCommonDir, workspaceId), + parseWorkspaceBinding, + ); +} + +function runtimeContext( + projectRoot: string, + workspaceId: Ulid, + checkoutId: Ulid, + repositoryBindingId: Ulid | null, + gitCommonDir: string | null, +): ProjectRuntimeContext { + const entityHomeStoreContext: EntityHomeStoreContext = { + projectRoot, + workspaceId, + checkoutId, + repositoryBindingId, + gitCommonDir, + }; + // Construct once here so incompatible bindings fail before callers resolve + // any task or prepare a journal. + resolveCoordinationEntityHomeStore(entityHomeStoreContext); + return { + projectRoot, + workspaceId, + checkoutId, + repositoryBindingId, + gitCommonDir, + entityHomeStoreContext, + }; +} + +async function runGit(cwd: string, args: string[]): Promise { + try { + const { stdout } = await execFile('git', args, { + cwd, + encoding: 'utf8', + timeout: 5_000, + maxBuffer: 64 * 1024, + }); + return stdout; + } catch { + return null; + } +} + +function resolveGitPath(projectRoot: string, value: string): string { + if (!value || value.includes('\0')) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + return path.isAbsolute(value) + ? path.resolve(value) + : path.resolve(projectRoot, value); +} + +function relativeProjectPath( + worktreeRoot: string, + projectRoot: string, +): string { + const relative = path.relative(worktreeRoot, projectRoot); + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + return relative === '.' || relative === '' + ? '.' + : relative.split(path.sep).join('/'); +} + +function digestPath(value: string): string { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; +} + +async function writeExclusiveOrRead( + target: string, + value: T, + parser: (raw: unknown) => T, + compatible: (stored: T, intended: T) => boolean, + conflictCode: string, +): Promise { + await mkdir(path.dirname(target), { recursive: true }); + try { + await writeFile(target, serialize(value), { encoding: 'utf8', flag: 'wx' }); + return value; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readJsonOrNull(target, parser); + if (existing !== null && compatible(existing, value)) return existing; + throw new Error(conflictCode); + } +} + +async function writeAtomic(target: string, value: unknown): Promise { + await mkdir(path.dirname(target), { recursive: true }); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${createUlid()}.tmp`, + ); + await writeFile(temporary, serialize(value), { + encoding: 'utf8', + flag: 'wx', + }); + await rename(temporary, target); +} + +async function readJsonOrNull( + target: string, + parser: (raw: unknown) => T, +): Promise { + try { + return parser(JSON.parse(await readFile(target, 'utf8'))); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_WORKSPACE_BINDING_CORRUPT'); + } + throw error; + } +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function serialize(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} diff --git a/src/runtime/projection-outbox.ts b/src/runtime/projection-outbox.ts new file mode 100644 index 0000000..a499e09 --- /dev/null +++ b/src/runtime/projection-outbox.ts @@ -0,0 +1,867 @@ +import { + lstat, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { readConfirmedDecision } from '../context/confirmed-decision.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { type WorkflowMode, parseWorkflowMode } from '../context/schema.js'; +import { V3ContextStore } from '../context/store.js'; +import { + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { readSharedActorProfile } from '../team/actor.js'; +import { + type TeamEventV1, + assertTeamEventDedupeCompatible, + findTeamEventByDedupeKey, + parseTeamEvent, + writeTeamEvent, +} from '../team/events.js'; +import { replaceFileAtomically } from './atomic-file.js'; +import { + clearSessionTaskPointer, + readSession, + resumeSession, +} from './session.js'; + +export type ProjectionIntentState = 'pending' | 'completed' | 'superseded'; +export type ProjectionAvailability = + | 'present' + | 'missing' + | 'not_applicable' + | 'conflict'; +export type ProjectionCacheKind = 'context_pack' | 'status_index'; + +export interface AuditEventProjectionTargetV1 { + kind: 'audit_event'; + event: TeamEventV1; +} + +export interface SessionPointerProjectionTargetV1 { + kind: 'session_pointer'; + action: 'resume' | 'clear'; + sessionId: Ulid; + expectedPreviousTaskRef: TaskRef | null; + taskRef: TaskRef; + workflowMode: WorkflowMode; + taskRevision: number; +} + +export interface CacheInvalidationProjectionTargetV1 { + kind: 'cache_invalidation'; + cacheKind: ProjectionCacheKind; + taskRef: TaskRef; +} + +export type ProjectionTargetV1 = + | AuditEventProjectionTargetV1 + | SessionPointerProjectionTargetV1 + | CacheInvalidationProjectionTargetV1; + +export interface ProjectionIntentV1 { + schemaVersion: 1; + projectionId: string; + operationId: Ulid; + state: ProjectionIntentState; + target: ProjectionTargetV1; + createdAt: string; + updatedAt: string; +} + +export interface EnqueueSessionPointerProjectionInput { + operationId: Ulid; + action: SessionPointerProjectionTargetV1['action']; + sessionId: Ulid; + expectedPreviousTaskRef: TaskRef | null; + taskRef: TaskRef; + workflowMode: WorkflowMode; + taskRevision: number; + now?: Date; +} + +export interface EnqueueCacheInvalidationProjectionInput { + operationId: Ulid; + cacheKind: ProjectionCacheKind; + taskRef: TaskRef; + now?: Date; +} + +export interface ProjectionReconcileItem { + projectionId: string; + kind: ProjectionTargetV1['kind']; + availability: ProjectionAvailability; + state: ProjectionIntentState; +} + +export interface ProjectionReconcileResult { + operationId: Ulid; + state: 'converged' | 'repair_required'; + projections: ProjectionReconcileItem[]; +} + +const PROJECTION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const INTENT_FILE_SUFFIX = '.json'; + +/** + * Stores the complete audit target before its authority commit. Doctor repairs + * only this validated event; it never reconstructs event content from a + * journal or from mutable current state. + */ +export function enqueueAuditEventProjection( + projectRoot: string, + event: TeamEventV1, + now: Date = new Date(), +): Promise { + const parsed = parseTeamEvent(event); + return enqueueProjection(projectRoot, { + operationId: parsed.operationId, + projectionId: `audit_${parsed.eventType}`, + target: { kind: 'audit_event', event: parsed }, + now, + }); +} + +export function enqueueSessionPointerProjection( + projectRoot: string, + input: EnqueueSessionPointerProjectionInput, +): Promise { + assertUlid(input.operationId, 'projection operationId'); + assertUlid(input.sessionId, 'projection sessionId'); + const taskRef = parseTaskRefValue(input.taskRef); + const expectedPreviousTaskRef = + input.expectedPreviousTaskRef === null + ? null + : parseTaskRefValue(input.expectedPreviousTaskRef); + const workflowMode = parseWorkflowMode(input.workflowMode); + const taskRevision = parsePositiveRevision(input.taskRevision); + return enqueueProjection(projectRoot, { + operationId: input.operationId, + projectionId: `session_${input.sessionId}`, + target: { + kind: 'session_pointer', + action: parseSessionAction(input.action), + sessionId: input.sessionId, + expectedPreviousTaskRef, + taskRef, + workflowMode, + taskRevision, + }, + now: input.now ?? new Date(), + }); +} + +export function enqueueCacheInvalidationProjection( + projectRoot: string, + input: EnqueueCacheInvalidationProjectionInput, +): Promise { + assertUlid(input.operationId, 'projection operationId'); + const taskRef = parseTaskRefValue(input.taskRef); + const cacheKind = parseCacheKind(input.cacheKind); + return enqueueProjection(projectRoot, { + operationId: input.operationId, + projectionId: `cache_${cacheKind}_${taskRef.namespace}_${taskRef.taskId}`, + target: { kind: 'cache_invalidation', cacheKind, taskRef }, + now: input.now ?? new Date(), + }); +} + +export async function completeProjectionIntent( + projectRoot: string, + operationId: Ulid, + projectionId: string, + now: Date = new Date(), +): Promise { + return transitionProjectionIntent( + projectRoot, + operationId, + projectionId, + 'completed', + now, + ); +} + +export async function readProjectionIntent( + projectRoot: string, + operationId: Ulid, + projectionId: string, +): Promise { + assertUlid(operationId, 'projection operationId'); + const parsedProjectionId = parseProjectionId(projectionId); + try { + return parseProjectionIntent( + JSON.parse( + await readFile( + projectionIntentPath(projectRoot, operationId, parsedProjectionId), + 'utf8', + ), + ), + ); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_PROJECTION_INTENT_CORRUPT'); + } + throw error; + } +} + +export async function listProjectionIntents( + projectRoot: string, + options: { operationId?: Ulid; includeTerminal?: boolean } = {}, +): Promise { + if (options.operationId !== undefined) { + assertUlid(options.operationId, 'projection operationId'); + } + let operationIds: string[]; + try { + operationIds = + options.operationId === undefined + ? await readdir(projectionOutboxDirectory(projectRoot)) + : [options.operationId]; + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const intents: ProjectionIntentV1[] = []; + for (const operationId of operationIds.sort(compareUtf8)) { + try { + assertUlid(operationId, 'projection outbox operationId'); + } catch { + throw new Error('MANCODE_PROJECTION_INTENT_CORRUPT'); + } + const directory = projectionOperationDirectory(projectRoot, operationId); + let entries: string[]; + try { + entries = await readdir(directory); + } catch (error) { + if (isNotFound(error)) continue; + throw error; + } + for (const entry of entries.sort(compareUtf8)) { + if (!entry.endsWith(INTENT_FILE_SUFFIX)) continue; + const projectionId = entry.slice(0, -INTENT_FILE_SUFFIX.length); + const intent = await readProjectionIntent( + projectRoot, + operationId, + projectionId, + ); + if (intent === null) continue; + if (options.includeTerminal === true || intent.state === 'pending') { + intents.push(intent); + } + } + } + return intents.sort( + (left, right) => + compareUtf8(left.operationId, right.operationId) || + compareUtf8(left.projectionId, right.projectionId), + ); +} + +/** Applies only durable, fully specified targets and leaves conflicts pending. */ +export async function reconcileProjectionIntents( + projectRoot: string, + operationId: Ulid, + now: Date = new Date(), +): Promise { + assertUlid(operationId, 'projection operationId'); + const intents = await listProjectionIntents(projectRoot, { + operationId, + includeTerminal: true, + }); + const projections: ProjectionReconcileItem[] = []; + let repairRequired = false; + for (const intent of intents) { + if (intent.state !== 'pending') { + projections.push({ + projectionId: intent.projectionId, + kind: intent.target.kind, + availability: + intent.state === 'completed' ? 'present' : 'not_applicable', + state: intent.state, + }); + continue; + } + let availability = await inspectProjection(projectRoot, intent.target); + if (availability === 'missing') { + await applyProjection(projectRoot, intent.target, now); + availability = await inspectProjection(projectRoot, intent.target); + } + if (availability === 'present' || availability === 'not_applicable') { + const state = availability === 'present' ? 'completed' : 'superseded'; + const updated = await transitionProjectionIntent( + projectRoot, + operationId, + intent.projectionId, + state, + now, + ); + projections.push({ + projectionId: updated.projectionId, + kind: updated.target.kind, + availability, + state: updated.state, + }); + continue; + } + repairRequired = true; + projections.push({ + projectionId: intent.projectionId, + kind: intent.target.kind, + availability, + state: intent.state, + }); + } + return { + operationId, + state: repairRequired ? 'repair_required' : 'converged', + projections, + }; +} + +/** An explicitly aborted authority operation makes all of its projections moot. */ +export async function supersedeProjectionIntents( + projectRoot: string, + operationId: Ulid, + now: Date = new Date(), +): Promise { + assertUlid(operationId, 'projection operationId'); + const intents = await listProjectionIntents(projectRoot, { + operationId, + includeTerminal: true, + }); + const projections: ProjectionReconcileItem[] = []; + for (const intent of intents) { + const updated = + intent.state === 'pending' + ? await transitionProjectionIntent( + projectRoot, + operationId, + intent.projectionId, + 'superseded', + now, + ) + : intent; + projections.push({ + projectionId: updated.projectionId, + kind: updated.target.kind, + availability: + updated.state === 'completed' ? 'present' : 'not_applicable', + state: updated.state, + }); + } + return { operationId, state: 'converged', projections }; +} + +export function projectionOutboxDirectory(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'runtime', + 'projections', + ); +} + +export function projectionCachePath( + projectRoot: string, + target: CacheInvalidationProjectionTargetV1, +): string { + const taskRef = parseTaskRefValue(target.taskRef); + const cacheKind = parseCacheKind(target.cacheKind); + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'cache', + cacheKind === 'context_pack' ? 'context-packs' : 'status-index', + taskRef.namespace, + `${taskRef.taskId}.json`, + ); +} + +export function parseProjectionIntent(value: unknown): ProjectionIntentV1 { + assertRecord(value, 'projection intent'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'projectionId', + 'operationId', + 'state', + 'target', + 'createdAt', + 'updatedAt', + ], + 'projection intent', + ); + if (value.schemaVersion !== 1) { + throw new Error('projection intent schemaVersion must be 1'); + } + const projectionId = parseProjectionId(value.projectionId); + assertUlid(value.operationId, 'projection operationId'); + const state = parseProjectionState(value.state); + const target = parseProjectionTarget(value.target); + const createdAt = parseTimestamp(value.createdAt, 'projection createdAt'); + const updatedAt = parseTimestamp(value.updatedAt, 'projection updatedAt'); + return { + schemaVersion: 1, + projectionId, + operationId: value.operationId, + state, + target, + createdAt, + updatedAt, + }; +} + +async function enqueueProjection( + projectRoot: string, + input: { + operationId: Ulid; + projectionId: string; + target: ProjectionTargetV1; + now: Date; + }, +): Promise { + const timestamp = input.now.toISOString(); + const intent = parseProjectionIntent({ + schemaVersion: 1, + projectionId: input.projectionId, + operationId: input.operationId, + state: 'pending', + target: input.target, + createdAt: timestamp, + updatedAt: timestamp, + }); + const target = projectionIntentPath( + projectRoot, + intent.operationId, + intent.projectionId, + ); + await mkdir(path.dirname(target), { recursive: true }); + try { + await writeFile(target, serialize(intent), { + encoding: 'utf8', + flag: 'wx', + }); + return intent; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readProjectionIntent( + projectRoot, + intent.operationId, + intent.projectionId, + ); + if ( + existing !== null && + projectionIdentityDigest(existing) === projectionIdentityDigest(intent) + ) { + return existing; + } + throw new Error('MANCODE_PROJECTION_INTENT_CONFLICT'); + } +} + +async function transitionProjectionIntent( + projectRoot: string, + operationId: Ulid, + projectionId: string, + state: Exclude, + now: Date, +): Promise { + const existing = await readProjectionIntent( + projectRoot, + operationId, + projectionId, + ); + if (existing === null) throw new Error('MANCODE_PROJECTION_INTENT_NOT_FOUND'); + if (existing.state !== 'pending') return existing; + const updated = parseProjectionIntent({ + ...existing, + state, + updatedAt: now.toISOString(), + }); + const target = projectionIntentPath(projectRoot, operationId, projectionId); + const temporary = `${target}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temporary, serialize(updated), { + encoding: 'utf8', + flag: 'wx', + }); + try { + await replaceFileAtomically(temporary, target); + } finally { + await rm(temporary, { force: true }); + } + return updated; +} + +async function inspectProjection( + projectRoot: string, + target: ProjectionTargetV1, +): Promise { + switch (target.kind) { + case 'audit_event': + return inspectAuditEventProjection(projectRoot, target.event); + case 'session_pointer': + return inspectSessionProjection(projectRoot, target); + case 'cache_invalidation': + return (await pathExists(projectionCachePath(projectRoot, target))) + ? 'missing' + : 'present'; + } +} + +async function applyProjection( + projectRoot: string, + target: ProjectionTargetV1, + now: Date, +): Promise { + switch (target.kind) { + case 'audit_event': + await assertAuditEventAuthority(projectRoot, target.event); + await writeTeamEvent(projectRoot, target.event); + return; + case 'session_pointer': + await applySessionProjection(projectRoot, target, now); + return; + case 'cache_invalidation': + await rm(projectionCachePath(projectRoot, target), { force: true }); + } +} + +async function inspectAuditEventProjection( + projectRoot: string, + event: TeamEventV1, +): Promise { + const authority = await auditEventAuthority(projectRoot, event); + const existing = await findTeamEventByDedupeKey( + projectRoot, + event.operationId, + event.eventType, + ); + if (authority === 'not_applicable') return 'conflict'; + if (authority === 'conflict') return 'conflict'; + if (existing === null) return 'missing'; + try { + assertTeamEventDedupeCompatible(existing, event); + return 'present'; + } catch { + return 'conflict'; + } +} + +async function auditEventAuthority( + projectRoot: string, + event: TeamEventV1, +): Promise<'present' | 'not_applicable' | 'conflict'> { + if (event.entityRef.kind === 'actor') { + const actor = await readSharedActorProfile(projectRoot, event.entityRef.id); + if (actor === null) return 'not_applicable'; + return actor.actorId === event.actorId ? 'present' : 'conflict'; + } + if (event.entityRef.kind === 'decision') { + const decision = await readConfirmedDecision( + projectRoot, + event.entityRef.id, + ); + if (decision === null) return 'not_applicable'; + return decision.operationId === event.operationId && + decision.confirmedByActorId === event.actorId + ? 'present' + : 'conflict'; + } + return 'conflict'; +} + +async function assertAuditEventAuthority( + projectRoot: string, + event: TeamEventV1, +): Promise { + const authority = await auditEventAuthority(projectRoot, event); + if (authority !== 'present') { + throw new Error('MANCODE_PROJECTION_AUTHORITY_UNVERIFIABLE'); + } +} + +async function inspectSessionProjection( + projectRoot: string, + target: SessionPointerProjectionTargetV1, +): Promise { + const session = await readSession(projectRoot, target.sessionId); + if (session === null || session.status === 'closed') return 'not_applicable'; + const task = await readProjectionTask(projectRoot, target.taskRef); + if (task === null) return 'conflict'; + if ( + task.metadata.transitionState !== 'stable' || + task.metadata.workflowMode !== target.workflowMode + ) { + return 'conflict'; + } + if (task.metadata.revision < target.taskRevision) return 'conflict'; + const terminal = isTerminalWorkflowStatus(task.metadata.status); + if (target.action === 'clear') { + if (!terminal) return 'conflict'; + if (session.activeTaskRef === null) return 'present'; + return sameTaskRef(session.activeTaskRef, target.taskRef) + ? 'missing' + : 'not_applicable'; + } + if (terminal) { + if (session.activeTaskRef === null) return 'not_applicable'; + return sameTaskRef(session.activeTaskRef, target.taskRef) + ? 'missing' + : 'not_applicable'; + } + if ( + session.activeTaskRef !== null && + sameTaskRef(session.activeTaskRef, target.taskRef) + ) { + return session.activeMode === target.workflowMode && + session.lastSeenRevision === task.metadata.revision + ? 'present' + : 'missing'; + } + return sameNullableTaskRef( + session.activeTaskRef, + target.expectedPreviousTaskRef, + ) + ? 'missing' + : 'not_applicable'; +} + +async function applySessionProjection( + projectRoot: string, + target: SessionPointerProjectionTargetV1, + now: Date, +): Promise { + const task = await readProjectionTask(projectRoot, target.taskRef); + if (task === null) return; + if ( + task.metadata.transitionState !== 'stable' || + task.metadata.workflowMode !== target.workflowMode + ) { + throw new Error('MANCODE_PROJECTION_AUTHORITY_UNVERIFIABLE'); + } + if (task.metadata.revision < target.taskRevision) return; + if ( + target.action === 'clear' || + isTerminalWorkflowStatus(task.metadata.status) + ) { + await clearSessionTaskPointer(projectRoot, target.sessionId, { + expectedTaskRef: target.taskRef, + now, + }); + return; + } + const session = await readSession(projectRoot, target.sessionId); + if ( + session === null || + session.status !== 'active' || + (!sameNullableTaskRef( + session.activeTaskRef, + target.expectedPreviousTaskRef, + ) && + (session.activeTaskRef === null || + !sameTaskRef(session.activeTaskRef, target.taskRef))) + ) { + return; + } + await resumeSession(projectRoot, target.sessionId, { + taskRef: target.taskRef, + workflowMode: task.metadata.workflowMode, + taskRevision: task.metadata.revision, + now, + }); +} + +async function readProjectionTask(projectRoot: string, taskRef: TaskRef) { + try { + return await new V3ContextStore(projectRoot).readTaskSnapshot(taskRef); + } catch (error) { + if (error instanceof Error && error.message === 'MANCODE_TASK_NOT_FOUND') { + return null; + } + throw error; + } +} + +function parseProjectionTarget(value: unknown): ProjectionTargetV1 { + assertRecord(value, 'projection target'); + if (value.kind === 'audit_event') { + assertKnownKeys(value, ['kind', 'event'], 'audit event projection target'); + return { kind: 'audit_event', event: parseTeamEvent(value.event) }; + } + if (value.kind === 'session_pointer') { + assertKnownKeys( + value, + [ + 'kind', + 'action', + 'sessionId', + 'expectedPreviousTaskRef', + 'taskRef', + 'workflowMode', + 'taskRevision', + ], + 'session pointer projection target', + ); + assertUlid(value.sessionId, 'projection sessionId'); + return { + kind: 'session_pointer', + action: parseSessionAction(value.action), + sessionId: value.sessionId, + expectedPreviousTaskRef: + value.expectedPreviousTaskRef === null + ? null + : parseTaskRefValue(value.expectedPreviousTaskRef), + taskRef: parseTaskRefValue(value.taskRef), + workflowMode: parseWorkflowMode(value.workflowMode), + taskRevision: parsePositiveRevision(value.taskRevision), + }; + } + if (value.kind === 'cache_invalidation') { + assertKnownKeys( + value, + ['kind', 'cacheKind', 'taskRef'], + 'cache invalidation projection target', + ); + return { + kind: 'cache_invalidation', + cacheKind: parseCacheKind(value.cacheKind), + taskRef: parseTaskRefValue(value.taskRef), + }; + } + throw new Error('projection target kind is invalid'); +} + +function projectionOperationDirectory( + projectRoot: string, + operationId: string, +): string { + assertUlid(operationId, 'projection operationId'); + return path.join(projectionOutboxDirectory(projectRoot), operationId); +} + +function projectionIntentPath( + projectRoot: string, + operationId: string, + projectionId: string, +): string { + return path.join( + projectionOperationDirectory(projectRoot, operationId), + `${parseProjectionId(projectionId)}${INTENT_FILE_SUFFIX}`, + ); +} + +function projectionIdentityDigest(intent: ProjectionIntentV1): string { + return digestCanonicalJson({ + projectionId: intent.projectionId, + operationId: intent.operationId, + target: intent.target, + }); +} + +function parseProjectionId(value: unknown): string { + if (typeof value !== 'string' || !PROJECTION_ID_PATTERN.test(value)) { + throw new Error('projectionId is invalid'); + } + return value; +} + +function parseProjectionState(value: unknown): ProjectionIntentState { + if (value !== 'pending' && value !== 'completed' && value !== 'superseded') { + throw new Error('projection intent state is invalid'); + } + return value; +} + +function parseSessionAction( + value: unknown, +): SessionPointerProjectionTargetV1['action'] { + if (value !== 'resume' && value !== 'clear') { + throw new Error('session pointer projection action is invalid'); + } + return value; +} + +function parseCacheKind(value: unknown): ProjectionCacheKind { + if (value !== 'context_pack' && value !== 'status_index') { + throw new Error('projection cache kind is invalid'); + } + return value; +} + +function parsePositiveRevision(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error('projection taskRevision must be a positive integer'); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function sameNullableTaskRef( + left: TaskRef | null, + right: TaskRef | null, +): boolean { + return left === null + ? right === null + : right !== null && sameTaskRef(left, right); +} + +function isTerminalWorkflowStatus(status: string): boolean { + return ( + status === 'completed' || status === 'abandoned' || status === 'superseded' + ); +} + +async function pathExists(target: string): Promise { + try { + await lstat(target); + return true; + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } +} + +function serialize(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/reconciler.ts b/src/runtime/reconciler.ts new file mode 100644 index 0000000..16af691 --- /dev/null +++ b/src/runtime/reconciler.ts @@ -0,0 +1,299 @@ +import { type Ulid, assertUlid } from '../context/ids.js'; +import { assertRepairUsesOriginalAuthorization } from '../team/authorization.js'; +import { + OPERATION_CRASH_FIXTURES, + OPERATION_DEFINITIONS, + type OperationCrashFixture, + type OperationStepDefinition, + assertOperationJournalMatchesDefinition, + getOperationDefinition, +} from './operation-definition.js'; +import { + type OperationJournalV1, + assertOperationReservationTopology, + operationReservationJournalDigest, + parseOperationJournal, +} from './operation-journal.js'; +import { + type OperationReservationV1, + parseOperationReservation, +} from './operation-reservation.js'; + +export type ProjectionAvailability = + | 'present' + | 'missing' + | 'not_applicable' + | 'conflict'; +export type OperationRecoveryAction = + | 'none' + | 'safe_abort' + | 'forward_repair' + | 'projection_retry' + | 'projection_repair_required'; + +export interface OperationProjectionState { + auditEvent: ProjectionAvailability; + sessionPointer: ProjectionAvailability; + cache: ProjectionAvailability; +} + +export interface OperationRecoveryInput { + journal: OperationJournalV1; + reservations?: OperationReservationV1[]; + projections?: Partial; +} + +export interface OperationRecoveryPlan { + action: OperationRecoveryAction; + nextJournalState: 'aborted' | 'repair_required' | 'committed' | null; + reason: + | 'terminal' + | 'no_external_write' + | 'reservation_incomplete' + | 'business_write_visible' + | 'journal_marked_repair_required' + | 'projection_missing' + | 'projection_conflict'; + pendingEntityLocks: string[]; + remainingStepIds: string[]; + retryProjections: Array<'auditEvent' | 'sessionPointer' | 'cache'>; + authorization: OperationJournalV1['authorizationBasis']; +} + +/** + * Converts durable journal state into a side-effect-free repair decision. The + * executor applies this plan under the existing canonical locks; this planner + * never infers a successor owner or substitutes a new actor/session. + */ +export function planOperationRecovery( + input: OperationRecoveryInput, +): OperationRecoveryPlan { + const journal = parseOperationJournal(input.journal); + assertOperationJournalMatchesDefinition(journal); + assertOperationReservationTopology(journal); + const definition = getOperationDefinition(journal.type); + const reservationsConsistent = + input.reservations === undefined || + reservationsMatchJournal(journal, input.reservations); + const remainingStepIds = journal.steps + .filter((step) => step.state === 'pending') + .map((step) => step.id); + const pendingEntityLocks = + journal.state === 'committed' || journal.state === 'aborted' + ? [] + : [...journal.entityLocks]; + + if (journal.state === 'aborted') { + return recoveryPlan(journal, 'none', null, 'terminal', [], []); + } + if (journal.state === 'committed') { + return planCommittedProjectionRecovery(journal, input.projections ?? {}); + } + if (journal.state === 'repair_required') { + return recoveryPlan( + journal, + 'forward_repair', + 'repair_required', + reservationsConsistent + ? 'journal_marked_repair_required' + : 'reservation_incomplete', + pendingEntityLocks, + remainingStepIds, + ); + } + const externalWriteVisible = hasCompletedExternalWrite( + journal, + definition.steps, + ); + if (externalWriteVisible) { + return recoveryPlan( + journal, + 'forward_repair', + 'repair_required', + reservationsConsistent + ? 'business_write_visible' + : 'reservation_incomplete', + pendingEntityLocks, + remainingStepIds, + ); + } + // A missing reservation before the first business write is still safe to + // abort: no task state has become externally visible and reservations can be + // removed as compensation. After a write, the branch above repairs forward. + return recoveryPlan( + journal, + 'safe_abort', + 'aborted', + reservationsConsistent ? 'no_external_write' : 'reservation_incomplete', + pendingEntityLocks, + remainingStepIds, + ); +} + +/** Enforces that a repair executor cannot swap in its own actor or session. */ +export function assertRecoveryActor( + plan: OperationRecoveryPlan, + actorId: Ulid, + sessionId: Ulid, +): void { + assertUlid(actorId, 'recovery actorId'); + assertUlid(sessionId, 'recovery sessionId'); + assertRepairUsesOriginalAuthorization(plan.authorization, actorId, sessionId); +} + +/** Makes every declared crash fixture executable and rejects definition drift. */ +export function assertOperationCrashFixtureCoverage(): void { + for (const definition of Object.values(OPERATION_DEFINITIONS)) { + const fixtures = OPERATION_CRASH_FIXTURES[definition.type]; + if (fixtures.length !== definition.steps.length + 1) { + throw new Error( + `operation ${definition.type} has incomplete crash fixture coverage`, + ); + } + for (const fixture of fixtures) { + if (fixture.operationType !== definition.type) { + throw new Error( + 'operation crash fixture type does not match definition', + ); + } + if (fixture.expectedRecovery !== crashRecoveryForFixture(fixture)) { + throw new Error('operation crash fixture recovery does not match step'); + } + } + } +} + +export function crashRecoveryForFixture( + fixture: OperationCrashFixture, +): OperationCrashFixture['expectedRecovery'] { + if (fixture.crashAfter === 'prepared') return 'safe_abort'; + const step = getOperationDefinition(fixture.operationType).steps.find( + (candidate) => candidate.id === fixture.crashAfter, + ); + if (step === undefined) { + throw new Error('operation crash fixture references an unknown step'); + } + return step.crashRecovery; +} + +function planCommittedProjectionRecovery( + journal: OperationJournalV1, + partial: Partial, +): OperationRecoveryPlan { + const projections: OperationProjectionState = { + auditEvent: partial.auditEvent ?? 'missing', + sessionPointer: partial.sessionPointer ?? 'not_applicable', + cache: partial.cache ?? 'not_applicable', + }; + for (const value of Object.values(projections)) { + if ( + value !== 'present' && + value !== 'missing' && + value !== 'not_applicable' && + value !== 'conflict' + ) { + throw new Error('operation projection availability is invalid'); + } + } + const retryProjections = ( + Object.entries(projections) as Array< + ['auditEvent' | 'sessionPointer' | 'cache', ProjectionAvailability] + > + ) + .filter(([, value]) => value === 'missing') + .map(([key]) => key); + if (Object.values(projections).includes('conflict')) { + return recoveryPlan( + journal, + 'projection_repair_required', + 'committed', + 'projection_conflict', + [], + [], + retryProjections, + ); + } + if (retryProjections.length > 0) { + return recoveryPlan( + journal, + 'projection_retry', + 'committed', + 'projection_missing', + [], + [], + retryProjections, + ); + } + return recoveryPlan(journal, 'none', null, 'terminal', [], []); +} + +function recoveryPlan( + journal: OperationJournalV1, + action: OperationRecoveryAction, + nextJournalState: OperationRecoveryPlan['nextJournalState'], + reason: OperationRecoveryPlan['reason'], + pendingEntityLocks: string[], + remainingStepIds: string[], + retryProjections: OperationRecoveryPlan['retryProjections'] = [], +): OperationRecoveryPlan { + return { + action, + nextJournalState, + reason, + pendingEntityLocks, + remainingStepIds, + retryProjections, + authorization: journal.authorizationBasis, + }; +} + +function hasCompletedExternalWrite( + journal: OperationJournalV1, + definitions: OperationStepDefinition[], +): boolean { + return journal.steps.some((step, index) => { + const definition = definitions[index]; + if (definition === undefined) { + throw new Error('operation journal step definition is missing'); + } + return ( + step.state === 'completed' && + (definition.visibility === 'business_write' || + definition.visibility === 'commit') + ); + }); +} + +function reservationsMatchJournal( + journal: OperationJournalV1, + reservations: OperationReservationV1[], +): boolean { + const expectedDigest = operationReservationJournalDigest(journal); + const expected = [...journal.secondaryReservations]; + if (reservations.length !== expected.length) return false; + for (const rawReservation of reservations) { + let reservation: OperationReservationV1; + try { + reservation = parseOperationReservation(rawReservation); + } catch { + return false; + } + const matchIndex = expected.findIndex( + (candidate) => + candidate.entityKeys.length === reservation.entityKeys.length && + candidate.entityKeys.every((key) => + reservation.entityKeys.includes(key), + ), + ); + if ( + matchIndex < 0 || + reservation.operationId !== journal.operationId || + reservation.primaryStoreId !== journal.primaryStoreId || + reservation.journalDigest !== expectedDigest + ) { + return false; + } + expected.splice(matchIndex, 1); + } + return expected.length === 0; +} diff --git a/src/runtime/retention.ts b/src/runtime/retention.ts new file mode 100644 index 0000000..4c38e7f --- /dev/null +++ b/src/runtime/retention.ts @@ -0,0 +1,432 @@ +import { lstat, readFile, readdir, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { V3ContextStore } from '../context/store.js'; +import { + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { type CheckpointV1, parseCheckpoint } from '../team/checkpoints.js'; +import { + type EntityHomeStore, + operationDirectory, + resolveCoordinationEntityHomeStore, + resolveLocalEntityHomeStore, +} from './entity-home-store.js'; +import { listHandoffs } from './handoff-store.js'; +import { + type OperationJournalV1, + parseOperationJournal, +} from './operation-journal.js'; +import { operationRecoveryPayloadPath } from './operation-recovery-store.js'; +import { readProjectRuntimeContext } from './project-runtime.js'; +import { parseSessionState } from './session.js'; + +const TERMINAL_JOURNAL_RETENTION_DAYS = 30; +const RETAINED_NON_MILESTONE_CHECKPOINTS = 10; + +export type RetentionCandidateKind = + | 'completed_session' + | 'terminal_operation' + | 'checkpoint'; + +export interface RetentionCandidate { + kind: RetentionCandidateKind; + target: string; + reason: string; + taskRef: TaskRef | null; + relatedTargets: string[]; +} + +export interface ContextCompactionPlan { + schemaVersion: 1; + generatedAt: string; + candidates: RetentionCandidate[]; + skippedReferencedCheckpoints: Array<{ + taskRef: TaskRef; + checkpointId: string; + }>; +} + +export interface CompactContextInput { + projectRoot: string; + taskRef?: TaskRef; + now?: Date; +} + +export interface AppliedContextCompaction extends ContextCompactionPlan { + deleted: string[]; +} + +interface OperationRetentionPlan { + candidates: RetentionCandidate[]; + protectedSessionIds: Set; + protectedTaskRefs: Set; +} + +/** + * Lists only fixed V3 runtime entities whose retention rules are mechanically + * provable. Context packs are currently ephemeral, so they have no on-disk + * candidate here. Checkpoint removal is deliberately restricted to completed + * tasks and explicit compaction callers. + */ +export async function planContextCompaction( + input: CompactContextInput, +): Promise { + const root = path.resolve(input.projectRoot); + const now = input.now ?? new Date(); + const store = new V3ContextStore(root); + const [project, runtime] = await Promise.all([ + store.readProjectSnapshot(), + readProjectRuntimeContext(root), + ]); + const localStore = resolveLocalEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const coordinationStore = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const operationPlan = await planOperationRetention( + uniqueStores([localStore, coordinationStore]), + now, + ); + const taskRefs = + input.taskRef === undefined + ? await listTaskRefs(root) + : [parseTaskRefValue(input.taskRef)]; + const [checkpointPlan, sessions] = await Promise.all([ + planCheckpointCompaction( + store, + coordinationStore, + taskRefs, + operationPlan.protectedTaskRefs, + ), + planCompletedSessionRetention( + root, + project.policy.retention.completedSessionDays, + now, + operationPlan.protectedSessionIds, + ), + ]); + return { + schemaVersion: 1, + generatedAt: now.toISOString(), + candidates: [ + ...sessions, + ...operationPlan.candidates, + ...checkpointPlan.candidates, + ].sort((left, right) => + Buffer.from(left.target, 'utf8').compare( + Buffer.from(right.target, 'utf8'), + ), + ), + skippedReferencedCheckpoints: checkpointPlan.skippedReferencedCheckpoints, + }; +} + +/** Applies a previously rendered deletion list after rechecking every target. */ +export async function applyContextCompaction( + plan: ContextCompactionPlan, +): Promise { + const deleted: string[] = []; + for (const candidate of plan.candidates) { + await removeRegularFile(candidate.target); + deleted.push(candidate.target); + for (const target of candidate.relatedTargets) { + if (await regularFileExists(target)) { + await removeRegularFile(target); + deleted.push(target); + } + } + } + return { ...plan, deleted }; +} + +async function planCheckpointCompaction( + store: V3ContextStore, + coordinationStore: EntityHomeStore, + taskRefs: TaskRef[], + protectedTaskRefs: ReadonlySet, +): Promise<{ + candidates: RetentionCandidate[]; + skippedReferencedCheckpoints: ContextCompactionPlan['skippedReferencedCheckpoints']; +}> { + const candidates: RetentionCandidate[] = []; + const skippedReferencedCheckpoints: ContextCompactionPlan['skippedReferencedCheckpoints'] = + []; + for (const taskRef of taskRefs) { + const task = await store.readTaskSnapshot(taskRef); + if (task.metadata.status !== 'completed') continue; + if (protectedTaskRefs.has(taskRefKey(taskRef))) continue; + const referenced = new Set(); + if (task.metadata.latestCheckpointRef?.artifactId !== undefined) { + referenced.add(task.metadata.latestCheckpointRef.artifactId); + } + if (taskRef.namespace === 'shared') { + const handoffs = await listHandoffs(coordinationStore, taskRef); + for (const handoff of handoffs) { + if ( + handoff.checkpointRef.kind === 'checkpoint' && + handoff.checkpointRef.artifactId !== undefined + ) { + referenced.add(handoff.checkpointRef.artifactId); + } + } + } + const checkpoints = await listTaskCheckpoints( + task.location.taskRoot, + taskRef, + ); + const removable = checkpoints + .filter((checkpoint) => !isMilestone(checkpoint)) + .sort(compareCheckpointNewestFirst) + .slice(RETAINED_NON_MILESTONE_CHECKPOINTS); + for (const checkpoint of removable) { + if (referenced.has(checkpoint.checkpointId)) { + skippedReferencedCheckpoints.push({ + taskRef, + checkpointId: checkpoint.checkpointId, + }); + continue; + } + candidates.push({ + kind: 'checkpoint', + target: path.join( + task.location.taskRoot, + 'checkpoints', + `${checkpoint.checkpointId}.json`, + ), + reason: `completed task retains only ${RETAINED_NON_MILESTONE_CHECKPOINTS} non-milestone checkpoints`, + taskRef, + relatedTargets: [], + }); + } + } + return { candidates, skippedReferencedCheckpoints }; +} + +async function planCompletedSessionRetention( + root: string, + completedSessionDays: number, + now: Date, + protectedSessionIds: ReadonlySet, +): Promise { + const directory = path.join(root, '.mancode', 'local', 'sessions'); + const entries = await readDirectoryOrEmpty(directory); + const threshold = now.getTime() - completedSessionDays * 86_400_000; + const candidates: RetentionCandidate[] = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const target = path.join(directory, entry); + const session = parseSessionState( + JSON.parse(await readRegularFile(target)), + ); + if ( + session.status === 'closed' && + session.closedAt !== null && + Date.parse(session.closedAt) < threshold && + !protectedSessionIds.has(session.sessionId) + ) { + candidates.push({ + kind: 'completed_session', + target, + reason: `closed session exceeds ${completedSessionDays} day retention`, + taskRef: null, + relatedTargets: [], + }); + } + } + return candidates; +} + +async function planOperationRetention( + stores: EntityHomeStore[], + now: Date, +): Promise { + const threshold = + now.getTime() - TERMINAL_JOURNAL_RETENTION_DAYS * 86_400_000; + const candidates: RetentionCandidate[] = []; + const protectedSessionIds = new Set(); + const protectedTaskRefs = new Set(); + for (const store of stores) { + const directory = operationDirectory(store); + const entries = await readDirectoryOrEmpty(directory); + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const target = path.join(directory, entry); + const journal = parseOperationJournal( + JSON.parse(await readRegularFile(target)), + ); + const terminal = + journal.state === 'committed' || journal.state === 'aborted'; + if (!terminal) { + protectedSessionIds.add(journal.sessionId); + for (const entityKey of operationEntityKeys(journal)) { + const protectedTaskRef = taskRefKeyFromEntityKey(entityKey); + if (protectedTaskRef !== null) { + protectedTaskRefs.add(protectedTaskRef); + } + } + } else if (Date.parse(journal.updatedAt) < threshold) { + candidates.push(operationRetentionCandidate(store, journal, target)); + } + } + } + return { candidates, protectedSessionIds, protectedTaskRefs }; +} + +function operationRetentionCandidate( + store: EntityHomeStore, + journal: OperationJournalV1, + target: string, +): RetentionCandidate { + return { + kind: 'terminal_operation', + target, + reason: `${journal.state} operation exceeds ${TERMINAL_JOURNAL_RETENTION_DAYS} day retention`, + taskRef: null, + relatedTargets: + journal.recoveryPayloadDigest === undefined + ? [] + : [operationRecoveryPayloadPath(store, journal.operationId)], + }; +} + +function operationEntityKeys(journal: OperationJournalV1): string[] { + return [ + ...journal.entityLocks, + ...journal.secondaryReservations.flatMap( + (reservation) => reservation.entityKeys, + ), + ]; +} + +function taskRefKey(taskRef: TaskRef): string { + return `${taskRef.namespace}:${taskRef.taskId}`; +} + +function taskRefKeyFromEntityKey(entityKey: string): string | null { + const match = /^task:(local|shared):([0-9A-HJKMNP-TV-Z]{26})$/.exec( + entityKey, + ); + return match === null ? null : `${match[1]}:${match[2]}`; +} + +async function listTaskRefs(root: string): Promise { + const refs: TaskRef[] = []; + for (const namespace of ['local', 'shared'] as const) { + const directory = path.join(root, '.mancode', namespace, 'workflows'); + for (const entry of await readDirectoryOrEmpty(directory)) { + if (!/^[0-9A-HJKMNP-TV-Z]{26}$/.test(entry)) continue; + refs.push({ namespace, taskId: entry }); + } + } + return refs.sort((left, right) => + Buffer.from(`${left.namespace}:${left.taskId}`, 'utf8').compare( + Buffer.from(`${right.namespace}:${right.taskId}`, 'utf8'), + ), + ); +} + +async function listTaskCheckpoints( + taskRoot: string, + taskRef: TaskRef, +): Promise { + const directory = path.join(taskRoot, 'checkpoints'); + const checkpoints: CheckpointV1[] = []; + for (const entry of await readDirectoryOrEmpty(directory)) { + if (!entry.endsWith('.json')) continue; + const checkpoint = parseCheckpoint( + JSON.parse(await readRegularFile(path.join(directory, entry))), + ); + if ( + !sameTaskRef(checkpoint.taskRef, taskRef) || + entry !== `${checkpoint.checkpointId}.json` + ) { + throw new Error('MANCODE_RETENTION_CHECKPOINT_CORRUPT'); + } + checkpoints.push(checkpoint); + } + return checkpoints; +} + +function isMilestone(checkpoint: CheckpointV1): boolean { + return checkpoint.kind !== 'diagnostic_started'; +} + +function compareCheckpointNewestFirst( + left: CheckpointV1, + right: CheckpointV1, +): number { + return ( + Date.parse(right.createdAt) - Date.parse(left.createdAt) || + Buffer.from(right.checkpointId, 'utf8').compare( + Buffer.from(left.checkpointId, 'utf8'), + ) + ); +} + +function uniqueStores(stores: EntityHomeStore[]): EntityHomeStore[] { + return stores.filter( + (store, index) => + stores.findIndex((candidate) => candidate.storeId === store.storeId) === + index, + ); +} + +async function readDirectoryOrEmpty(directory: string): Promise { + try { + const entry = await lstat(directory); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_RETENTION_PATH_UNSAFE'); + } + return (await readdir(directory)).sort((left, right) => + Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')), + ); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } +} + +async function readRegularFile(target: string): Promise { + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_RETENTION_PATH_UNSAFE'); + } + const content = await readFile(target, 'utf8'); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_RETENTION_PATH_UNSAFE'); + } + return content; +} + +async function removeRegularFile(target: string): Promise { + await readRegularFile(target); + await rm(target); +} + +async function regularFileExists(target: string): Promise { + try { + await readRegularFile(target); + return true; + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/session-identity.ts b/src/runtime/session-identity.ts new file mode 100644 index 0000000..2eee68e --- /dev/null +++ b/src/runtime/session-identity.ts @@ -0,0 +1,134 @@ +import { createHash } from 'node:crypto'; +import { type Ulid, assertUlid } from '../context/ids.js'; + +export type SessionIdentitySource = 'explicit' | 'env' | 'host'; +export type HostIdentityCapability = 'host_verified' | 'explicit_required'; + +export interface SessionIdentityCandidate { + internalSessionId?: Ulid; + externalKeyHash?: string; + source: SessionIdentitySource; + client: string; + propagatesToCommands: boolean; +} + +export interface TrustedHostSessionInput { + externalSessionKey: string; + propagatesToCommands: boolean; +} + +export interface SessionIdentityProviderInput { + explicitSessionId?: string; + environment: NodeJS.ProcessEnv; + trustedHostInput?: TrustedHostSessionInput; + client: string; +} + +export interface SessionIdentityProvider { + resolveCandidate( + input: SessionIdentityProviderInput, + ): SessionIdentityCandidate | null; +} + +export interface SessionIdentityProviderOptions { + /** No platform evidence means host identity is never trusted by default. */ + hostIdentityCapability?: HostIdentityCapability; +} + +export function createSessionIdentityProvider( + workspaceId: Ulid, + options: SessionIdentityProviderOptions = {}, +): SessionIdentityProvider { + assertUlid(workspaceId, 'workspaceId'); + const hostIdentityCapability = + options.hostIdentityCapability ?? 'explicit_required'; + if ( + hostIdentityCapability !== 'host_verified' && + hostIdentityCapability !== 'explicit_required' + ) { + throw new Error('host identity capability is invalid'); + } + return { + resolveCandidate(input) { + const client = parseClient(input.client); + if (input.explicitSessionId !== undefined) { + assertUlid(input.explicitSessionId, '--session'); + return { + internalSessionId: input.explicitSessionId, + source: 'explicit', + client, + propagatesToCommands: true, + }; + } + const environmentSessionId = input.environment.MANCODE_SESSION_ID; + if (environmentSessionId) { + assertUlid(environmentSessionId, 'MANCODE_SESSION_ID'); + return { + internalSessionId: environmentSessionId, + source: 'env', + client, + propagatesToCommands: true, + }; + } + if ( + !input.trustedHostInput || + hostIdentityCapability !== 'host_verified' + ) { + return null; + } + return { + externalKeyHash: hashHostSessionKey( + workspaceId, + client, + input.trustedHostInput.externalSessionKey, + ), + source: 'host', + client, + propagatesToCommands: input.trustedHostInput.propagatesToCommands, + }; + }, + }; +} + +/** The raw host identifier only exists in memory and never becomes a filename. */ +export function hashHostSessionKey( + workspaceId: Ulid, + client: string, + rawHostSessionKey: string, +): string { + assertUlid(workspaceId, 'workspaceId'); + const normalizedClient = parseClient(client); + const normalizedKey = normalizeHostSessionKey(rawHostSessionKey); + return `sha256:${createHash('sha256') + .update(`${workspaceId}\0${normalizedClient}\0${normalizedKey}`, 'utf8') + .digest('hex')}`; +} + +function parseClient(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error('session client is required'); + } + return value.trim(); +} + +function normalizeHostSessionKey(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error('trusted host session key is required'); + } + const normalized = value.trim(); + if (normalized.includes('\0')) { + throw new Error('trusted host session key must not contain NUL'); + } + for (let index = 0; index < normalized.length; index += 1) { + const codeUnit = normalized.charCodeAt(index); + if (codeUnit < 0xd800 || codeUnit > 0xdfff) continue; + const next = normalized.charCodeAt(index + 1); + if (codeUnit > 0xdbff || next < 0xdc00 || next > 0xdfff) { + throw new Error( + 'trusted host session key must not contain a lone surrogate', + ); + } + index += 1; + } + return normalized; +} diff --git a/src/runtime/session.ts b/src/runtime/session.ts new file mode 100644 index 0000000..cbcfa84 --- /dev/null +++ b/src/runtime/session.ts @@ -0,0 +1,569 @@ +import { + mkdir, + readFile, + readdir, + rm, + rmdir, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { type WorkflowMode, parseWorkflowMode } from '../context/schema.js'; +import { type TaskRef, parseTaskRefValue } from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { replaceFileAtomically } from './atomic-file.js'; +import type { + SessionIdentityCandidate, + SessionIdentitySource, +} from './session-identity.js'; + +export interface SessionStateV1 { + schemaVersion: 1; + sessionId: Ulid; + identitySource: SessionIdentitySource; + identityLookupKeyHash: string | null; + actorId: Ulid; + client: string; + status: 'active' | 'closed'; + activeTaskRef: TaskRef | null; + activeMode: WorkflowMode | null; + lastSeenRevision: number | null; + executionIds: Ulid[]; + startedAt: string; + closedAt: string | null; + updatedAt: string; +} + +export interface CreateSessionInput { + actorId: Ulid; + client: string; + identitySource: SessionIdentitySource; + identityLookupKeyHash?: string | null; + sessionId?: Ulid; + now?: Date; +} + +export interface BootstrapSessionResult { + session: SessionStateV1; + environment: { MANCODE_SESSION_ID: Ulid }; + hint: string; +} + +export interface ResumeSessionInput { + taskRef: TaskRef; + workflowMode: WorkflowMode; + taskRevision: number; + now?: Date; +} + +export interface ClearSessionTaskPointerInput { + /** Refuse to clear a pointer that another command has already replaced. */ + expectedTaskRef?: TaskRef; + now?: Date; +} + +const SESSION_HASH_PATTERN = /^sha256:[a-f0-9]{64}$/; +const SESSION_SOURCES = new Set([ + 'explicit', + 'env', + 'host', +]); + +export async function createSession( + projectRoot: string, + input: CreateSessionInput, +): Promise { + assertUlid(input.actorId, 'session actorId'); + if (!SESSION_SOURCES.has(input.identitySource)) { + throw new Error('session identitySource is invalid'); + } + const client = parseClient(input.client); + const identityLookupKeyHash = parseIdentityLookupKeyHash( + input.identitySource, + input.identityLookupKeyHash ?? null, + ); + const now = (input.now ?? new Date()).toISOString(); + const directory = sessionDirectory(projectRoot); + await mkdir(directory, { recursive: true }); + + for (let attempt = 0; attempt < 10; attempt += 1) { + const sessionId = input.sessionId ?? createUlid(); + if (input.sessionId) assertUlid(input.sessionId, 'sessionId'); + const session: SessionStateV1 = { + schemaVersion: 1, + sessionId, + identitySource: input.identitySource, + identityLookupKeyHash, + actorId: input.actorId, + client, + status: 'active', + activeTaskRef: null, + activeMode: null, + lastSeenRevision: null, + executionIds: [], + startedAt: now, + closedAt: null, + updatedAt: now, + }; + try { + await writeFile( + sessionPath(projectRoot, sessionId), + `${JSON.stringify(session, null, 2)}\n`, + { encoding: 'utf8', flag: 'wx' }, + ); + return session; + } catch (error) { + if (!isAlreadyExists(error) || input.sessionId) throw error; + } + } + throw new Error('unable to allocate a unique sessionId'); +} + +export async function readSession( + projectRoot: string, + sessionId: string, +): Promise { + assertUlid(sessionId, 'sessionId'); + try { + assertUlid(sessionId, 'sessionId'); + const raw = await readFile(sessionPath(projectRoot, sessionId), 'utf8'); + return parseSessionState(JSON.parse(raw)); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) + throw new Error('MANCODE_SESSION_CORRUPT'); + throw error; + } +} + +/** + * The only bootstrap path when no host, environment, or explicit session ID + * can be resolved. Callers surface the hint instead of installing a hidden + * client-global session pointer. + */ +export async function createBootstrapSession( + projectRoot: string, + input: Pick & { now?: Date }, +): Promise { + const session = await createSession(projectRoot, { + actorId: input.actorId, + client: input.client, + identitySource: 'explicit', + now: input.now, + }); + return { + session, + environment: { MANCODE_SESSION_ID: session.sessionId }, + hint: `export MANCODE_SESSION_ID=${session.sessionId}`, + }; +} + +/** + * Binds only this active session's convenience pointer after the resolver has + * already validated TaskRef, workflow dimensions, repair state, and + * freshness. Workflow state remains authoritative and is never changed here. + */ +export async function resumeSession( + projectRoot: string, + sessionId: Ulid, + input: ResumeSessionInput, +): Promise { + assertUlid(sessionId, 'sessionId'); + const taskRef = parseTaskRefValue(input.taskRef); + const workflowMode = parseWorkflowMode(input.workflowMode); + const taskRevision = parseRevision( + input.taskRevision, + 'session taskRevision', + ); + return withSessionMutationLock(projectRoot, sessionId, async () => { + const session = await readSession(projectRoot, sessionId); + if (session === null || session.status !== 'active') { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + const updated: SessionStateV1 = { + ...session, + activeTaskRef: taskRef, + activeMode: workflowMode, + lastSeenRevision: taskRevision, + updatedAt: (input.now ?? new Date()).toISOString(), + }; + await writeSession(projectRoot, updated); + return updated; + }); +} + +/** + * Clears only this session's convenience pointer. Workflow authority must + * already be terminal or otherwise durable; a mismatched current pointer is + * deliberately left untouched so a later session switch cannot be erased. + */ +export async function clearSessionTaskPointer( + projectRoot: string, + sessionId: Ulid, + input: ClearSessionTaskPointerInput = {}, +): Promise { + assertUlid(sessionId, 'sessionId'); + const expected = + input.expectedTaskRef === undefined + ? undefined + : parseTaskRefValue(input.expectedTaskRef); + return withSessionMutationLock(projectRoot, sessionId, async () => { + const session = await readSession(projectRoot, sessionId); + if (session === null || session.status !== 'active') { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + if ( + expected !== undefined && + (session.activeTaskRef === null || + session.activeTaskRef.namespace !== expected.namespace || + session.activeTaskRef.taskId !== expected.taskId) + ) { + return session; + } + const updated: SessionStateV1 = { + ...session, + activeTaskRef: null, + activeMode: null, + lastSeenRevision: null, + updatedAt: (input.now ?? new Date()).toISOString(), + }; + await writeSession(projectRoot, updated); + return updated; + }); +} + +/** Subagents share a session but must use distinct execution identities. */ +export async function attachSessionExecution( + projectRoot: string, + sessionId: Ulid, + executionId: Ulid, + now: Date = new Date(), +): Promise { + assertUlid(sessionId, 'sessionId'); + assertUlid(executionId, 'executionId'); + return withSessionMutationLock(projectRoot, sessionId, async () => { + const session = await readSession(projectRoot, sessionId); + if (session === null || session.status !== 'active') { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + if (session.executionIds.includes(executionId)) return session; + const updated: SessionStateV1 = { + ...session, + executionIds: [...session.executionIds, executionId], + updatedAt: now.toISOString(), + }; + await writeSession(projectRoot, updated); + return updated; + }); +} + +export async function closeSession( + projectRoot: string, + sessionId: Ulid, + now: Date = new Date(), +): Promise { + assertUlid(sessionId, 'sessionId'); + return withSessionMutationLock(projectRoot, sessionId, async () => { + const session = await readSession(projectRoot, sessionId); + if (!session) throw new Error('MANCODE_SESSION_NOT_FOUND'); + if (session.status === 'closed') return session; + const closedAt = now.toISOString(); + const closed: SessionStateV1 = { + ...session, + status: 'closed', + activeTaskRef: null, + activeMode: null, + closedAt, + updatedAt: closedAt, + }; + await writeSession(projectRoot, closed); + return closed; + }); +} + +/** + * Explicit and environment IDs must already exist. A verified host identity + * may create its own session record, never a client-global fallback pointer. + */ +export async function resolveSessionCandidate( + projectRoot: string, + candidate: SessionIdentityCandidate | null, + actorId?: Ulid, +): Promise { + if (candidate === null) return null; + if (candidate.internalSessionId) { + const existing = await readSession( + projectRoot, + candidate.internalSessionId, + ); + return assertUsableSession(existing, candidate.client); + } + if (!candidate.externalKeyHash) { + throw new Error('MANCODE_SESSION_REQUIRED'); + } + const existing = await findHostSession( + projectRoot, + candidate.client, + candidate.externalKeyHash, + ); + if (existing) return existing; + if (!actorId) throw new Error('MANCODE_SESSION_REQUIRED'); + return createSession(projectRoot, { + actorId, + client: candidate.client, + identitySource: 'host', + identityLookupKeyHash: candidate.externalKeyHash, + }); +} + +export function parseSessionState(value: unknown): SessionStateV1 { + assertRecord(value, 'session state'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'sessionId', + 'identitySource', + 'identityLookupKeyHash', + 'actorId', + 'client', + 'status', + 'activeTaskRef', + 'activeMode', + 'lastSeenRevision', + 'executionIds', + 'startedAt', + 'closedAt', + 'updatedAt', + ], + 'session state', + ); + if (value.schemaVersion !== 1) + throw new Error('session schemaVersion must be 1'); + assertUlid(value.sessionId, 'sessionId'); + assertUlid(value.actorId, 'session actorId'); + if ( + typeof value.identitySource !== 'string' || + !SESSION_SOURCES.has(value.identitySource as SessionIdentitySource) + ) { + throw new Error('session identitySource is invalid'); + } + if (value.status !== 'active' && value.status !== 'closed') { + throw new Error('session status is invalid'); + } + const identityLookupKeyHash = parseIdentityLookupKeyHash( + value.identitySource as SessionIdentitySource, + value.identityLookupKeyHash, + ); + const activeTaskRef = + value.activeTaskRef === null + ? null + : parseTaskRefValue(value.activeTaskRef); + const activeMode = + value.activeMode === null ? null : parseWorkflowMode(value.activeMode); + if ((activeTaskRef === null) !== (activeMode === null)) { + throw new Error( + 'session activeTaskRef and activeMode must be set together', + ); + } + const closedAt = parseTimestampOrNull(value.closedAt, 'session closedAt'); + if ((value.status === 'closed') !== (closedAt !== null)) { + throw new Error( + 'closed sessions require closedAt and active sessions must not have one', + ); + } + return { + schemaVersion: 1, + sessionId: value.sessionId, + identitySource: value.identitySource as SessionIdentitySource, + identityLookupKeyHash, + actorId: value.actorId, + client: parseClient(value.client), + status: value.status, + activeTaskRef, + activeMode, + lastSeenRevision: parseRevisionOrNull(value.lastSeenRevision), + executionIds: parseExecutionIds(value.executionIds), + startedAt: parseTimestamp(value.startedAt, 'session startedAt'), + closedAt, + updatedAt: parseTimestamp(value.updatedAt, 'session updatedAt'), + }; +} + +function sessionDirectory(projectRoot: string): string { + return path.join(projectRoot, '.mancode', 'local', 'sessions'); +} + +function sessionPath(projectRoot: string, sessionId: string): string { + assertUlid(sessionId, 'sessionId'); + return path.join(sessionDirectory(projectRoot), `${sessionId}.json`); +} + +function parseClient(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error('session client is required'); + } + return value.trim(); +} + +function parseIdentityLookupKeyHash( + source: SessionIdentitySource, + value: unknown, +): string | null { + if (source === 'host') { + if (typeof value !== 'string' || !SESSION_HASH_PATTERN.test(value)) { + throw new Error('host sessions require identityLookupKeyHash'); + } + return value; + } + if (value !== null) { + throw new Error( + 'explicit/env sessions must not persist identityLookupKeyHash', + ); + } + return null; +} + +function parseRevisionOrNull(value: unknown): number | null { + if (value === null) return null; + return parseRevision(value, 'session lastSeenRevision'); +} + +function parseExecutionIds(value: unknown): Ulid[] { + if (!Array.isArray(value)) + throw new Error('session executionIds must be an array'); + const seen = new Set(); + for (const executionId of value) { + assertUlid(executionId, 'session executionId'); + if (seen.has(executionId)) { + throw new Error('session executionIds must not contain duplicates'); + } + seen.add(executionId); + } + return [...value] as Ulid[]; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function parseTimestampOrNull(value: unknown, label: string): string | null { + return value === null ? null : parseTimestamp(value, label); +} + +async function findHostSession( + projectRoot: string, + client: string, + identityLookupKeyHash: string, +): Promise { + let entries: string[]; + try { + entries = await readdir(sessionDirectory(projectRoot)); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const session = await readSession( + projectRoot, + entry.slice(0, -'.json'.length), + ); + if ( + session?.status === 'active' && + session.client === client && + session.identitySource === 'host' && + session.identityLookupKeyHash === identityLookupKeyHash + ) { + return session; + } + } + return null; +} + +async function writeSession( + projectRoot: string, + session: SessionStateV1, +): Promise { + const target = sessionPath(projectRoot, session.sessionId); + const temporary = path.join( + sessionDirectory(projectRoot), + `.${session.sessionId}.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, `${JSON.stringify(session, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + try { + await replaceFileAtomically(temporary, target); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} + +/** + * A same-session update is serialized across processes. A lock is never + * broken automatically: after a crash it is a repair signal, not authority to + * overwrite a possibly live session update. + */ +async function withSessionMutationLock( + projectRoot: string, + sessionId: Ulid, + operation: () => Promise, +): Promise { + const lock = sessionLockPath(projectRoot, sessionId); + try { + await mkdir(lock); + } catch (error) { + if (isAlreadyExists(error)) throw new Error('MANCODE_SESSION_LOCK_HELD'); + throw error; + } + try { + return await operation(); + } finally { + await rmdir(lock); + } +} + +function sessionLockPath(projectRoot: string, sessionId: Ulid): string { + assertUlid(sessionId, 'sessionId'); + return path.join(sessionDirectory(projectRoot), `.${sessionId}.lock`); +} + +function parseRevision(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function assertUsableSession( + session: SessionStateV1 | null, + client: string, +): SessionStateV1 { + if (!session || session.status !== 'active' || session.client !== client) { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + return session; +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/task-head-fence.ts b/src/runtime/task-head-fence.ts new file mode 100644 index 0000000..4cd7368 --- /dev/null +++ b/src/runtime/task-head-fence.ts @@ -0,0 +1,218 @@ +import { + type TaskAggregateManifestV1, + taskAggregateDigest, +} from '../context/aggregate.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; + +export interface TaskHeadFenceV1 { + schemaVersion: 1; + workspaceId: Ulid; + taskRef: TaskRef; + fenceRevision: number; + taskRevision: number; + aggregateDigest: string; + ownershipEpoch: number; + codeRef: { + head: string; + }; + checkoutId: Ulid; + remoteRevision: number | null; + lastOperationId: Ulid; + updatedAt: string; +} + +export interface TaskHeadFenceTransitionOptions { + expectedFenceRevision: number; + /** Only explicit reconcile may adopt a new aggregate or code head at the same task revision. */ + allowSameTaskRevision?: boolean; +} + +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function parseTaskHeadFence(value: unknown): TaskHeadFenceV1 { + assertRecord(value, 'task head fence'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'workspaceId', + 'taskRef', + 'fenceRevision', + 'taskRevision', + 'aggregateDigest', + 'ownershipEpoch', + 'codeRef', + 'checkoutId', + 'remoteRevision', + 'lastOperationId', + 'updatedAt', + ], + 'task head fence', + ); + if (value.schemaVersion !== 1) { + throw new Error('task head fence schemaVersion must be 1'); + } + assertUlid(value.workspaceId, 'task head fence workspaceId'); + const taskRef = parseTaskRefValue(value.taskRef); + if (taskRef.namespace !== 'shared') { + throw new Error('task head fences may only target shared TaskRefs'); + } + assertUlid(value.checkoutId, 'task head fence checkoutId'); + assertUlid(value.lastOperationId, 'task head fence lastOperationId'); + return { + schemaVersion: 1, + workspaceId: value.workspaceId, + taskRef, + fenceRevision: parsePositiveInteger( + value.fenceRevision, + 'task head fence fenceRevision', + ), + taskRevision: parsePositiveInteger( + value.taskRevision, + 'task head fence taskRevision', + ), + aggregateDigest: parseDigest( + value.aggregateDigest, + 'task head fence aggregateDigest', + ), + ownershipEpoch: parseNonNegativeInteger( + value.ownershipEpoch, + 'task head fence ownershipEpoch', + ), + codeRef: parseCodeRef(value.codeRef), + checkoutId: value.checkoutId, + remoteRevision: parseNonNegativeIntegerOrNull( + value.remoteRevision, + 'task head fence remoteRevision', + ), + lastOperationId: value.lastOperationId, + updatedAt: parseTimestamp(value.updatedAt, 'task head fence updatedAt'), + }; +} + +export function assertTaskHeadFenceMatchesAggregate( + fence: TaskHeadFenceV1, + manifest: TaskAggregateManifestV1, + codeHead: string, +): void { + const normalizedCodeHead = parseCodeHead( + codeHead, + 'task head fence codeHead', + ); + if ( + !sameTaskRef(fence.taskRef, manifest.taskRef) || + fence.taskRevision !== manifest.taskRevision || + fence.ownershipEpoch !== manifest.ownershipEpoch || + fence.aggregateDigest !== taskAggregateDigest(manifest) || + fence.codeRef.head !== normalizedCodeHead + ) { + throw new Error( + 'task aggregate does not match the shared task head fence; reconcile or repair is required', + ); + } +} + +export function assertTaskHeadFenceTransition( + previous: TaskHeadFenceV1, + next: TaskHeadFenceV1, + options: TaskHeadFenceTransitionOptions, +): void { + if ( + !Number.isSafeInteger(options.expectedFenceRevision) || + options.expectedFenceRevision < 1 + ) { + throw new Error( + 'task head fence expectedFenceRevision must be a positive integer', + ); + } + if (previous.fenceRevision !== options.expectedFenceRevision) { + throw new Error('MANCODE_TASK_HEAD_FENCE_CONFLICT'); + } + if ( + previous.schemaVersion !== next.schemaVersion || + previous.workspaceId !== next.workspaceId || + !sameTaskRef(previous.taskRef, next.taskRef) + ) { + throw new Error( + 'task head fence schema, workspace, and TaskRef are immutable', + ); + } + if (next.fenceRevision !== previous.fenceRevision + 1) { + throw new Error('task head fence fenceRevision must increase exactly once'); + } + if (next.taskRevision < previous.taskRevision) { + throw new Error('task head fence taskRevision cannot decrease'); + } + if ( + next.taskRevision === previous.taskRevision && + options.allowSameTaskRevision !== true + ) { + throw new Error( + 'task head fence may only adopt the same taskRevision during explicit reconcile', + ); + } + if ( + next.taskRevision === previous.taskRevision && + next.aggregateDigest === previous.aggregateDigest && + next.codeRef.head === previous.codeRef.head + ) { + throw new Error( + 'same-revision task head reconcile requires a different aggregate digest or code head', + ); + } +} + +function parseCodeRef(value: unknown): TaskHeadFenceV1['codeRef'] { + assertRecord(value, 'task head fence codeRef'); + assertKnownKeys(value, ['head'], 'task head fence codeRef'); + return { head: parseCodeHead(value.head, 'task head fence codeRef head') }; +} + +function parseCodeHead(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error(`${label} is required`); + } + return value; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parseNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseNonNegativeIntegerOrNull( + value: unknown, + label: string, +): number | null { + if (value === null) return null; + return parseNonNegativeInteger(value, label); +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} diff --git a/src/runtime/task-head-store.ts b/src/runtime/task-head-store.ts new file mode 100644 index 0000000..3c12105 --- /dev/null +++ b/src/runtime/task-head-store.ts @@ -0,0 +1,124 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { type TaskRef, parseTaskRefValue } from '../context/task-ref.js'; +import { replaceFileAtomically } from './atomic-file.js'; +import { + type EntityHomeStore, + taskHeadDirectory, +} from './entity-home-store.js'; +import { type TaskHeadFenceV1, parseTaskHeadFence } from './task-head-fence.js'; + +/** + * The task-head fence is coordination-store authority, not an artifact under + * the task directory. Keeping its storage API separate prevents ordinary + * workflow writers from accidentally treating it as a local projection. + */ +export async function createTaskHeadFence( + store: EntityHomeStore, + fence: TaskHeadFenceV1, +): Promise { + const parsed = parseTaskHeadFence(fence); + const target = taskHeadFencePath(store, parsed.taskRef); + await mkdir(path.dirname(target), { recursive: true }); + try { + await writeFile(target, serialize(parsed), { + encoding: 'utf8', + flag: 'wx', + }); + return parsed; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readTaskHeadFence(store, parsed.taskRef); + if ( + existing !== null && + digestCanonicalJson(existing) === digestCanonicalJson(parsed) + ) { + return existing; + } + throw new Error('MANCODE_TASK_HEAD_FENCE_CONFLICT'); + } +} + +/** Writes a replacement only after the caller has performed its fence CAS. */ +export async function replaceTaskHeadFence( + store: EntityHomeStore, + fence: TaskHeadFenceV1, +): Promise { + const parsed = parseTaskHeadFence(fence); + const target = taskHeadFencePath(store, parsed.taskRef); + await mkdir(path.dirname(target), { recursive: true }); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, serialize(parsed), { + encoding: 'utf8', + flag: 'wx', + }); + await replaceFileAtomically(temporary, target); + return parsed; +} + +export async function readTaskHeadFence( + store: EntityHomeStore, + taskRef: TaskRef, +): Promise { + const parsedTaskRef = parseTaskRefValue(taskRef); + if (parsedTaskRef.namespace !== 'shared') { + throw new Error('task head fences require a shared TaskRef'); + } + try { + const parsed = parseTaskHeadFence( + JSON.parse( + await readFile(taskHeadFencePath(store, parsedTaskRef), 'utf8'), + ), + ); + if ( + parsed.taskRef.namespace !== parsedTaskRef.namespace || + parsed.taskRef.taskId !== parsedTaskRef.taskId + ) { + throw new Error('MANCODE_TASK_HEAD_FENCE_CORRUPT'); + } + return parsed; + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_TASK_HEAD_FENCE_CORRUPT'); + } + throw error; + } +} + +export function taskHeadFencePath( + store: EntityHomeStore, + taskRef: TaskRef, +): string { + const parsedTaskRef = parseTaskRefValue(taskRef); + if (parsedTaskRef.namespace !== 'shared') { + throw new Error('task head fences require a shared TaskRef'); + } + return path.join(taskHeadDirectory(store), `${parsedTaskRef.taskId}.json`); +} + +function serialize(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/task-operation.ts b/src/runtime/task-operation.ts new file mode 100644 index 0000000..127c0f9 --- /dev/null +++ b/src/runtime/task-operation.ts @@ -0,0 +1,775 @@ +import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { assertCompatibilityGate } from '../context/compatibility.js'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { scanLegacyAuthority } from '../context/layout.js'; +import { + type StoredCoordinationSnapshot, + type StoredProjectSnapshot, + type StoredTaskSnapshot, + V3ContextStore, +} from '../context/store.js'; +import { + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { assertParentWorkflowRelation } from '../context/workflow-metadata.js'; +import { readSharedActorProfile } from '../team/actor.js'; +import { + type AuthorizationAction, + type AuthorizationClaimContext, + type AuthorizationConditions, + type AuthorizationEvidenceContext, + type AuthorizationHandoffContext, + type AuthorizationTaskContext, + createAuthorizationBasis, +} from '../team/authorization.js'; +import { + type CheckpointV1, + checkpointDigest, + parseCheckpoint, +} from '../team/checkpoints.js'; +import { assertTransportCoordinationWriteAllowed } from '../team/transport-migration-freeze.js'; +import { capabilitiesFromProjectConfig } from '../team/transport.js'; +import { VERSION } from '../version.js'; +import { replaceFileAtomically } from './atomic-file.js'; +import { + type EntityHomeStore, + resolveTaskEntityHomeStore, +} from './entity-home-store.js'; +import { + type LocalLockHandle, + type OperationEntityLockTarget, + acquireOperationEntityLocks, +} from './local-lock.js'; +import { + armOperationCrashAfterVisibleWrite, + throwIfDeferredOperationCrashInjected, + throwIfOperationCrashInjected, +} from './operation-crash-injection.js'; +import { + assertOperationJournalMatchesDefinition, + getOperationDefinition, +} from './operation-definition.js'; +import type { + OperationJournalV1, + OperationStep, + OperationType, +} from './operation-journal.js'; +import { + type OperationRecoveryActionV1, + TASK_AUTHORITY_FILE_NAMES, + type TaskAuthorityFileName, + assertOperationRecoveryPayloadCoversJournal, + operationRecoveryPayloadDigest, + parseOperationRecoveryPayload, +} from './operation-recovery-payload.js'; +import { writeOperationRecoveryPayload } from './operation-recovery-store.js'; +import { + createPreparedOperationJournal, + updateOperationJournal, +} from './operation-store.js'; +import { + type ProjectRuntimeContext, + readCheckoutCodeHead, + readProjectRuntimeContext, +} from './project-runtime.js'; +import { type SessionStateV1, readSession } from './session.js'; +import { assertTaskHeadFenceMatchesAggregate } from './task-head-fence.js'; + +export interface OpenV3TaskOperationInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + operationId?: Ulid; + /** Additional entities use the same task-store lock family. */ + extraEntityLocks?: string[]; + /** + * Extra entity-home stores needed by a single multi-store operation. They + * are acquired together with the task store in global store/key order. + */ + additionalEntityLockTargets?: OperationEntityLockTarget[]; + /** Reserved for the explicit task-head reconcile adoption operation. */ + allowTaskHeadFenceMismatch?: boolean; + now?: Date; +} + +export interface OpenedV3TaskOperation { + projectRoot: string; + taskRef: TaskRef; + operationId: Ulid; + now: Date; + runtime: ProjectRuntimeContext; + project: StoredProjectSnapshot; + store: V3ContextStore; + homeStore: EntityHomeStore; + session: SessionStateV1; + task: StoredTaskSnapshot; + coordination: StoredCoordinationSnapshot; + codeHead: string | null; + entityLocks: string[]; + renewLocks(): Promise; + release(): Promise; +} + +export interface CreateTaskOperationJournalInput { + type: OperationType; + action: AuthorizationAction; + expectedRevisions: Record; + entityLocks?: string[]; + conditions?: AuthorizationConditions; + task?: AuthorizationTaskContext | null; + claim?: AuthorizationClaimContext | null; + handoff?: AuthorizationHandoffContext | null; + evidence?: AuthorizationEvidenceContext | null; + profileActorId?: Ulid | null; + /** Exact targets that make a crash after a durable write intent repairable. */ + recovery?: { + actions: OperationRecoveryActionV1[]; + noOpStepIds?: string[]; + }; +} + +const TASK_AUTHORITY_FILES = new Set(TASK_AUTHORITY_FILE_NAMES); + +/** + * Opens a V3 task mutation only after all current authority has been reread + * under the canonical task lock. This prevents a stale caller from composing + * a journal using a snapshot captured before another writer committed. + */ +export async function openV3TaskOperation( + input: OpenV3TaskOperationInput, +): Promise { + const projectRoot = path.resolve(requireProjectRoot(input.projectRoot)); + const taskRef = parseTaskRefValue(input.taskRef); + assertUlid(input.sessionId, 'task operation sessionId'); + assertRevision( + input.expectedTaskRevision, + 'task operation expectedTaskRevision', + ); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'task operation operationId'); + const runtime = await readProjectRuntimeContext(projectRoot); + const store = new V3ContextStore(projectRoot); + const [project, legacy, session] = await Promise.all([ + store.readProjectSnapshot(), + scanLegacyAuthority(projectRoot), + readSession(projectRoot, input.sessionId), + ]); + if (session === null || session.status !== 'active') { + throw new Error('MANCODE_SESSION_NOT_FOUND'); + } + assertCompatibilityGate({ + manifest: project.manifest, + expectedSchemaEpoch: project.manifest.epoch, + readerVersion: VERSION, + writerVersion: VERSION, + adapterVersions: project.manifest.managedAdapters, + currentLegacyBaseline: legacy.baseline, + legacyAuthorityPresent: legacy.authorityPresent, + operation: 'v3_business_write', + }); + const homeStore = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + taskRef, + ); + const taskKey = taskEntityKey(taskRef); + const entityLocks = uniqueEntityLocks([ + taskKey, + ...(input.extraEntityLocks ?? []), + ]); + const locks = await acquireOperationEntityLocks( + operationId, + [ + { store: homeStore, entityLockKeys: entityLocks }, + ...(input.additionalEntityLockTargets ?? []), + ], + { now }, + ); + try { + if (taskRef.namespace === 'shared') { + await assertTransportCoordinationWriteAllowed(homeStore, project.config); + } + const [task, coordination, codeHead] = await Promise.all([ + store.readTaskSnapshot(taskRef), + store.readCoordinationSnapshot(taskRef, homeStore), + taskRef.namespace === 'shared' + ? readCheckoutCodeHead(projectRoot) + : Promise.resolve(null), + ]); + if (task.aggregate === null || task.metadata.transitionState !== 'stable') { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } + if (coordination.pendingOperations.length > 0) { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } + if (task.metadata.revision !== input.expectedTaskRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + await assertTaskParentIsWritable(store, task); + if (taskRef.namespace === 'shared') { + if (coordination.taskHeadFence === null || codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + } + if (input.allowTaskHeadFenceMismatch !== true) { + assertTaskHeadFenceMatchesAggregate( + coordination.taskHeadFence, + task.aggregate, + codeHead, + ); + } + } + return { + projectRoot, + taskRef, + operationId, + now, + runtime, + project, + store, + homeStore, + session, + task, + coordination, + codeHead, + entityLocks, + async renewLocks(): Promise { + await Promise.all(locks.map((lock) => lock.renew())); + }, + async release(): Promise { + await releaseLocks(locks); + }, + }; + } catch (error) { + await releaseLocks(locks); + throw error; + } +} + +/** + * A child may continue to be read for diagnosis after its parent changes, but + * no child mutation may race ahead of the parent snapshot it was created from. + * This check lives at the shared task-operation boundary so every V3 writer + * receives the same gate rather than relying on individual command handlers. + */ +async function assertTaskParentIsWritable( + store: V3ContextStore, + task: StoredTaskSnapshot, +): Promise { + if (task.metadata.parent === null) return; + try { + const parent = await store.readParentSnapshot(task.metadata); + if (parent === null) throw new Error('MANCODE_PARENT_UNAVAILABLE'); + assertParentWorkflowRelation(task.metadata, parent.metadata); + if (parent.staleReasons.length > 0) { + throw new Error('MANCODE_PARENT_STALE'); + } + } catch (error) { + if (error instanceof Error && error.message === 'MANCODE_PARENT_STALE') { + throw error; + } + throw new Error('MANCODE_PARENT_UNAVAILABLE'); + } +} + +/** + * Writes the durable prepared journal after authorizing against the locked + * snapshot. Operation definitions still validate all required key families. + */ +export async function createTaskOperationJournal( + context: OpenedV3TaskOperation, + input: CreateTaskOperationJournalInput, +): Promise { + const action = input.action; + const shared = context.taskRef.namespace === 'shared'; + if (shared) { + await assertTransportCoordinationWriteAllowed( + context.homeStore, + context.project.config, + ); + } + const joined = shared + ? (await readSharedActorProfile( + context.projectRoot, + context.session.actorId, + )) !== null + : false; + const taskContext: AuthorizationTaskContext = { + ownerActorId: context.task.metadata.ownerActorId, + participantActorIds: context.task.metadata.participants, + }; + const authorizationBasis = createAuthorizationBasis( + { + action, + actorId: context.session.actorId, + session: { + sessionId: context.session.sessionId, + actorId: context.session.actorId, + status: context.session.status, + }, + joined, + sharedWriteGuard: capabilitiesFromProjectConfig(context.project.config) + .writeGuard, + task: input.task ?? taskContext, + claim: input.claim ?? null, + handoff: input.handoff ?? null, + evidence: input.evidence ?? null, + profileActorId: input.profileActorId ?? null, + conditions: { + expectedRevisionMatches: true, + ownershipEpochFresh: shared, + ...input.conditions, + }, + }, + context.now, + ); + const definition = getOperationDefinition(input.type); + const entityLocks = uniqueEntityLocks([ + ...context.entityLocks, + ...(input.entityLocks ?? []), + ]); + const recovery = + input.recovery === undefined + ? null + : parseOperationRecoveryPayload({ + schemaVersion: 1, + operationId: context.operationId, + type: input.type, + primaryStoreId: context.homeStore.storeId, + actions: input.recovery.actions, + noOpStepIds: input.recovery.noOpStepIds ?? [], + }); + const journal: OperationJournalV1 = { + schemaVersion: 1, + operationId: context.operationId, + type: input.type, + state: 'prepared', + primaryStoreId: context.homeStore.storeId, + checkoutId: context.runtime.checkoutId, + secondaryReservations: [], + actorId: context.session.actorId, + sessionId: context.session.sessionId, + authorizationBasis, + ...(recovery === null + ? {} + : { recoveryPayloadDigest: operationRecoveryPayloadDigest(recovery) }), + entityLocks, + expectedRevisions: { ...input.expectedRevisions }, + steps: definition.steps.map((step) => ({ id: step.id, state: 'pending' })), + startedAt: context.now.toISOString(), + updatedAt: context.now.toISOString(), + }; + assertOperationJournalMatchesDefinition(journal); + if (recovery !== null) { + assertOperationRecoveryPayloadCoversJournal(journal, recovery); + await writeOperationRecoveryPayload(context.homeStore, recovery); + } + const created = await createPreparedOperationJournal( + context.homeStore, + journal, + ); + throwIfOperationCrashInjected(input.type, 'prepared'); + return created; +} + +/** Mark a step's durable intent before executing the corresponding write. */ +export async function advanceTaskOperation( + context: OpenedV3TaskOperation, + previous: OperationJournalV1, + stepId: string, + canAbort: boolean, +): Promise { + throwIfDeferredOperationCrashInjected(previous.type); + await context.renewLocks(); + const next = await updateOperationJournal( + context.homeStore, + { + ...previous, + state: 'applying', + steps: completeOperationStep(previous.steps, stepId), + updatedAt: context.now.toISOString(), + }, + { canAbort }, + ); + injectAfterTaskOperationStep(previous.type, stepId); + return next; +} + +export async function commitTaskOperation( + context: OpenedV3TaskOperation, + previous: OperationJournalV1, +): Promise { + throwIfDeferredOperationCrashInjected(previous.type); + await context.renewLocks(); + const next = await updateOperationJournal( + context.homeStore, + { + ...previous, + state: 'committed', + steps: completeOperationStep(previous.steps, 'commit'), + updatedAt: context.now.toISOString(), + }, + { canAbort: false }, + ); + throwIfOperationCrashInjected(previous.type, 'commit'); + return next; +} + +export async function handleTaskOperationFailure( + context: OpenedV3TaskOperation, + journal: OperationJournalV1, +): Promise { + if (journal.state === 'committed' || journal.state === 'aborted') return; + if (hasBusinessWriteIntent(journal)) { + try { + await updateOperationJournal( + context.homeStore, + { + ...journal, + state: 'repair_required', + updatedAt: context.now.toISOString(), + }, + { canAbort: false }, + ); + } catch { + // The already-durable write intent remains enough to block normal work. + } + return; + } + await updateOperationJournal( + context.homeStore, + { + ...journal, + state: 'aborted', + updatedAt: context.now.toISOString(), + }, + { canAbort: true }, + ); +} + +/** Fixed-name authority writer; callers cannot smuggle arbitrary paths. */ +export async function writeTaskAuthorityFile( + context: OpenedV3TaskOperation, + fileName: string, + content: string, +): Promise { + await writeTaskAuthorityFileAtRoot( + context.task.location.taskRoot, + context.operationId, + fileName, + content, + ); +} + +/** Fixed-name authority reader used by forward repair; it never follows links. */ +export async function readTaskAuthorityFileAtRoot( + taskRoot: string, + fileName: TaskAuthorityFileName, +): Promise { + assertTaskAuthorityFileName(fileName); + await assertSafeTaskDirectory(taskRoot); + const target = path.join(taskRoot, fileName); + try { + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const content = await readFile(target, 'utf8'); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return content; + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +/** Fixed-name authority writer usable by normal paths and recovery alike. */ +export async function writeTaskAuthorityFileAtRoot( + taskRoot: string, + operationId: Ulid, + fileName: string, + content: string, +): Promise { + if (!TASK_AUTHORITY_FILES.has(fileName) || content.includes('\0')) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + assertUlid(operationId, 'task authority operationId'); + await assertSafeTaskDirectory(taskRoot); + const target = path.join(taskRoot, fileName); + const temporary = path.join( + taskRoot, + `.${fileName}.${operationId}.${process.pid}.tmp`, + ); + await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' }); + try { + await replaceFileAtomically(temporary, target); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } + const written = await lstat(target); + if (!written.isFile() || written.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } +} + +/** + * Creates one immutable checkpoint under its canonical task-local path. A + * retry may observe the exact same snapshot, but a different value for the + * same checkpoint ID is always a durable conflict rather than an overwrite. + */ +export async function writeTaskCheckpoint( + context: OpenedV3TaskOperation, + value: CheckpointV1, +): Promise { + const checkpoint = parseCheckpoint(value); + if ( + !sameTaskRef(checkpoint.taskRef, context.taskRef) || + checkpoint.operationId !== context.operationId + ) { + throw new Error('MANCODE_CHECKPOINT_OPERATION_MISMATCH'); + } + return writeTaskCheckpointAtRoot(context.task.location.taskRoot, checkpoint); +} + +/** Immutable checkpoint writer usable by the original operation and repair. */ +export async function writeTaskCheckpointAtRoot( + taskRoot: string, + value: CheckpointV1, +): Promise { + const checkpoint = parseCheckpoint(value); + await assertSafeTaskDirectory(taskRoot); + const directory = await ensureSafeTaskChildDirectory(taskRoot, 'checkpoints'); + const target = path.join(directory, `${checkpoint.checkpointId}.json`); + const serialized = serializeTaskAuthority(checkpoint); + try { + await writeFile(target, serialized, { encoding: 'utf8', flag: 'wx' }); + const written = await lstat(target); + if (!written.isFile() || written.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return checkpoint; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readImmutableCheckpoint(target); + if (checkpointDigest(existing) === checkpointDigest(checkpoint)) { + return existing; + } + throw new Error('MANCODE_CHECKPOINT_ID_CONFLICT'); + } +} + +/** Reads a fixed checkpoint path without permitting a linked task subtree. */ +export async function readTaskCheckpointAtRoot( + taskRoot: string, + checkpointId: Ulid, +): Promise { + assertUlid(checkpointId, 'checkpointId'); + await assertSafeTaskDirectory(taskRoot); + const directory = path.join(taskRoot, 'checkpoints'); + try { + const entry = await lstat(directory); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } + try { + return await readImmutableCheckpoint( + path.join(directory, `${checkpointId}.json`), + ); + } catch (error) { + if ( + error instanceof Error && + error.message === 'MANCODE_CHECKPOINT_ID_CONFLICT' + ) { + return null; + } + throw error; + } +} + +export function serializeTaskAuthority(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +export function taskEntityKey(taskRef: TaskRef): string { + const parsed = parseTaskRefValue(taskRef); + return `task:${parsed.namespace}:${parsed.taskId}`; +} + +export function taskHeadEntityKey(taskRef: TaskRef): string { + const parsed = parseTaskRefValue(taskRef); + if (parsed.namespace !== 'shared') { + throw new Error('task head entity keys require shared TaskRef'); + } + return `task_head:${parsed.taskId}`; +} + +function completeOperationStep( + steps: OperationStep[], + stepId: string, +): OperationStep[] { + const index = steps.findIndex((step) => step.id === stepId); + if (index < 0) throw new Error('MANCODE_OPERATION_STEP_INVALID'); + if (steps[index]?.state === 'completed') { + throw new Error('MANCODE_OPERATION_STEP_ALREADY_COMPLETED'); + } + if (steps.slice(0, index).some((step) => step.state !== 'completed')) { + throw new Error('MANCODE_OPERATION_STEP_ORDER_INVALID'); + } + return steps.map((step, currentIndex) => + currentIndex === index ? { ...step, state: 'completed' as const } : step, + ); +} + +function injectAfterTaskOperationStep( + operationType: OperationType, + stepId: string, +): void { + const step = getOperationDefinition(operationType).steps.find( + (candidate) => candidate.id === stepId, + ); + if (step?.visibility === 'business_write') { + armOperationCrashAfterVisibleWrite(operationType, stepId); + return; + } + throwIfOperationCrashInjected(operationType, stepId); +} + +function hasBusinessWriteIntent(journal: OperationJournalV1): boolean { + const definition = getOperationDefinition(journal.type); + return journal.steps.some( + (step, index) => + step.state === 'completed' && + definition.steps[index]?.visibility === 'business_write', + ); +} + +function assertTaskAuthorityFileName( + value: string, +): asserts value is TaskAuthorityFileName { + if (!TASK_AUTHORITY_FILES.has(value)) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } +} + +function uniqueEntityLocks(keys: string[]): string[] { + if (keys.length === 0) + throw new Error('task operation requires an entity lock'); + const unique = new Set(); + for (const key of keys) { + if (!/^[a-z][a-z0-9_-]*:[^\0/\\]+$/.test(key) || key.includes('..')) { + throw new Error('MANCODE_ENTITY_LOCK_INVALID'); + } + unique.add(key); + } + return [...unique].sort((left, right) => + Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')), + ); +} + +async function assertSafeTaskDirectory(taskRoot: string): Promise { + const entry = await lstat(taskRoot); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } +} + +async function ensureSafeTaskChildDirectory( + taskRoot: string, + child: 'checkpoints', +): Promise { + const directory = path.join(taskRoot, child); + try { + await mkdir(directory); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + const entry = await lstat(directory); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return directory; +} + +async function readImmutableCheckpoint(target: string): Promise { + let before: Awaited>; + try { + before = await lstat(target); + } catch (error) { + if (isNotFound(error)) throw new Error('MANCODE_CHECKPOINT_ID_CONFLICT'); + throw error; + } + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + try { + const checkpoint = parseCheckpoint( + JSON.parse(await readFile(target, 'utf8')), + ); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return checkpoint; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('MANCODE_CHECKPOINT_CORRUPT'); + } + throw error; + } +} + +async function releaseLocks(locks: LocalLockHandle[]): Promise { + await Promise.allSettled([...locks].reverse().map((lock) => lock.release())); +} + +function requireProjectRoot(value: string): string { + if (!value.trim() || value.includes('\0')) { + throw new Error('task operation projectRoot is required'); + } + return value; +} + +function assertRevision(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/runtime/workspace-binding.ts b/src/runtime/workspace-binding.ts new file mode 100644 index 0000000..35217c8 --- /dev/null +++ b/src/runtime/workspace-binding.ts @@ -0,0 +1,266 @@ +import { sortUtf8StringSet } from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { + type ProjectConfigV1, + projectConfigIdentityDigest, +} from '../team/policy.js'; + +export interface CommonDirRegistryV1 { + schemaVersion: 1; + workspaceIds: Ulid[]; + updatedAt: string; +} + +export interface WorkspaceBindingV1 { + schemaVersion: 1; + workspaceId: Ulid; + repositoryBindingId: Ulid; + projectPathFromWorktreeRoot: string; + configSchemaVersion: number; + configIdentityDigest: string; + registeredAt: string; +} + +export interface CheckoutBindingV1 { + schemaVersion: 1; + workspaceId: Ulid; + repositoryBindingId: Ulid; + checkoutId: Ulid; + worktreeGitDirHash: string; + projectRealpathHash: string; + registeredAt: string; + lastSeenAt: string; +} + +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function parseCommonDirRegistry(value: unknown): CommonDirRegistryV1 { + assertRecord(value, 'common-dir registry'); + assertKnownKeys( + value, + ['schemaVersion', 'workspaceIds', 'updatedAt'], + 'common-dir registry', + ); + if (value.schemaVersion !== 1) { + throw new Error('common-dir registry schemaVersion must be 1'); + } + return { + schemaVersion: 1, + workspaceIds: parseUlidSet( + value.workspaceIds, + 'common-dir registry workspaceIds', + ), + updatedAt: parseTimestamp(value.updatedAt, 'common-dir registry updatedAt'), + }; +} + +export function parseWorkspaceBinding(value: unknown): WorkspaceBindingV1 { + assertRecord(value, 'workspace binding'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'workspaceId', + 'repositoryBindingId', + 'projectPathFromWorktreeRoot', + 'configSchemaVersion', + 'configIdentityDigest', + 'registeredAt', + ], + 'workspace binding', + ); + if (value.schemaVersion !== 1) { + throw new Error('workspace binding schemaVersion must be 1'); + } + assertUlid(value.workspaceId, 'workspace binding workspaceId'); + assertUlid( + value.repositoryBindingId, + 'workspace binding repositoryBindingId', + ); + return { + schemaVersion: 1, + workspaceId: value.workspaceId, + repositoryBindingId: value.repositoryBindingId, + projectPathFromWorktreeRoot: parseProjectRelativePath( + value.projectPathFromWorktreeRoot, + ), + configSchemaVersion: parsePositiveInteger( + value.configSchemaVersion, + 'workspace binding configSchemaVersion', + ), + configIdentityDigest: parseDigest( + value.configIdentityDigest, + 'workspace binding configIdentityDigest', + ), + registeredAt: parseTimestamp( + value.registeredAt, + 'workspace binding registeredAt', + ), + }; +} + +export function parseCheckoutBinding(value: unknown): CheckoutBindingV1 { + assertRecord(value, 'checkout binding'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'workspaceId', + 'repositoryBindingId', + 'checkoutId', + 'worktreeGitDirHash', + 'projectRealpathHash', + 'registeredAt', + 'lastSeenAt', + ], + 'checkout binding', + ); + if (value.schemaVersion !== 1) { + throw new Error('checkout binding schemaVersion must be 1'); + } + assertUlid(value.workspaceId, 'checkout binding workspaceId'); + assertUlid(value.repositoryBindingId, 'checkout binding repositoryBindingId'); + assertUlid(value.checkoutId, 'checkout binding checkoutId'); + return { + schemaVersion: 1, + workspaceId: value.workspaceId, + repositoryBindingId: value.repositoryBindingId, + checkoutId: value.checkoutId, + worktreeGitDirHash: parseDigest( + value.worktreeGitDirHash, + 'checkout binding worktreeGitDirHash', + ), + projectRealpathHash: parseDigest( + value.projectRealpathHash, + 'checkout binding projectRealpathHash', + ), + registeredAt: parseTimestamp( + value.registeredAt, + 'checkout binding registeredAt', + ), + lastSeenAt: parseTimestamp(value.lastSeenAt, 'checkout binding lastSeenAt'), + }; +} + +export function assertWorkspaceBindingMatchesConfig( + binding: WorkspaceBindingV1, + config: ProjectConfigV1, +): void { + if ( + binding.workspaceId !== config.workspaceId || + binding.configSchemaVersion !== config.schemaVersion || + binding.configIdentityDigest !== projectConfigIdentityDigest(config) + ) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } +} + +export function assertWorkspaceBindingCompatible( + existing: WorkspaceBindingV1, + candidate: WorkspaceBindingV1, +): void { + if ( + existing.workspaceId !== candidate.workspaceId || + existing.repositoryBindingId !== candidate.repositoryBindingId || + existing.projectPathFromWorktreeRoot !== + candidate.projectPathFromWorktreeRoot || + existing.configSchemaVersion !== candidate.configSchemaVersion || + existing.configIdentityDigest !== candidate.configIdentityDigest + ) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } +} + +export function assertCheckoutBindingMatchesWorkspace( + checkout: CheckoutBindingV1, + workspace: WorkspaceBindingV1, +): void { + if ( + checkout.workspaceId !== workspace.workspaceId || + checkout.repositoryBindingId !== workspace.repositoryBindingId + ) { + throw new Error('MANCODE_CHECKOUT_BINDING_MISMATCH'); + } +} + +export function localCoordinationDomainId( + repositoryBindingId: Ulid, + workspaceId: Ulid, +): string { + assertUlid(repositoryBindingId, 'local coordination repositoryBindingId'); + assertUlid(workspaceId, 'local coordination workspaceId'); + return `local:${repositoryBindingId}:${workspaceId}`; +} + +export function gitRefCoordinationDomainId( + remoteIdentityHash: string, + workspaceId: Ulid, + transportEpoch: number | Ulid, +): string { + parseDigest(remoteIdentityHash, 'git-ref coordination remoteIdentityHash'); + assertUlid(workspaceId, 'git-ref coordination workspaceId'); + if (typeof transportEpoch === 'number') { + if (!Number.isSafeInteger(transportEpoch) || transportEpoch < 1) { + throw new Error( + 'git-ref coordination transportEpoch must be a positive integer', + ); + } + } else { + // Read compatibility for early V3 fixtures that used an opaque ULID epoch. + assertUlid(transportEpoch, 'git-ref coordination transportEpoch'); + } + return `git-ref:${remoteIdentityHash}:${workspaceId}:${transportEpoch}`; +} + +function parseUlidSet(value: unknown, label: string): Ulid[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + for (const item of value) assertUlid(item, label); + const normalized = sortUtf8StringSet(value); + if (normalized.length !== value.length) { + throw new Error(`${label} must not contain duplicates`); + } + return normalized as Ulid[]; +} + +function parseProjectRelativePath(value: unknown): string { + if ( + typeof value !== 'string' || + value.includes('\0') || + value.includes('\\') + ) { + throw new Error('workspace binding projectPathFromWorktreeRoot is invalid'); + } + if (value === '.') return value; + if ( + !value || + value.startsWith('/') || + value + .split('/') + .some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw new Error('workspace binding projectPathFromWorktreeRoot is invalid'); + } + return value; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} diff --git a/src/team/actor.ts b/src/team/actor.ts new file mode 100644 index 0000000..205ffb5 --- /dev/null +++ b/src/team/actor.ts @@ -0,0 +1,275 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { assertSharedTextSafe } from '../context/privacy.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; + +/** A machine-local workflow identity, not a portable account or credential. */ +export interface LocalActorIdentityV1 { + schemaVersion: 1; + actorId: Ulid; + displayName: string; + createdAt: string; + updatedAt: string; +} + +/** The deliberately small profile that `team join` may publish. */ +export interface SharedActorProfileV1 { + schemaVersion: 1; + actorId: Ulid; + displayName: string; + joinedAt: string; + updatedAt: string; +} + +export interface CreateLocalActorInput { + displayName: string; + actorId?: Ulid; + now?: Date; +} + +export interface ResolveLocalActorInput extends CreateLocalActorInput {} + +export interface ResolvedLocalActor { + actor: LocalActorIdentityV1; + created: boolean; +} + +export async function createLocalActor( + projectRoot: string, + input: CreateLocalActorInput, +): Promise { + const now = (input.now ?? new Date()).toISOString(); + const actor: LocalActorIdentityV1 = { + schemaVersion: 1, + actorId: input.actorId ?? createUlid(), + displayName: parseDisplayName(input.displayName, 'local actor displayName'), + createdAt: now, + updatedAt: now, + }; + if (input.actorId !== undefined) assertUlid(input.actorId, 'local actorId'); + await mkdir(path.dirname(localActorPath(projectRoot)), { recursive: true }); + try { + await writeFile(localActorPath(projectRoot), serialize(actor), { + encoding: 'utf8', + flag: 'wx', + }); + } catch (error) { + if (isAlreadyExists(error)) throw new Error('MANCODE_LOCAL_ACTOR_EXISTS'); + throw error; + } + return actor; +} + +/** Returns the existing machine-local identity or creates one explicitly. */ +export async function resolveOrCreateLocalActor( + projectRoot: string, + input: ResolveLocalActorInput, +): Promise { + const existing = await readLocalActor(projectRoot); + if (existing !== null) return { actor: existing, created: false }; + return { actor: await createLocalActor(projectRoot, input), created: true }; +} + +export async function readLocalActor( + projectRoot: string, +): Promise { + return readJsonOrNull(localActorPath(projectRoot), parseLocalActorIdentity); +} + +/** + * Creates the exact data that may cross the local/shared boundary. The local + * identity never gets copied wholesale, which prevents later private fields + * from accidentally becoming publishable. + */ +export function createSharedActorProfile( + actor: LocalActorIdentityV1, + now: Date = new Date(), +): SharedActorProfileV1 { + const local = parseLocalActorIdentity(actor); + assertSharedTextSafe(local.displayName, 'shared actor displayName'); + const timestamp = now.toISOString(); + return { + schemaVersion: 1, + actorId: local.actorId, + displayName: local.displayName, + joinedAt: timestamp, + updatedAt: timestamp, + }; +} + +/** + * Publishing is idempotent for the same actor/profile and rejects any actor + * ID collision that would silently merge identities from two machines. + */ +export async function publishSharedActorProfile( + projectRoot: string, + profile: SharedActorProfileV1, +): Promise { + const parsed = parseSharedActorProfile(profile); + const target = sharedActorProfilePath(projectRoot, parsed.actorId); + await mkdir(path.dirname(target), { recursive: true }); + try { + await writeFile(target, serialize(parsed), { + encoding: 'utf8', + flag: 'wx', + }); + return parsed; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readSharedActorProfile(projectRoot, parsed.actorId); + if ( + existing !== null && + existing.actorId === parsed.actorId && + existing.displayName === parsed.displayName + ) { + return existing; + } + throw new Error('MANCODE_ACTOR_PROFILE_CONFLICT'); + } +} + +export async function readSharedActorProfile( + projectRoot: string, + actorId: string, +): Promise { + assertUlid(actorId, 'shared actor profile actorId'); + return readJsonOrNull( + sharedActorProfilePath(projectRoot, actorId), + parseSharedActorProfile, + ); +} + +export function parseLocalActorIdentity(value: unknown): LocalActorIdentityV1 { + assertRecord(value, 'local actor identity'); + assertKnownKeys( + value, + ['schemaVersion', 'actorId', 'displayName', 'createdAt', 'updatedAt'], + 'local actor identity', + ); + if (value.schemaVersion !== 1) { + throw new Error('local actor identity schemaVersion must be 1'); + } + assertUlid(value.actorId, 'local actor identity actorId'); + return { + schemaVersion: 1, + actorId: value.actorId, + displayName: parseDisplayName(value.displayName, 'local actor displayName'), + createdAt: parseTimestamp(value.createdAt, 'local actor createdAt'), + updatedAt: parseTimestamp(value.updatedAt, 'local actor updatedAt'), + }; +} + +export function parseSharedActorProfile(value: unknown): SharedActorProfileV1 { + assertRecord(value, 'shared actor profile'); + assertKnownKeys( + value, + ['schemaVersion', 'actorId', 'displayName', 'joinedAt', 'updatedAt'], + 'shared actor profile', + ); + if (value.schemaVersion !== 1) { + throw new Error('shared actor profile schemaVersion must be 1'); + } + assertUlid(value.actorId, 'shared actor profile actorId'); + const displayName = parseDisplayName( + value.displayName, + 'shared actor displayName', + ); + assertSharedTextSafe(displayName, 'shared actor displayName'); + return { + schemaVersion: 1, + actorId: value.actorId, + displayName, + joinedAt: parseTimestamp(value.joinedAt, 'shared actor joinedAt'), + updatedAt: parseTimestamp(value.updatedAt, 'shared actor updatedAt'), + }; +} + +export function localActorPath(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'actor.json', + ); +} + +export function sharedActorProfileDirectory(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'shared', + 'team', + 'actors', + ); +} + +export function sharedActorProfilePath( + projectRoot: string, + actorId: string, +): string { + assertUlid(actorId, 'shared actor profile actorId'); + return path.join(sharedActorProfileDirectory(projectRoot), `${actorId}.json`); +} + +export function sharedActorProfileDigest( + profile: SharedActorProfileV1, +): string { + return digestCanonicalJson(parseSharedActorProfile(profile)); +} + +async function readJsonOrNull( + target: string, + parser: (value: unknown) => T, +): Promise { + try { + return parser(JSON.parse(await readFile(target, 'utf8'))); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) + throw new Error('MANCODE_ACTOR_RECORD_CORRUPT'); + throw error; + } +} + +function parseDisplayName(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + !value.trim() || + value.includes('\0') || + value.trim().length > 128 + ) { + throw new Error(`${label} is invalid`); + } + return value.trim(); +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function serialize(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/team/assessment.ts b/src/team/assessment.ts new file mode 100644 index 0000000..a622166 --- /dev/null +++ b/src/team/assessment.ts @@ -0,0 +1,210 @@ +import type { TeamRecommendationPolicy } from './policy.js'; + +export type TeamRecommendation = 'single' | 'team'; +export type TeamAssessmentConfidence = 'high' | 'medium' | 'low'; +export type TeamAssessmentSource = 'explicit' | 'task' | 'auto' | 'fallback'; + +export interface TeamAssessmentSignals { + isGitRepository: boolean; + remoteCount: number; + contributorsAllTime: number; + contributorsRecent: number; + hasTrackedUpstream: boolean; + hasCodeowners: boolean; + hasPullRequestTemplate: boolean; +} + +export interface TeamAssessment { + recommendation: TeamRecommendation; + confidence: TeamAssessmentConfidence; + source: TeamAssessmentSource; + signals: TeamAssessmentSignals; + reasons: string[]; + evaluatedAt: string; +} + +export interface TeamAssessmentInput { + policy: TeamRecommendationPolicy; + signals: TeamAssessmentSignals | null; + evaluatedAt: string; + explicitRecommendation?: TeamRecommendation; + taskRecommendation?: TeamRecommendation; +} + +/** + * Assessment never mutates workflow mode, visibility, or coordination. The + * caller may surface this recommendation only after creation resolution has + * honored mode, parent, and explicit user constraints. + */ +export function assessTeam(input: TeamAssessmentInput): TeamAssessment { + assertTimestamp(input.evaluatedAt); + if (input.explicitRecommendation !== undefined) { + assertRecommendation( + input.explicitRecommendation, + 'explicit recommendation', + ); + return buildAssessment( + input.explicitRecommendation, + 'high', + 'explicit', + input.signals, + [`explicit recommendation: ${input.explicitRecommendation}`], + input.evaluatedAt, + ); + } + if (input.taskRecommendation !== undefined) { + assertRecommendation(input.taskRecommendation, 'task recommendation'); + return buildAssessment( + input.taskRecommendation, + 'high', + 'task', + input.signals, + [`task constraint: ${input.taskRecommendation}`], + input.evaluatedAt, + ); + } + if (input.policy === 'on' || input.policy === 'off') { + const recommendation: TeamRecommendation = + input.policy === 'on' ? 'team' : 'single'; + return buildAssessment( + recommendation, + 'high', + 'explicit', + input.signals, + [`team policy: ${input.policy}`], + input.evaluatedAt, + ); + } + if (input.policy !== 'auto') { + throw new Error('team assessment policy is invalid'); + } + if (input.signals === null) { + return buildAssessment( + 'single', + 'low', + 'fallback', + null, + ['team signals are unavailable'], + input.evaluatedAt, + ); + } + const signals = parseSignals(input.signals); + const hasCoordinationPath = + signals.remoteCount > 0 || signals.hasTrackedUpstream; + const qualifiesForTeam = + signals.contributorsRecent > 1 && hasCoordinationPath; + if (!qualifiesForTeam) { + const reasons = [ + signals.contributorsRecent > 1 + ? 'no Git remote or tracked upstream' + : 'fewer than two recent contributors', + ]; + if (signals.contributorsAllTime > 1) { + reasons.push('historical contributors do not trigger team mode alone'); + } + if (signals.hasCodeowners || signals.hasPullRequestTemplate) { + reasons.push('repository templates do not trigger team mode alone'); + } + return buildAssessment( + 'single', + signals.isGitRepository ? 'medium' : 'low', + 'auto', + signals, + reasons, + input.evaluatedAt, + ); + } + const reasons = [ + `${signals.contributorsRecent} recent contributors`, + signals.hasTrackedUpstream ? 'tracked upstream' : 'Git remote available', + ]; + if (signals.hasCodeowners) reasons.push('CODEOWNERS present'); + if (signals.hasPullRequestTemplate) + reasons.push('pull request template present'); + if (signals.contributorsAllTime > signals.contributorsRecent) { + reasons.push(`${signals.contributorsAllTime} all-time contributors`); + } + const confidence: TeamAssessmentConfidence = + signals.hasTrackedUpstream || + signals.hasCodeowners || + signals.hasPullRequestTemplate + ? 'high' + : 'medium'; + return buildAssessment( + 'team', + confidence, + 'auto', + signals, + reasons, + input.evaluatedAt, + ); +} + +function buildAssessment( + recommendation: TeamRecommendation, + confidence: TeamAssessmentConfidence, + source: TeamAssessmentSource, + signals: TeamAssessmentSignals | null, + reasons: string[], + evaluatedAt: string, +): TeamAssessment { + return { + recommendation, + confidence, + source, + signals: + signals === null + ? { + isGitRepository: false, + remoteCount: 0, + contributorsAllTime: 0, + contributorsRecent: 0, + hasTrackedUpstream: false, + hasCodeowners: false, + hasPullRequestTemplate: false, + } + : signals, + reasons, + evaluatedAt, + }; +} + +function parseSignals(value: TeamAssessmentSignals): TeamAssessmentSignals { + const numericKeys = [ + 'remoteCount', + 'contributorsAllTime', + 'contributorsRecent', + ] as const; + for (const key of numericKeys) { + if (!Number.isSafeInteger(value[key]) || value[key] < 0) { + throw new Error(`team assessment ${key} must be a non-negative integer`); + } + } + const booleanKeys = [ + 'isGitRepository', + 'hasTrackedUpstream', + 'hasCodeowners', + 'hasPullRequestTemplate', + ] as const; + for (const key of booleanKeys) { + if (typeof value[key] !== 'boolean') { + throw new Error(`team assessment ${key} must be boolean`); + } + } + return { ...value }; +} + +function assertRecommendation( + value: unknown, + label: string, +): asserts value is TeamRecommendation { + if (value !== 'single' && value !== 'team') { + throw new Error(`${label} must be single or team`); + } +} + +function assertTimestamp(value: string): void { + if (!value || Number.isNaN(Date.parse(value))) { + throw new Error('team assessment evaluatedAt must be an ISO timestamp'); + } +} diff --git a/src/team/authorization.ts b/src/team/authorization.ts new file mode 100644 index 0000000..56d3f78 --- /dev/null +++ b/src/team/authorization.ts @@ -0,0 +1,819 @@ +import { + digestCanonicalJson, + sortUtf8StringSet, +} from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { assertSharedTextSafe } from '../context/privacy.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { type CapabilityLevel, parseCapabilityLevel } from './capabilities.js'; + +export const TEAM_TRUST_BOUNDARY = 'repo-collaborators' as const; + +export type AuthorizationAction = + | 'local_workflow_mutation' + | 'shared_create_publish_promote' + | 'shared_metadata_plan_mutation' + | 'shared_ledger_evidence' + | 'task_head_reconcile' + | 'task_complete_scope_change_child_merge' + | 'claim_create' + | 'claim_renew_release_transfer' + | 'claim_reclaim' + | 'handoff_offer_cancel' + | 'handoff_accept_reject' + | 'review_skip_or_waiver' + | 'team_policy_config_transport' + | 'actor_profile_publish' + | 'confirmed_decision_publish'; + +export interface AuthorizationSession { + sessionId: Ulid | null; + actorId: Ulid | null; + status: 'active' | 'closed' | 'missing'; +} + +export interface AuthorizationTaskContext { + ownerActorId: Ulid | null; + participantActorIds: Ulid[]; +} + +export interface AuthorizationClaimContext { + ownerActorId: Ulid; + transferTargetActorId: Ulid | null; +} + +export interface AuthorizationHandoffContext { + fromActorId: Ulid; + toActorId: Ulid; + intent: 'offer' | 'cancel' | 'accept' | 'reject'; +} + +export interface AuthorizationEvidenceContext { + assignedToActor: boolean; + restrictsWriteToAssignedItem: boolean; +} + +/** + * Action-independent checks are optional in the request but are never + * defaulted to success. A missing proof is treated as false for the relevant + * authorization rule. + */ +export interface AuthorizationConditions { + expectedRevisionMatches?: boolean; + ownershipEpochFresh?: boolean; + privacyConfirmed?: boolean; + explicitConfirmation?: boolean; + confirmedDecisionSharingEnabled?: boolean; + taskContextAvailable?: boolean; + transportFresh?: boolean; + gitSourceConfirmed?: boolean; + completionGateSatisfied?: boolean; + claimHandoffConsistent?: boolean; + implementationScopeContainsClaim?: boolean; + coordinationStoreFresh?: boolean; + requiresParentOwner?: boolean; + parentOwnerActorId?: Ulid | null; + reviewAction?: 'skip' | 'waiver'; + reviewSeverity?: 'p0' | 'p1' | 'p2' | 'legacy_unknown'; + reason?: string | null; +} + +export interface AuthorizationRequest { + action: AuthorizationAction; + actorId: Ulid; + session: AuthorizationSession; + joined: boolean; + sharedWriteGuard: CapabilityLevel; + task: AuthorizationTaskContext | null; + claim: AuthorizationClaimContext | null; + handoff: AuthorizationHandoffContext | null; + evidence: AuthorizationEvidenceContext | null; + profileActorId: Ulid | null; + conditions?: AuthorizationConditions; +} + +export type AuthorizationFailureCode = + | 'MANCODE_SESSION_REQUIRED' + | 'MANCODE_SESSION_ACTOR_MISMATCH' + | 'MANCODE_JOIN_REQUIRED' + | 'MANCODE_SHARED_WRITE_UNAVAILABLE' + | 'MANCODE_EXPECTED_REVISION_CONFLICT' + | 'MANCODE_OWNERSHIP_EPOCH_STALE' + | 'MANCODE_PRIVACY_CONFIRMATION_REQUIRED' + | 'MANCODE_TASK_OWNER_REQUIRED' + | 'MANCODE_PARTICIPANT_REQUIRED' + | 'MANCODE_CLAIM_OWNER_REQUIRED' + | 'MANCODE_SCOPE_OUTSIDE_IMPLEMENTATION_SCOPE' + | 'MANCODE_RECLAIM_REASON_REQUIRED' + | 'MANCODE_STORE_NOT_FRESH' + | 'MANCODE_HANDOFF_ACTOR_MISMATCH' + | 'MANCODE_HANDOFF_RECIPIENT_NOT_PARTICIPANT' + | 'MANCODE_TASK_UNAVAILABLE' + | 'MANCODE_TRANSPORT_UNAVAILABLE' + | 'MANCODE_COMPLETION_GATE_BLOCKED' + | 'MANCODE_PARENT_OWNER_REQUIRED' + | 'MANCODE_GIT_SOURCE_CONFIRMATION_REQUIRED' + | 'MANCODE_CLAIM_HANDOFF_INCONSISTENT' + | 'MANCODE_LEDGER_ASSIGNMENT_REQUIRED' + | 'MANCODE_LEDGER_SCOPE_MUTATION_DENIED' + | 'MANCODE_WAIVER_FORBIDDEN' + | 'MANCODE_EXPLICIT_CONFIRMATION_REQUIRED' + | 'MANCODE_PROFILE_OWNER_REQUIRED' + | 'MANCODE_CONFIRMED_DECISIONS_DISABLED'; + +export interface AuthorizationDecision { + allowed: boolean; + failures: AuthorizationFailureCode[]; + trustBoundary: typeof TEAM_TRUST_BOUNDARY; +} + +export interface AuthorizationMatrixEntry { + action: AuthorizationAction; + actorRule: string; + requiredGuards: string[]; +} + +export interface AuthorizationBasisV1 { + schemaVersion: 1; + action: AuthorizationAction; + actorId: Ulid; + sessionId: Ulid; + trustBoundary: typeof TEAM_TRUST_BOUNDARY; + decisionDigest: string; + authorizedAt: string; +} + +const AUTHORIZATION_ACTIONS = new Set([ + 'local_workflow_mutation', + 'shared_create_publish_promote', + 'shared_metadata_plan_mutation', + 'shared_ledger_evidence', + 'task_head_reconcile', + 'task_complete_scope_change_child_merge', + 'claim_create', + 'claim_renew_release_transfer', + 'claim_reclaim', + 'handoff_offer_cancel', + 'handoff_accept_reject', + 'review_skip_or_waiver', + 'team_policy_config_transport', + 'actor_profile_publish', + 'confirmed_decision_publish', +]); +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +/** The executable counterpart of the plan's cooperative authorization table. */ +export const AUTHORIZATION_MATRIX: readonly AuthorizationMatrixEntry[] = [ + { + action: 'local_workflow_mutation', + actorRule: 'current active session actor', + requiredGuards: ['expected revision'], + }, + { + action: 'shared_create_publish_promote', + actorRule: 'source owner or new task creator', + requiredGuards: ['joined', 'privacy confirmation', 'shared write guard'], + }, + { + action: 'shared_metadata_plan_mutation', + actorRule: 'current task owner', + requiredGuards: ['expected revision', 'fresh ownership epoch'], + }, + { + action: 'shared_ledger_evidence', + actorRule: 'task owner or assigned participant', + requiredGuards: ['assigned item only', 'fresh ownership epoch'], + }, + { + action: 'task_head_reconcile', + actorRule: 'current task owner', + requiredGuards: ['expected fence revision', 'Git source confirmation'], + }, + { + action: 'task_complete_scope_change_child_merge', + actorRule: 'current task owner', + requiredGuards: ['completion gate', 'fresh ownership epoch'], + }, + { + action: 'claim_create', + actorRule: 'task owner or participant', + requiredGuards: ['implementation scope subset'], + }, + { + action: 'claim_renew_release_transfer', + actorRule: 'current claim owner', + requiredGuards: ['expected claim revision', 'participant transfer target'], + }, + { + action: 'claim_reclaim', + actorRule: 'current task owner', + requiredGuards: ['fresh store', 'reason', 'expected revision'], + }, + { + action: 'handoff_offer_cancel', + actorRule: 'from actor; task owner may cancel', + requiredGuards: ['named participant recipient'], + }, + { + action: 'handoff_accept_reject', + actorRule: 'handoff recipient only', + requiredGuards: ['available task context', 'fresh transport'], + }, + { + action: 'review_skip_or_waiver', + actorRule: 'current task owner', + requiredGuards: ['reason', 'P0/legacy_unknown waiver prohibition'], + }, + { + action: 'team_policy_config_transport', + actorRule: 'joined actor', + requiredGuards: ['expected revision', 'explicit confirmation'], + }, + { + action: 'actor_profile_publish', + actorRule: 'matching local actor', + requiredGuards: ['explicit confirmation'], + }, + { + action: 'confirmed_decision_publish', + actorRule: 'joined actor', + requiredGuards: [ + 'confirmed-decision sharing enabled', + 'privacy confirmation', + 'explicit confirmation', + ], + }, +]; + +export function evaluateAuthorization( + input: AuthorizationRequest, +): AuthorizationDecision { + validateRequest(input); + const failures: AuthorizationFailureCode[] = []; + requireActiveSession(input, failures); + if (input.action !== 'local_workflow_mutation') { + if (input.action !== 'actor_profile_publish' && !input.joined) { + failures.push('MANCODE_JOIN_REQUIRED'); + } + if (input.sharedWriteGuard === 'unavailable') { + failures.push('MANCODE_SHARED_WRITE_UNAVAILABLE'); + } + } + + switch (input.action) { + case 'local_workflow_mutation': + requireExpectedRevision(input, failures); + break; + case 'shared_create_publish_promote': + requireExpectedRevision(input, failures); + requireCondition( + input, + 'privacyConfirmed', + 'MANCODE_PRIVACY_CONFIRMATION_REQUIRED', + failures, + ); + if (input.task !== null && input.task.ownerActorId !== input.actorId) { + failures.push('MANCODE_TASK_OWNER_REQUIRED'); + } + break; + case 'shared_metadata_plan_mutation': + requireTaskOwner(input, failures); + requireExpectedRevision(input, failures); + requireOwnershipEpoch(input, failures); + break; + case 'shared_ledger_evidence': + requireTask(input, failures); + if ( + input.task !== null && + input.task.ownerActorId !== input.actorId && + input.evidence?.assignedToActor !== true + ) { + failures.push('MANCODE_LEDGER_ASSIGNMENT_REQUIRED'); + } + if (input.evidence?.restrictsWriteToAssignedItem !== true) { + failures.push('MANCODE_LEDGER_SCOPE_MUTATION_DENIED'); + } + requireExpectedRevision(input, failures); + requireOwnershipEpoch(input, failures); + break; + case 'task_head_reconcile': + requireTaskOwner(input, failures); + requireExpectedRevision(input, failures); + requireOwnershipEpoch(input, failures); + requireCondition( + input, + 'gitSourceConfirmed', + 'MANCODE_GIT_SOURCE_CONFIRMATION_REQUIRED', + failures, + ); + requireCondition( + input, + 'claimHandoffConsistent', + 'MANCODE_CLAIM_HANDOFF_INCONSISTENT', + failures, + ); + break; + case 'task_complete_scope_change_child_merge': + requireTaskOwner(input, failures); + requireExpectedRevision(input, failures); + requireOwnershipEpoch(input, failures); + requireCondition( + input, + 'completionGateSatisfied', + 'MANCODE_COMPLETION_GATE_BLOCKED', + failures, + ); + if ( + input.conditions?.requiresParentOwner === true && + input.conditions.parentOwnerActorId !== input.actorId + ) { + failures.push('MANCODE_PARENT_OWNER_REQUIRED'); + } + break; + case 'claim_create': + requireTask(input, failures); + if ( + input.task !== null && + input.task.ownerActorId !== input.actorId && + !isTaskParticipant(input.task, input.actorId) + ) { + failures.push('MANCODE_PARTICIPANT_REQUIRED'); + } + requireExpectedRevision(input, failures); + requireOwnershipEpoch(input, failures); + requireCondition( + input, + 'implementationScopeContainsClaim', + 'MANCODE_SCOPE_OUTSIDE_IMPLEMENTATION_SCOPE', + failures, + ); + break; + case 'claim_renew_release_transfer': + requireClaimOwner(input, failures); + requireExpectedRevision(input, failures); + if ( + input.claim?.transferTargetActorId !== null && + input.claim !== null && + !isTaskParticipantOrMissing( + input.task, + input.claim.transferTargetActorId, + ) + ) { + failures.push('MANCODE_PARTICIPANT_REQUIRED'); + } + break; + case 'claim_reclaim': + requireTaskOwner(input, failures); + requireExpectedRevision(input, failures); + requireOwnershipEpoch(input, failures); + requireCondition( + input, + 'coordinationStoreFresh', + 'MANCODE_STORE_NOT_FRESH', + failures, + ); + requirePrivacySafeReason(input, failures); + break; + case 'handoff_offer_cancel': + requireTask(input, failures); + requireHandoff(input, failures); + if (input.handoff !== null) { + const actorMayAct = + input.handoff.fromActorId === input.actorId || + (input.handoff.intent === 'cancel' && + input.task?.ownerActorId === input.actorId); + if (!actorMayAct) failures.push('MANCODE_HANDOFF_ACTOR_MISMATCH'); + if (!isTaskParticipantOrMissing(input.task, input.handoff.toActorId)) { + failures.push('MANCODE_HANDOFF_RECIPIENT_NOT_PARTICIPANT'); + } + } + requireExpectedRevision(input, failures); + requireOwnershipEpoch(input, failures); + break; + case 'handoff_accept_reject': + requireHandoff(input, failures); + if (input.handoff !== null && input.handoff.toActorId !== input.actorId) { + failures.push('MANCODE_HANDOFF_ACTOR_MISMATCH'); + } + requireExpectedRevision(input, failures); + requireOwnershipEpoch(input, failures); + requireCondition( + input, + 'taskContextAvailable', + 'MANCODE_TASK_UNAVAILABLE', + failures, + ); + requireCondition( + input, + 'transportFresh', + 'MANCODE_TRANSPORT_UNAVAILABLE', + failures, + ); + break; + case 'review_skip_or_waiver': + requireTaskOwner(input, failures); + requireExpectedRevision(input, failures); + requireOwnershipEpoch(input, failures); + requirePrivacySafeReason(input, failures); + if ( + input.conditions?.reviewAction === 'waiver' && + (input.conditions.reviewSeverity === 'p0' || + input.conditions.reviewSeverity === 'legacy_unknown') + ) { + failures.push('MANCODE_WAIVER_FORBIDDEN'); + } + break; + case 'team_policy_config_transport': + requireExpectedRevision(input, failures); + requireCondition( + input, + 'explicitConfirmation', + 'MANCODE_EXPLICIT_CONFIRMATION_REQUIRED', + failures, + ); + break; + case 'actor_profile_publish': + if (input.profileActorId !== input.actorId) { + failures.push('MANCODE_PROFILE_OWNER_REQUIRED'); + } + requireCondition( + input, + 'explicitConfirmation', + 'MANCODE_EXPLICIT_CONFIRMATION_REQUIRED', + failures, + ); + break; + case 'confirmed_decision_publish': + requireCondition( + input, + 'confirmedDecisionSharingEnabled', + 'MANCODE_CONFIRMED_DECISIONS_DISABLED', + failures, + ); + requireCondition( + input, + 'privacyConfirmed', + 'MANCODE_PRIVACY_CONFIRMATION_REQUIRED', + failures, + ); + requireCondition( + input, + 'explicitConfirmation', + 'MANCODE_EXPLICIT_CONFIRMATION_REQUIRED', + failures, + ); + break; + } + return { + allowed: failures.length === 0, + failures: uniqueFailures(failures), + trustBoundary: TEAM_TRUST_BOUNDARY, + }; +} + +export function assertAuthorized(input: AuthorizationRequest): void { + const decision = evaluateAuthorization(input); + if (!decision.allowed) { + throw new Error(decision.failures[0] ?? 'MANCODE_AUTHORIZATION_DENIED'); + } +} + +/** + * Stores only actor/session IDs and a digest of boolean/ID facts. In + * particular, a waiver or reclaim reason is never copied into the journal. + */ +export function createAuthorizationBasis( + input: AuthorizationRequest, + now: Date = new Date(), +): AuthorizationBasisV1 { + assertAuthorized(input); + const sessionId = input.session.sessionId; + if (sessionId === null) throw new Error('MANCODE_SESSION_REQUIRED'); + return { + schemaVersion: 1, + action: input.action, + actorId: input.actorId, + sessionId, + trustBoundary: TEAM_TRUST_BOUNDARY, + decisionDigest: digestCanonicalJson(authorizationDecisionProjection(input)), + authorizedAt: now.toISOString(), + }; +} + +export function parseAuthorizationBasis(value: unknown): AuthorizationBasisV1 { + assertRecord(value, 'authorization basis'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'action', + 'actorId', + 'sessionId', + 'trustBoundary', + 'decisionDigest', + 'authorizedAt', + ], + 'authorization basis', + ); + if (value.schemaVersion !== 1) { + throw new Error('authorization basis schemaVersion must be 1'); + } + if ( + typeof value.action !== 'string' || + !AUTHORIZATION_ACTIONS.has(value.action as AuthorizationAction) + ) { + throw new Error('authorization basis action is invalid'); + } + assertUlid(value.actorId, 'authorization basis actorId'); + assertUlid(value.sessionId, 'authorization basis sessionId'); + if (value.trustBoundary !== TEAM_TRUST_BOUNDARY) { + throw new Error('authorization basis trustBoundary is invalid'); + } + if ( + typeof value.decisionDigest !== 'string' || + !DIGEST_PATTERN.test(value.decisionDigest) + ) { + throw new Error('authorization basis decisionDigest is invalid'); + } + if ( + typeof value.authorizedAt !== 'string' || + Number.isNaN(Date.parse(value.authorizedAt)) + ) { + throw new Error( + 'authorization basis authorizedAt must be an ISO timestamp', + ); + } + return { + schemaVersion: 1, + action: value.action as AuthorizationAction, + actorId: value.actorId, + sessionId: value.sessionId, + trustBoundary: TEAM_TRUST_BOUNDARY, + decisionDigest: value.decisionDigest, + authorizedAt: value.authorizedAt, + }; +} + +/** Reconciliation may continue an approved operation but never replace actor/session. */ +export function assertRepairUsesOriginalAuthorization( + basis: AuthorizationBasisV1, + actorId: Ulid, + sessionId: Ulid, +): void { + const parsed = parseAuthorizationBasis(basis); + assertUlid(actorId, 'repair actorId'); + assertUlid(sessionId, 'repair sessionId'); + if (parsed.actorId !== actorId || parsed.sessionId !== sessionId) { + throw new Error('MANCODE_REPAIR_AUTHORIZATION_MISMATCH'); + } +} + +function validateRequest(input: AuthorizationRequest): void { + if (!AUTHORIZATION_ACTIONS.has(input.action)) { + throw new Error('authorization action is invalid'); + } + assertUlid(input.actorId, 'authorization actorId'); + parseCapabilityLevel( + input.sharedWriteGuard, + 'authorization sharedWriteGuard', + ); + validateSession(input.session); + validateTask(input.task); + validateClaim(input.claim); + validateHandoff(input.handoff); + if (input.profileActorId !== null) { + assertUlid(input.profileActorId, 'authorization profileActorId'); + } + if (typeof input.joined !== 'boolean') { + throw new Error('authorization joined must be boolean'); + } + if (input.evidence !== null) { + if ( + typeof input.evidence.assignedToActor !== 'boolean' || + typeof input.evidence.restrictsWriteToAssignedItem !== 'boolean' + ) { + throw new Error('authorization evidence flags must be boolean'); + } + } + if ( + input.conditions?.parentOwnerActorId !== undefined && + input.conditions.parentOwnerActorId !== null + ) { + assertUlid( + input.conditions.parentOwnerActorId, + 'authorization parentOwnerActorId', + ); + } +} + +function validateSession(session: AuthorizationSession): void { + if ( + session.status !== 'active' && + session.status !== 'closed' && + session.status !== 'missing' + ) { + throw new Error('authorization session status is invalid'); + } + if (session.sessionId !== null) + assertUlid(session.sessionId, 'authorization sessionId'); + if (session.actorId !== null) + assertUlid(session.actorId, 'authorization session actorId'); +} + +function validateTask(task: AuthorizationTaskContext | null): void { + if (task === null) return; + if (task.ownerActorId !== null) + assertUlid(task.ownerActorId, 'authorization task ownerActorId'); + if (!Array.isArray(task.participantActorIds)) { + throw new Error('authorization task participantActorIds must be an array'); + } + for (const actorId of task.participantActorIds) { + assertUlid(actorId, 'authorization task participantActorId'); + } + if ( + sortUtf8StringSet(task.participantActorIds).length !== + task.participantActorIds.length + ) { + throw new Error('authorization task participantActorIds must not repeat'); + } +} + +function validateClaim(claim: AuthorizationClaimContext | null): void { + if (claim === null) return; + assertUlid(claim.ownerActorId, 'authorization claim ownerActorId'); + if (claim.transferTargetActorId !== null) { + assertUlid( + claim.transferTargetActorId, + 'authorization claim transferTargetActorId', + ); + } +} + +function validateHandoff(handoff: AuthorizationHandoffContext | null): void { + if (handoff === null) return; + assertUlid(handoff.fromActorId, 'authorization handoff fromActorId'); + assertUlid(handoff.toActorId, 'authorization handoff toActorId'); + if ( + handoff.intent !== 'offer' && + handoff.intent !== 'cancel' && + handoff.intent !== 'accept' && + handoff.intent !== 'reject' + ) { + throw new Error('authorization handoff intent is invalid'); + } +} + +function requireActiveSession( + input: AuthorizationRequest, + failures: AuthorizationFailureCode[], +): void { + if (input.session.status !== 'active' || input.session.sessionId === null) { + failures.push('MANCODE_SESSION_REQUIRED'); + } + if (input.session.actorId !== input.actorId) { + failures.push('MANCODE_SESSION_ACTOR_MISMATCH'); + } +} + +function requireTask( + input: AuthorizationRequest, + failures: AuthorizationFailureCode[], +): void { + if (input.task === null) failures.push('MANCODE_TASK_OWNER_REQUIRED'); +} + +function requireTaskOwner( + input: AuthorizationRequest, + failures: AuthorizationFailureCode[], +): void { + requireTask(input, failures); + if (input.task !== null && input.task.ownerActorId !== input.actorId) { + failures.push('MANCODE_TASK_OWNER_REQUIRED'); + } +} + +function requireClaimOwner( + input: AuthorizationRequest, + failures: AuthorizationFailureCode[], +): void { + if (input.claim === null || input.claim.ownerActorId !== input.actorId) { + failures.push('MANCODE_CLAIM_OWNER_REQUIRED'); + } +} + +function requireHandoff( + input: AuthorizationRequest, + failures: AuthorizationFailureCode[], +): void { + if (input.handoff === null) failures.push('MANCODE_HANDOFF_ACTOR_MISMATCH'); +} + +function requireExpectedRevision( + input: AuthorizationRequest, + failures: AuthorizationFailureCode[], +): void { + requireCondition( + input, + 'expectedRevisionMatches', + 'MANCODE_EXPECTED_REVISION_CONFLICT', + failures, + ); +} + +function requireOwnershipEpoch( + input: AuthorizationRequest, + failures: AuthorizationFailureCode[], +): void { + requireCondition( + input, + 'ownershipEpochFresh', + 'MANCODE_OWNERSHIP_EPOCH_STALE', + failures, + ); +} + +function requireCondition( + input: AuthorizationRequest, + condition: Exclude< + keyof AuthorizationConditions, + 'reason' | 'reviewAction' | 'reviewSeverity' | 'parentOwnerActorId' + >, + failure: AuthorizationFailureCode, + failures: AuthorizationFailureCode[], +): void { + if (input.conditions?.[condition] !== true) failures.push(failure); +} + +function requirePrivacySafeReason( + input: AuthorizationRequest, + failures: AuthorizationFailureCode[], +): void { + const reason = input.conditions?.reason; + if (typeof reason !== 'string' || !reason.trim()) { + failures.push('MANCODE_RECLAIM_REASON_REQUIRED'); + return; + } + try { + assertSharedTextSafe(reason, 'authorization reason'); + } catch { + failures.push('MANCODE_PRIVACY_CONFIRMATION_REQUIRED'); + } +} + +function isTaskParticipant( + task: AuthorizationTaskContext, + actorId: Ulid, +): boolean { + return task.participantActorIds.includes(actorId); +} + +function isTaskParticipantOrMissing( + task: AuthorizationTaskContext | null, + actorId: Ulid, +): boolean { + return task !== null && isTaskParticipant(task, actorId); +} + +function uniqueFailures( + failures: AuthorizationFailureCode[], +): AuthorizationFailureCode[] { + return [...new Set(failures)]; +} + +function authorizationDecisionProjection(input: AuthorizationRequest): object { + return { + action: input.action, + actorId: input.actorId, + session: input.session, + joined: input.joined, + sharedWriteGuard: input.sharedWriteGuard, + task: input.task, + claim: input.claim, + handoff: input.handoff, + evidence: input.evidence, + profileActorId: input.profileActorId, + conditions: { + expectedRevisionMatches: + input.conditions?.expectedRevisionMatches === true, + ownershipEpochFresh: input.conditions?.ownershipEpochFresh === true, + privacyConfirmed: input.conditions?.privacyConfirmed === true, + explicitConfirmation: input.conditions?.explicitConfirmation === true, + taskContextAvailable: input.conditions?.taskContextAvailable === true, + transportFresh: input.conditions?.transportFresh === true, + gitSourceConfirmed: input.conditions?.gitSourceConfirmed === true, + completionGateSatisfied: + input.conditions?.completionGateSatisfied === true, + claimHandoffConsistent: input.conditions?.claimHandoffConsistent === true, + implementationScopeContainsClaim: + input.conditions?.implementationScopeContainsClaim === true, + coordinationStoreFresh: input.conditions?.coordinationStoreFresh === true, + requiresParentOwner: input.conditions?.requiresParentOwner === true, + parentOwnerActorId: input.conditions?.parentOwnerActorId ?? null, + reviewAction: input.conditions?.reviewAction ?? null, + reviewSeverity: input.conditions?.reviewSeverity ?? null, + hasReason: + typeof input.conditions?.reason === 'string' && + input.conditions.reason.trim() !== '', + }, + }; +} diff --git a/src/team/capabilities.ts b/src/team/capabilities.ts new file mode 100644 index 0000000..8b95354 --- /dev/null +++ b/src/team/capabilities.ts @@ -0,0 +1,34 @@ +export type CapabilityLevel = 'enforced' | 'advisory' | 'unavailable'; +export type Freshness = 'fresh' | 'stale' | 'unknown' | 'unavailable'; + +const CAPABILITY_LEVELS = new Set([ + 'enforced', + 'advisory', + 'unavailable', +]); +const FRESHNESS_LEVELS = new Set([ + 'fresh', + 'stale', + 'unknown', + 'unavailable', +]); + +export function parseCapabilityLevel( + value: unknown, + label: string, +): CapabilityLevel { + if ( + typeof value !== 'string' || + !CAPABILITY_LEVELS.has(value as CapabilityLevel) + ) { + throw new Error(`${label} must be enforced, advisory, or unavailable`); + } + return value as CapabilityLevel; +} + +export function parseFreshness(value: unknown, label: string): Freshness { + if (typeof value !== 'string' || !FRESHNESS_LEVELS.has(value as Freshness)) { + throw new Error(`${label} is invalid`); + } + return value as Freshness; +} diff --git a/src/team/checkpoints.ts b/src/team/checkpoints.ts new file mode 100644 index 0000000..ea07c79 --- /dev/null +++ b/src/team/checkpoints.ts @@ -0,0 +1,219 @@ +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { assertSharedTextSafe } from '../context/privacy.js'; +import { type TaskRef, parseTaskRefValue } from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; + +export type CheckpointKind = + | 'plan_confirmed' + | 'scope_changed' + | 'diagnostic_started' + | 'base_changed' + | 'verification_completed' + | 'blocked' + | 'handoff_offered' + | 'completed'; + +export interface CheckpointV1 { + schemaVersion: 1; + checkpointId: Ulid; + /** Immutable provenance for the journal that created this checkpoint. */ + operationId: Ulid; + taskRef: TaskRef; + taskRevision: number; + ownershipEpochAtOffer: number; + kind: CheckpointKind; + git: { + branch: string | null; + head: string | null; + base: string | null; + }; + summary: string; + governance: { + requirementsDigest: string; + planVersion: number; + reviewLedgerDigest: string; + verificationLedgerDigest: string; + }; + nextAction: string; + createdBy: { + actorId: Ulid; + client: string; + }; + createdAt: string; +} + +const CHECKPOINT_KINDS = new Set([ + 'plan_confirmed', + 'scope_changed', + 'diagnostic_started', + 'base_changed', + 'verification_completed', + 'blocked', + 'handoff_offered', + 'completed', +]); +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function parseCheckpoint(value: unknown): CheckpointV1 { + assertRecord(value, 'checkpoint'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'checkpointId', + 'operationId', + 'taskRef', + 'taskRevision', + 'ownershipEpochAtOffer', + 'kind', + 'git', + 'summary', + 'governance', + 'nextAction', + 'createdBy', + 'createdAt', + ], + 'checkpoint', + ); + if (value.schemaVersion !== 1) + throw new Error('checkpoint schemaVersion must be 1'); + assertUlid(value.checkpointId, 'checkpointId'); + assertUlid(value.operationId, 'checkpoint operationId'); + const kind = parseCheckpointKind(value.kind); + if (typeof value.summary !== 'string' || !value.summary.trim()) { + throw new Error('checkpoint summary is required'); + } + if (typeof value.nextAction !== 'string' || !value.nextAction.trim()) { + throw new Error('checkpoint nextAction is required'); + } + assertSharedTextSafe(value.summary, 'checkpoint summary'); + assertSharedTextSafe(value.nextAction, 'checkpoint nextAction'); + return { + schemaVersion: 1, + checkpointId: value.checkpointId, + operationId: value.operationId, + taskRef: parseTaskRefValue(value.taskRef), + taskRevision: parsePositiveInteger( + value.taskRevision, + 'checkpoint taskRevision', + ), + ownershipEpochAtOffer: parseNonNegativeInteger( + value.ownershipEpochAtOffer, + 'checkpoint ownershipEpochAtOffer', + ), + kind, + git: parseGitSnapshot(value.git), + summary: value.summary, + governance: parseGovernance(value.governance), + nextAction: value.nextAction, + createdBy: parseCreatedBy(value.createdBy), + createdAt: parseTimestamp(value.createdAt, 'checkpoint createdAt'), + }; +} + +export function parseCheckpointKind(value: unknown): CheckpointKind { + if ( + typeof value !== 'string' || + !CHECKPOINT_KINDS.has(value as CheckpointKind) + ) { + throw new Error('checkpoint kind is invalid'); + } + return value as CheckpointKind; +} + +/** + * Checkpoints are immutable, so their aggregate reference digest covers the + * complete typed snapshot, including provenance and its creation time. + */ +export function checkpointDigest(checkpoint: CheckpointV1): string { + return digestCanonicalJson(checkpoint); +} + +function parseGitSnapshot(value: unknown): CheckpointV1['git'] { + assertRecord(value, 'checkpoint git'); + assertKnownKeys(value, ['branch', 'head', 'base'], 'checkpoint git'); + return { + branch: parseGitValueOrNull(value.branch, 'checkpoint git branch'), + head: parseGitValueOrNull(value.head, 'checkpoint git head'), + base: parseGitValueOrNull(value.base, 'checkpoint git base'), + }; +} + +function parseGovernance(value: unknown): CheckpointV1['governance'] { + assertRecord(value, 'checkpoint governance'); + assertKnownKeys( + value, + [ + 'requirementsDigest', + 'planVersion', + 'reviewLedgerDigest', + 'verificationLedgerDigest', + ], + 'checkpoint governance', + ); + return { + requirementsDigest: parseDigest( + value.requirementsDigest, + 'checkpoint requirementsDigest', + ), + planVersion: parsePositiveInteger( + value.planVersion, + 'checkpoint planVersion', + ), + reviewLedgerDigest: parseDigest( + value.reviewLedgerDigest, + 'checkpoint reviewLedgerDigest', + ), + verificationLedgerDigest: parseDigest( + value.verificationLedgerDigest, + 'checkpoint verificationLedgerDigest', + ), + }; +} + +function parseCreatedBy(value: unknown): CheckpointV1['createdBy'] { + assertRecord(value, 'checkpoint createdBy'); + assertKnownKeys(value, ['actorId', 'client'], 'checkpoint createdBy'); + assertUlid(value.actorId, 'checkpoint createdBy actorId'); + if (typeof value.client !== 'string' || !value.client.trim()) { + throw new Error('checkpoint createdBy client is required'); + } + return { actorId: value.actorId, client: value.client }; +} + +function parseGitValueOrNull(value: unknown, label: string): string | null { + if (value === null) return null; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} must be a non-empty string or null`); + } + return value; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parseNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} diff --git a/src/team/claim-acquisition.ts b/src/team/claim-acquisition.ts new file mode 100644 index 0000000..5d6f0ac --- /dev/null +++ b/src/team/claim-acquisition.ts @@ -0,0 +1,247 @@ +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { assertTaskCodeHeadUnchanged } from '../context/task-mutation.js'; +import { type TaskRef, parseTaskRefValue } from '../context/task-ref.js'; +import { createClaim } from '../runtime/claim-store.js'; +import { recordLocalDiagnostic } from '../runtime/diagnostics.js'; +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { createClaimRecoveryAction } from '../runtime/operation-recovery-payload.js'; +import { readCheckoutBranch } from '../runtime/project-runtime.js'; +import { + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + taskEntityKey, +} from '../runtime/task-operation.js'; +import { localCoordinationDomainId } from '../runtime/workspace-binding.js'; +import { + type ClaimScope, + type ClaimV1, + normalizeClaimScope, + parseClaim, +} from './claims.js'; +import { + assertClaimScopeSubset, + assessClaimConflicts, + deriveClaimValidity, +} from './conflicts.js'; +import { capabilitiesFromProjectConfig } from './transport.js'; + +export const DEFAULT_CLAIM_TTL_MS = 24 * 60 * 60 * 1000; +export const MIN_CLAIM_TTL_MS = 60 * 1000; +export const MAX_CLAIM_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +export interface AcquireV3ClaimInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + scope: unknown; + ttlMs?: number; + claimId?: Ulid; + operationId?: Ulid; + now?: Date; +} + +export interface AcquiredV3Claim { + claim: ClaimV1; + operation: OperationJournalV1; + conflict: ReturnType; +} + +/** + * Acquires an active local-coordination claim under the shared task lock. It + * binds scope, task revision, ownership epoch, and the current code head in + * the claim's immutable acquisition snapshot. + */ +export async function acquireV3Claim( + input: AcquireV3ClaimInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_CLAIM_REQUIRES_SHARED_TASK'); + } + const scope = normalizeClaimScope(input.scope); + const ttlMs = parseClaimTtl(input.ttlMs); + const now = input.now ?? new Date(); + const claimId = input.claimId ?? createUlid(now.getTime()); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(claimId, 'claimId'); + assertUlid(operationId, 'claim operationId'); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId, + extraEntityLocks: [`claim:${claimId}`], + now, + }); + let journal: OperationJournalV1 | null = null; + try { + assertClaimTaskEligible(context.task.metadata); + if (context.project.config.transport.mode !== 'local') { + throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); + } + assertClaimScopeSubset(scope, { + source: context.task.metadata.implementationScope.source, + include: context.task.metadata.implementationScope.include, + exclude: context.task.metadata.implementationScope.exclude, + modules: context.task.metadata.implementationScope.modules, + }); + const capabilities = capabilitiesFromProjectConfig(context.project.config); + const codeHead = context.codeHead; + if (codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + } + const staleClaims = context.coordination.claims.filter( + (claim) => + claim.state === 'active' && + deriveClaimValidity(claim, { + taskRef, + taskRevision: context.task.metadata.revision, + implementationScopeDigest: + context.task.metadata.implementationScope.digest, + ownershipEpoch: context.task.metadata.ownershipEpoch, + codeRefHead: codeHead, + now: context.now, + transportFreshness: 'fresh', + }) !== 'fresh', + ); + if (staleClaims.length > 0) { + throw new Error('MANCODE_CLAIM_REVALIDATION_REQUIRED'); + } + const conflict = assessClaimConflicts(scope, context.coordination.claims, { + // `unavailable` describes cross-clone visibility for local transport; + // the common-dir store read under this lock is nevertheless fresh. + transportFreshness: 'fresh', + claimAcquisition: capabilities.claimAcquisition, + }); + if (conflict.acquisition !== 'allow') { + throw new Error('MANCODE_SCOPE_CONFLICT'); + } + const timestamp = context.now.toISOString(); + const branch = (await readCheckoutBranch(context.projectRoot)) ?? 'HEAD'; + const claim = parseClaim({ + schemaVersion: 1, + claimId, + workspaceId: context.runtime.workspaceId, + coordinationDomainId: coordinationDomainId(context), + authority: { mode: 'local', remoteRevision: null }, + taskRef, + taskRevisionAtAcquire: context.task.metadata.revision, + lastValidatedTaskRevision: context.task.metadata.revision, + implementationScopeDigest: + context.task.metadata.implementationScope.digest, + ownershipEpochAtAcquire: context.task.metadata.ownershipEpoch, + ownerActorId: context.session.actorId, + state: 'active', + revision: 1, + scope, + scopeDigest: digestCanonicalJson(scope), + codeRefAtAcquire: { branch, head: codeHead }, + lastValidatedCodeRef: { branch, head: codeHead }, + acquisitionEnforcement: capabilities.claimAcquisition, + writeGuard: capabilities.writeGuard, + expiresAt: new Date(context.now.getTime() + ttlMs).toISOString(), + predecessorClaimId: null, + successorClaimId: null, + lastOperationId: context.operationId, + createdAt: timestamp, + updatedAt: timestamp, + }); + + journal = await createTaskOperationJournal(context, { + type: 'claim_create', + action: 'claim_create', + expectedRevisions: { + [taskEntityKey(taskRef)]: context.task.metadata.revision, + [`claim:${claimId}`]: 0, + }, + conditions: { implementationScopeContainsClaim: true }, + recovery: { + actions: [ + createClaimRecoveryAction({ + stepId: 'create-active-claim', + before: null, + claim, + }), + ], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + journal = await advanceTaskOperation( + context, + journal, + 'create-active-claim', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await createClaim(context.homeStore, claim); + const operation = await commitTaskOperation(context, journal); + return { claim, operation, conflict }; + } catch (error) { + if (error instanceof Error && error.message === 'MANCODE_SCOPE_CONFLICT') { + await recordLocalDiagnostic(input.projectRoot, { + kind: 'claim_conflict', + level: 'blocker', + }).catch(() => undefined); + } + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A durable journal blocks overlapping mutations until repair. + } + } + throw error; + } finally { + await context.release(); + } +} + +function assertClaimTaskEligible(metadata: { + workflowMode: string; + status: string; + implementationScope: { source: string }; +}): void { + if (metadata.workflowMode !== 'manteam') { + throw new Error('MANCODE_CLAIM_WORKFLOW_MODE_INVALID'); + } + if (metadata.status !== 'in_progress') { + throw new Error('MANCODE_CLAIM_WORKFLOW_NOT_ACTIVE'); + } + if (metadata.implementationScope.source === 'legacy_unspecified') { + throw new Error('MANCODE_SCOPE_CONFIRMATION_REQUIRED'); + } +} + +function coordinationDomainId( + context: Awaited>, +): string { + if (context.runtime.repositoryBindingId !== null) { + return localCoordinationDomainId( + context.runtime.repositoryBindingId, + context.runtime.workspaceId, + ); + } + // Non-Git local coordination has no repository binding. Its workspace ID is + // still a stable authority boundary and cannot collide with Git bindings. + return `local:non-git:${context.runtime.workspaceId}`; +} + +export function parseClaimTtl(value: number | undefined): number { + const ttl = value ?? DEFAULT_CLAIM_TTL_MS; + if ( + !Number.isSafeInteger(ttl) || + ttl < MIN_CLAIM_TTL_MS || + ttl > MAX_CLAIM_TTL_MS + ) { + throw new Error('MANCODE_CLAIM_TTL_INVALID'); + } + return ttl; +} + +export type { ClaimScope }; diff --git a/src/team/claim-operation.ts b/src/team/claim-operation.ts new file mode 100644 index 0000000..401da7a --- /dev/null +++ b/src/team/claim-operation.ts @@ -0,0 +1,1004 @@ +import { createHash } from 'node:crypto'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, +} from '../context/aggregate.js'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { V3ContextStore } from '../context/store.js'; +import { + assertTaskCodeHeadUnchanged, + nextTaskHeadFence, +} from '../context/task-mutation.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from '../context/workflow-metadata.js'; +import { createClaim, readClaim, updateClaim } from '../runtime/claim-store.js'; +import { resolveCoordinationEntityHomeStore } from '../runtime/entity-home-store.js'; +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + createCheckpointRecoveryAction, + createClaimRecoveryAction, + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, +} from '../runtime/operation-recovery-payload.js'; +import { + readCheckoutBranch, + readProjectRuntimeContext, +} from '../runtime/project-runtime.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + type OpenedV3TaskOperation, + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskEntityKey, + taskHeadEntityKey, + writeTaskAuthorityFile, + writeTaskCheckpoint, +} from '../runtime/task-operation.js'; +import { readSharedActorProfile } from './actor.js'; +import { type CheckpointV1, parseCheckpoint } from './checkpoints.js'; +import { parseClaimTtl } from './claim-acquisition.js'; +import { type ClaimV1, assertClaimTransition, parseClaim } from './claims.js'; +import { type ClaimValidity, deriveClaimValidity } from './conflicts.js'; +import { capabilitiesFromProjectConfig } from './transport.js'; + +interface ExistingClaimOperationInput { + projectRoot: string; + claimId: Ulid; + sessionId: Ulid; + expectedClaimRevision: number; + operationId?: Ulid; + now?: Date; +} + +export interface RenewV3ClaimInput extends ExistingClaimOperationInput { + ttlMs?: number; +} + +export interface ReleaseV3ClaimInput extends ExistingClaimOperationInput {} + +export interface TransferV3ClaimInput extends ExistingClaimOperationInput { + toActorId: Ulid; + successorClaimId?: Ulid; +} + +export interface ReclaimV3ClaimInput extends ExistingClaimOperationInput { + reason: string; +} + +export interface RevalidateV3ClaimInput extends ExistingClaimOperationInput { + checkpointId?: Ulid; + checkpointSummary?: string; + checkpointNextAction?: string; +} + +export interface UpdatedV3Claim { + claim: ClaimV1; + operation: OperationJournalV1; +} + +export interface TransferredV3Claim { + predecessorClaim: ClaimV1; + successorClaim: ClaimV1; + operation: OperationJournalV1; +} + +export interface RevalidatedV3Claim { + metadata: WorkflowMetadataV3; + claim: ClaimV1; + checkpoint: CheckpointV1 | null; + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1; + operation: OperationJournalV1; +} + +/** Extends an active local claim only after its full snapshot is fresh. */ +export async function renewV3Claim( + input: RenewV3ClaimInput, +): Promise { + const ttlMs = parseClaimTtl(input.ttlMs); + return mutateOwnedClaim(input, 'renew', (context, claim, timestamp) => { + assertClaimFreshForRenewOrTransfer(context, claim); + const next = parseClaim({ + ...claim, + revision: claim.revision + 1, + expiresAt: new Date(context.now.getTime() + ttlMs).toISOString(), + lastOperationId: context.operationId, + updatedAt: timestamp, + }); + assertClaimTransition(claim, next); + return next; + }); +} + +/** Releases an active claim; stale claims may always be safely relinquished. */ +export async function releaseV3Claim( + input: ReleaseV3ClaimInput, +): Promise { + return mutateOwnedClaim(input, 'release', (_context, claim, timestamp) => { + assertActiveClaim(claim); + const next = parseClaim({ + ...claim, + state: 'released', + revision: claim.revision + 1, + lastOperationId: _context.operationId, + updatedAt: timestamp, + }); + assertClaimTransition(claim, next); + return next; + }); +} + +/** + * Transfers an active claim by creating a new pending successor first. The + * original claim's owner and immutable acquisition snapshot are never edited. + */ +export async function transferV3Claim( + input: TransferV3ClaimInput, +): Promise { + assertUlid(input.toActorId, 'claim transfer target actorId'); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'claim transfer operationId'); + const opened = await openExistingClaimContext({ + projectRoot: input.projectRoot, + claimId: input.claimId, + sessionId: input.sessionId, + expectedClaimRevision: input.expectedClaimRevision, + operationId, + now, + extraEntityLocks: [], + }); + const { context, claim } = opened; + const timestamp = context.now.toISOString(); + const successorClaimId = + input.successorClaimId ?? + claimTransferSuccessorId(operationId, claim.claimId, timestamp); + assertUlid(successorClaimId, 'claim transfer successorClaimId'); + if (successorClaimId === claim.claimId) { + await context.release(); + throw new Error('MANCODE_CLAIM_TRANSFER_SUCCESSOR_INVALID'); + } + + // Reopen with the successor lock as well. The first locked snapshot proves + // the task ref; this second open keeps the create-CAS and predecessor CAS in + // one canonical lock family. + await context.release(); + const reopened = await openExistingClaimContext({ + projectRoot: input.projectRoot, + claimId: input.claimId, + sessionId: input.sessionId, + expectedClaimRevision: input.expectedClaimRevision, + operationId, + now, + extraEntityLocks: [`claim:${successorClaimId}`], + }); + const lockedContext = reopened.context; + const lockedClaim = reopened.claim; + let journal: OperationJournalV1 | null = null; + try { + assertLocalClaimTransport(lockedContext); + assertOwnedClaim(lockedContext, lockedClaim); + assertClaimFreshForRenewOrTransfer(lockedContext, lockedClaim); + await assertTransferRecipient(lockedContext, input.toActorId); + if ( + lockedContext.coordination.claims.some( + (candidate) => candidate.claimId === successorClaimId, + ) + ) { + throw new Error('MANCODE_CLAIM_TRANSFER_SUCCESSOR_CONFLICT'); + } + const branch = + (await readCheckoutBranch(lockedContext.projectRoot)) ?? 'HEAD'; + const pendingSuccessor = buildPendingTransferSuccessor( + lockedContext, + lockedClaim, + successorClaimId, + input.toActorId, + branch, + timestamp, + ); + const transferred = transferPredecessorClaim( + lockedClaim, + successorClaimId, + lockedContext.operationId, + timestamp, + ); + const activeSuccessor = activateSuccessorClaim( + pendingSuccessor, + lockedContext.operationId, + timestamp, + ); + journal = await createTaskOperationJournal(lockedContext, { + type: 'claim_transfer', + action: 'claim_renew_release_transfer', + expectedRevisions: { + [taskEntityKey(lockedContext.taskRef)]: + lockedContext.task.metadata.revision, + [`claim:${lockedClaim.claimId}`]: lockedClaim.revision, + [`claim:${successorClaimId}`]: 0, + }, + claim: { + ownerActorId: lockedClaim.ownerActorId, + transferTargetActorId: input.toActorId, + }, + recovery: { + actions: [ + createClaimRecoveryAction({ + stepId: 'create-pending-successor-claim', + before: null, + claim: pendingSuccessor, + }), + createClaimRecoveryAction({ + stepId: 'transfer-predecessor-claim', + before: lockedClaim, + claim: transferred, + }), + createClaimRecoveryAction({ + stepId: 'activate-successor-claim', + before: pendingSuccessor, + claim: activeSuccessor, + }), + ], + }, + }); + journal = await advanceTaskOperation( + lockedContext, + journal, + 'validate', + true, + ); + + journal = await advanceTaskOperation( + lockedContext, + journal, + 'create-pending-successor-claim', + false, + ); + await assertTaskCodeHeadUnchanged( + lockedContext.projectRoot, + lockedContext.codeHead, + ); + await createClaim(lockedContext.homeStore, pendingSuccessor); + + journal = await advanceTaskOperation( + lockedContext, + journal, + 'transfer-predecessor-claim', + false, + ); + await assertTaskCodeHeadUnchanged( + lockedContext.projectRoot, + lockedContext.codeHead, + ); + await updateClaim( + lockedContext.homeStore, + transferred, + lockedClaim.revision, + ); + + journal = await advanceTaskOperation( + lockedContext, + journal, + 'activate-successor-claim', + false, + ); + await assertTaskCodeHeadUnchanged( + lockedContext.projectRoot, + lockedContext.codeHead, + ); + await updateClaim(lockedContext.homeStore, activeSuccessor, 1); + const operation = await commitTaskOperation(lockedContext, journal); + return { + predecessorClaim: transferred, + successorClaim: activeSuccessor, + operation, + }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(lockedContext, journal); + } catch { + // A transfer write intent is repair-only until it is reconciled. + } + } + throw error; + } finally { + await lockedContext.release(); + } +} + +/** Marks an expired local claim terminal; it never resurrects that identity. */ +export async function reclaimV3Claim( + input: ReclaimV3ClaimInput, +): Promise { + if (!input.reason.trim()) { + throw new Error('MANCODE_RECLAIM_REASON_REQUIRED'); + } + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'claim reclaim operationId'); + const { context, claim } = await openExistingClaimContext({ + projectRoot: input.projectRoot, + claimId: input.claimId, + sessionId: input.sessionId, + expectedClaimRevision: input.expectedClaimRevision, + operationId, + now, + extraEntityLocks: [], + }); + let journal: OperationJournalV1 | null = null; + try { + assertLocalClaimTransport(context); + assertActiveClaim(claim); + if (context.task.metadata.ownerActorId !== context.session.actorId) { + throw new Error('MANCODE_TASK_OWNER_REQUIRED'); + } + if (Date.parse(claim.expiresAt) > context.now.getTime()) { + throw new Error('MANCODE_CLAIM_RECLAIM_NOT_ELIGIBLE'); + } + const timestamp = context.now.toISOString(); + const expired = parseClaim({ + ...claim, + state: 'expired', + revision: claim.revision + 1, + lastOperationId: context.operationId, + updatedAt: timestamp, + }); + assertClaimTransition(claim, expired); + journal = await createTaskOperationJournal(context, { + type: 'claim_reclaim', + action: 'claim_reclaim', + expectedRevisions: { + [taskEntityKey(context.taskRef)]: context.task.metadata.revision, + [`claim:${claim.claimId}`]: claim.revision, + }, + claim: { ownerActorId: claim.ownerActorId, transferTargetActorId: null }, + conditions: { coordinationStoreFresh: true, reason: input.reason }, + recovery: { + actions: [ + createClaimRecoveryAction({ + stepId: 'expire-claim', + before: claim, + claim: expired, + }), + ], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + journal = await advanceTaskOperation( + context, + journal, + 'expire-claim', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await updateClaim(context.homeStore, expired, claim.revision); + const operation = await commitTaskOperation(context, journal); + return { claim: expired, operation }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // The claim state is only recoverable forward after write intent. + } + } + throw error; + } finally { + await context.release(); + } +} + +/** + * Refreshes a claim's derived validity after a task revision or code-head + * change. Scope and ownership epoch drift are deliberately non-repairable: + * callers must use scope re-claim or handoff instead. + */ +export async function revalidateV3Claim( + input: RevalidateV3ClaimInput, +): Promise { + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + const checkpointId = input.checkpointId ?? createUlid(now.getTime()); + assertUlid(operationId, 'claim revalidation operationId'); + assertUlid(checkpointId, 'claim revalidation checkpointId'); + const { context, claim } = await openExistingClaimContext({ + projectRoot: input.projectRoot, + claimId: input.claimId, + sessionId: input.sessionId, + expectedClaimRevision: input.expectedClaimRevision, + operationId, + now, + extraEntityLocks: [`checkpoint:${checkpointId}`], + includeTaskHeadFence: true, + }); + let journal: OperationJournalV1 | null = null; + try { + assertLocalClaimTransport(context); + assertOwnedClaim(context, claim); + const validity = claimValidity(context, claim); + assertRevalidatable(validity); + const timestamp = context.now.toISOString(); + const baseChanged = validity === 'code_ref_stale'; + const pendingMetadata = markClaimValidationPending( + context.task.metadata, + context.operationId, + timestamp, + ); + const branch = (await readCheckoutBranch(context.projectRoot)) ?? 'HEAD'; + const checkpoint = baseChanged + ? buildBaseChangedCheckpoint( + context, + pendingMetadata, + checkpointId, + input.checkpointSummary, + input.checkpointNextAction, + branch, + timestamp, + ) + : null; + const metadata = completeClaimValidationMetadata( + pendingMetadata, + checkpoint, + context.operationId, + timestamp, + ); + const validatedClaim = parseClaim({ + ...claim, + revision: claim.revision + 1, + lastValidatedTaskRevision: metadata.revision, + lastValidatedCodeRef: { branch, head: requireCodeHead(context) }, + lastOperationId: context.operationId, + updatedAt: timestamp, + }); + assertClaimTransition(claim, validatedClaim); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: checkpoint ?? context.task.latestCheckpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + if (taskHeadFence === null) { + throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + } + journal = await createTaskOperationJournal(context, { + type: 'claim_revalidation', + action: 'claim_renew_release_transfer', + expectedRevisions: revalidationExpectedRevisions( + context, + claim, + checkpoint, + ), + claim: { ownerActorId: claim.ownerActorId, transferTargetActorId: null }, + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-task-operation-pending', + taskRef: context.taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(pendingMetadata), + }), + ...(checkpoint === null + ? [] + : [ + createCheckpointRecoveryAction({ + stepId: 'write-base-changed-checkpoint', + before: null, + checkpoint, + }), + ]), + createClaimRecoveryAction({ + stepId: 'update-claim-validation', + before: claim, + claim: validatedClaim, + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'complete-task-validation', + taskRef: context.taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(pendingMetadata), + targetContent: serializeTaskAuthority(metadata), + }), + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ], + noOpStepIds: + checkpoint === null ? ['write-base-changed-checkpoint'] : [], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + + journal = await advanceTaskOperation( + context, + journal, + 'mark-task-operation-pending', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await writeTaskAuthorityFile( + context, + 'metadata.json', + `${JSON.stringify(pendingMetadata, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'write-base-changed-checkpoint', + false, + ); + if (checkpoint !== null) { + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await writeTaskCheckpoint(context, checkpoint); + } + + journal = await advanceTaskOperation( + context, + journal, + 'update-claim-validation', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await updateClaim(context.homeStore, validatedClaim, claim.revision); + + journal = await advanceTaskOperation( + context, + journal, + 'complete-task-validation', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await writeTaskAuthorityFile( + context, + 'metadata.json', + `${JSON.stringify(metadata, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-task-head-fence', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + const operation = await commitTaskOperation(context, journal); + return { + metadata, + claim: validatedClaim, + checkpoint, + aggregate, + taskHeadFence, + operation, + }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // Metadata pending plus the journal remain the recovery boundary. + } + } + throw error; + } finally { + await context.release(); + } +} + +async function mutateOwnedClaim( + input: ExistingClaimOperationInput, + intent: 'renew' | 'release', + buildNext: ( + context: OpenedV3TaskOperation, + claim: ClaimV1, + timestamp: string, + ) => ClaimV1, +): Promise { + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, `claim ${intent} operationId`); + const { context, claim } = await openExistingClaimContext({ + projectRoot: input.projectRoot, + claimId: input.claimId, + sessionId: input.sessionId, + expectedClaimRevision: input.expectedClaimRevision, + operationId, + now, + extraEntityLocks: [], + }); + let journal: OperationJournalV1 | null = null; + try { + assertLocalClaimTransport(context); + assertOwnedClaim(context, claim); + const timestamp = context.now.toISOString(); + const next = buildNext(context, claim, timestamp); + journal = await createTaskOperationJournal(context, { + type: 'claim_renew_release', + action: 'claim_renew_release_transfer', + expectedRevisions: { + [taskEntityKey(context.taskRef)]: context.task.metadata.revision, + [`claim:${claim.claimId}`]: claim.revision, + }, + claim: { ownerActorId: claim.ownerActorId, transferTargetActorId: null }, + recovery: { + actions: [ + createClaimRecoveryAction({ + stepId: 'update-claim', + before: claim, + claim: next, + }), + ], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + journal = await advanceTaskOperation( + context, + journal, + 'update-claim', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await updateClaim(context.homeStore, next, claim.revision); + const operation = await commitTaskOperation(context, journal); + return { claim: next, operation }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A durable claim mutation remains repair-only after its write intent. + } + } + throw error; + } finally { + await context.release(); + } +} + +interface OpenExistingClaimContextInput { + projectRoot: string; + claimId: Ulid; + sessionId: Ulid; + expectedClaimRevision: number; + operationId: Ulid; + now: Date; + extraEntityLocks: string[]; + includeTaskHeadFence?: boolean; +} + +async function openExistingClaimContext( + input: OpenExistingClaimContextInput, +): Promise<{ context: OpenedV3TaskOperation; claim: ClaimV1 }> { + assertUlid(input.claimId, 'claimId'); + assertPositiveRevision(input.expectedClaimRevision, 'claim revision'); + const runtime = await readProjectRuntimeContext(input.projectRoot); + const coordinationStore = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const preflight = await readClaim(coordinationStore, input.claimId); + if (preflight === null) throw new Error('MANCODE_CLAIM_NOT_FOUND'); + if (preflight.revision !== input.expectedClaimRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + const store = new V3ContextStore(input.projectRoot); + const snapshot = await store.readTaskSnapshot(preflight.taskRef); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef: preflight.taskRef, + sessionId: input.sessionId, + expectedTaskRevision: snapshot.metadata.revision, + operationId: input.operationId, + extraEntityLocks: [ + `claim:${input.claimId}`, + ...input.extraEntityLocks, + ...(input.includeTaskHeadFence === true + ? [taskHeadEntityKey(preflight.taskRef)] + : []), + ], + now: input.now, + }); + const claim = context.coordination.claims.find( + (candidate) => candidate.claimId === input.claimId, + ); + if (claim === undefined) { + await context.release(); + throw new Error('MANCODE_CLAIM_NOT_FOUND'); + } + if (claim.revision !== input.expectedClaimRevision) { + await context.release(); + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + return { context, claim }; +} + +function assertLocalClaimTransport(context: OpenedV3TaskOperation): void { + if ( + context.taskRef.namespace !== 'shared' || + context.project.config.transport.mode !== 'local' + ) { + throw new Error('MANCODE_GIT_REF_TRANSPORT_NOT_IMPLEMENTED'); + } +} + +function assertOwnedClaim( + context: OpenedV3TaskOperation, + claim: ClaimV1, +): void { + assertActiveClaim(claim); + if (claim.ownerActorId !== context.session.actorId) { + throw new Error('MANCODE_CLAIM_OWNER_REQUIRED'); + } +} + +function assertActiveClaim(claim: ClaimV1): void { + if (claim.state !== 'active') { + throw new Error('MANCODE_CLAIM_NOT_ACTIVE'); + } +} + +function assertClaimFreshForRenewOrTransfer( + context: OpenedV3TaskOperation, + claim: ClaimV1, +): void { + const validity = claimValidity(context, claim); + if (validity === 'fresh') return; + if (validity === 'expired') throw new Error('MANCODE_CLAIM_EXPIRED'); + throw new Error('MANCODE_CLAIM_REVALIDATION_REQUIRED'); +} + +function claimValidity( + context: OpenedV3TaskOperation, + claim: ClaimV1, +): ClaimValidity { + return deriveClaimValidity(claim, { + taskRef: context.taskRef, + taskRevision: context.task.metadata.revision, + implementationScopeDigest: context.task.metadata.implementationScope.digest, + ownershipEpoch: context.task.metadata.ownershipEpoch, + codeRefHead: requireCodeHead(context), + now: context.now, + transportFreshness: 'fresh', + }); +} + +function assertRevalidatable(validity: ClaimValidity): void { + if (validity === 'needs_revalidation' || validity === 'code_ref_stale') { + return; + } + if (validity === 'expired') throw new Error('MANCODE_CLAIM_EXPIRED'); + if (validity === 'fresh') { + throw new Error('MANCODE_CLAIM_REVALIDATION_NOT_REQUIRED'); + } + throw new Error('MANCODE_CLAIM_REVALIDATION_REQUIRED'); +} + +async function assertTransferRecipient( + context: OpenedV3TaskOperation, + toActorId: Ulid, +): Promise { + if (toActorId === context.session.actorId) { + throw new Error('MANCODE_CLAIM_TRANSFER_TARGET_INVALID'); + } + if (!context.task.metadata.participants.includes(toActorId)) { + throw new Error('MANCODE_PARTICIPANT_REQUIRED'); + } + if ((await readSharedActorProfile(context.projectRoot, toActorId)) === null) { + throw new Error('MANCODE_PARTICIPANT_JOIN_REQUIRED'); + } +} + +function buildPendingTransferSuccessor( + context: OpenedV3TaskOperation, + predecessor: ClaimV1, + successorClaimId: Ulid, + toActorId: Ulid, + branch: string, + timestamp: string, +): ClaimV1 { + const capabilities = capabilitiesFromProjectConfig(context.project.config); + const codeHead = requireCodeHead(context); + return parseClaim({ + ...predecessor, + claimId: successorClaimId, + authority: { mode: 'local', remoteRevision: null }, + taskRevisionAtAcquire: context.task.metadata.revision, + lastValidatedTaskRevision: context.task.metadata.revision, + implementationScopeDigest: context.task.metadata.implementationScope.digest, + ownershipEpochAtAcquire: context.task.metadata.ownershipEpoch, + ownerActorId: toActorId, + state: 'pending', + revision: 1, + codeRefAtAcquire: { branch, head: codeHead }, + lastValidatedCodeRef: { branch, head: codeHead }, + acquisitionEnforcement: capabilities.claimAcquisition, + writeGuard: capabilities.writeGuard, + predecessorClaimId: predecessor.claimId, + successorClaimId: null, + lastOperationId: context.operationId, + createdAt: timestamp, + updatedAt: timestamp, + }); +} + +function transferPredecessorClaim( + previous: ClaimV1, + successorClaimId: Ulid, + operationId: Ulid, + timestamp: string, +): ClaimV1 { + const next = parseClaim({ + ...previous, + state: 'transferred', + revision: previous.revision + 1, + successorClaimId, + lastOperationId: operationId, + updatedAt: timestamp, + }); + assertClaimTransition(previous, next); + return next; +} + +function activateSuccessorClaim( + previous: ClaimV1, + operationId: Ulid, + timestamp: string, +): ClaimV1 { + const next = parseClaim({ + ...previous, + state: 'active', + revision: previous.revision + 1, + lastOperationId: operationId, + updatedAt: timestamp, + }); + assertClaimTransition(previous, next); + return next; +} + +function markClaimValidationPending( + previous: WorkflowMetadataV3, + operationId: Ulid, + timestamp: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'operation_pending', + lastOperationId: operationId, + updatedAt: timestamp, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function completeClaimValidationMetadata( + previous: WorkflowMetadataV3, + checkpoint: CheckpointV1 | null, + operationId: Ulid, + timestamp: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'stable', + latestCheckpointRef: + checkpoint === null + ? previous.latestCheckpointRef + : { + taskRef: checkpoint.taskRef, + kind: 'checkpoint', + artifactId: checkpoint.checkpointId, + }, + lastOperationId: operationId, + updatedAt: timestamp, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function buildBaseChangedCheckpoint( + context: OpenedV3TaskOperation, + pendingMetadata: WorkflowMetadataV3, + checkpointId: Ulid, + summary: string | undefined, + nextAction: string | undefined, + branch: string, + timestamp: string, +): CheckpointV1 { + return parseCheckpoint({ + schemaVersion: 1, + checkpointId, + operationId: context.operationId, + taskRef: context.taskRef, + taskRevision: pendingMetadata.revision, + ownershipEpochAtOffer: pendingMetadata.ownershipEpoch, + kind: 'base_changed', + git: { + branch, + head: requireCodeHead(context), + base: pendingMetadata.base?.head ?? null, + }, + summary: + summary ?? 'Refreshed the claim after the checked-out code head changed.', + governance: { + requirementsDigest: context.task.requirements.contentDigest, + planVersion: pendingMetadata.governance.planVersion, + reviewLedgerDigest: context.task.review.contentDigest, + verificationLedgerDigest: context.task.verification.contentDigest, + }, + nextAction: + nextAction ?? + 'Continue only after the refreshed claim snapshot is visible as stable.', + createdBy: { + actorId: context.session.actorId, + client: context.session.client, + }, + createdAt: timestamp, + }); +} + +function revalidationExpectedRevisions( + context: OpenedV3TaskOperation, + claim: ClaimV1, + checkpoint: CheckpointV1 | null, +): Record { + const expected: Record = { + [taskEntityKey(context.taskRef)]: context.task.metadata.revision, + [`claim:${claim.claimId}`]: claim.revision, + }; + if (checkpoint !== null) { + expected[`checkpoint:${checkpoint.checkpointId}`] = 0; + } + const fence = context.coordination.taskHeadFence; + if (fence === null) throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + expected[taskHeadEntityKey(context.taskRef)] = fence.fenceRevision; + return expected; +} + +/** Stable IDs are essential when a transfer is resumed after a crash. */ +export function claimTransferSuccessorId( + operationId: Ulid, + predecessorClaimId: Ulid, + timestamp: string, +): Ulid { + assertUlid(operationId, 'claim transfer operationId'); + assertUlid(predecessorClaimId, 'claim transfer predecessorClaimId'); + const milliseconds = Date.parse(timestamp); + if (Number.isNaN(milliseconds)) { + throw new Error('MANCODE_CLAIM_TRANSFER_TIMESTAMP_INVALID'); + } + const entropy = createHash('sha256') + .update(`claim-transfer:${operationId}:${predecessorClaimId}`, 'utf8') + .digest() + .subarray(0, 10); + return createUlid(milliseconds, entropy); +} + +function requireCodeHead(context: OpenedV3TaskOperation): string { + if (context.codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + } + return context.codeHead; +} + +function assertPositiveRevision(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error( + `MANCODE_${label.toUpperCase().replaceAll(' ', '_')}_INVALID`, + ); + } +} diff --git a/src/team/claims.ts b/src/team/claims.ts new file mode 100644 index 0000000..badde52 --- /dev/null +++ b/src/team/claims.ts @@ -0,0 +1,436 @@ +import { + digestCanonicalJson, + sortUtf8StringSet, +} from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { assertSharedTextSafe } from '../context/privacy.js'; +import { + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { type CapabilityLevel, parseCapabilityLevel } from './capabilities.js'; + +export type CoordinationMode = 'local' | 'git-ref'; +export type ClaimState = + | 'pending' + | 'active' + | 'released' + | 'expired' + | 'transferred' + | 'cancelled'; + +export interface CodeRef { + branch: string; + head: string; +} + +export interface ClaimScope { + paths: string[]; + modules: string[]; + apis: string[]; + schemas: string[]; +} + +export interface ClaimV1 { + schemaVersion: 1; + claimId: Ulid; + workspaceId: Ulid; + coordinationDomainId: string; + authority: { + mode: CoordinationMode; + remoteRevision: string | null; + }; + taskRef: TaskRef; + taskRevisionAtAcquire: number; + lastValidatedTaskRevision: number; + implementationScopeDigest: string; + ownershipEpochAtAcquire: number; + ownerActorId: Ulid; + state: ClaimState; + revision: number; + scope: ClaimScope; + scopeDigest: string; + codeRefAtAcquire: CodeRef; + lastValidatedCodeRef: CodeRef; + acquisitionEnforcement: CapabilityLevel; + writeGuard: CapabilityLevel; + expiresAt: string; + predecessorClaimId: Ulid | null; + successorClaimId: Ulid | null; + lastOperationId: Ulid | null; + createdAt: string; + updatedAt: string; +} + +const CLAIM_STATES = new Set([ + 'pending', + 'active', + 'released', + 'expired', + 'transferred', + 'cancelled', +]); +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function parseClaim(value: unknown): ClaimV1 { + assertRecord(value, 'claim'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'claimId', + 'workspaceId', + 'coordinationDomainId', + 'authority', + 'taskRef', + 'taskRevisionAtAcquire', + 'lastValidatedTaskRevision', + 'implementationScopeDigest', + 'ownershipEpochAtAcquire', + 'ownerActorId', + 'state', + 'revision', + 'scope', + 'scopeDigest', + 'codeRefAtAcquire', + 'lastValidatedCodeRef', + 'acquisitionEnforcement', + 'writeGuard', + 'expiresAt', + 'predecessorClaimId', + 'successorClaimId', + 'lastOperationId', + 'createdAt', + 'updatedAt', + ], + 'claim', + ); + if (value.schemaVersion !== 1) + throw new Error('claim schemaVersion must be 1'); + assertUlid(value.claimId, 'claimId'); + assertUlid(value.workspaceId, 'claim workspaceId'); + assertUlid(value.ownerActorId, 'claim ownerActorId'); + const state = parseClaimState(value.state); + const scope = parseClaimScope(value.scope); + const scopeDigest = parseDigest(value.scopeDigest, 'claim scopeDigest'); + if (scopeDigest !== digestCanonicalJson(scope)) { + throw new Error('claim scopeDigest does not match the canonical scope'); + } + const taskRef = parseTaskRefValue(value.taskRef); + if (taskRef.namespace !== 'shared') { + throw new Error('claims may only target shared TaskRefs'); + } + const claim: ClaimV1 = { + schemaVersion: 1, + claimId: value.claimId, + workspaceId: value.workspaceId, + coordinationDomainId: parseCoordinationDomainId(value.coordinationDomainId), + authority: parseAuthority(value.authority, state), + taskRef, + taskRevisionAtAcquire: parsePositiveInteger( + value.taskRevisionAtAcquire, + 'claim taskRevisionAtAcquire', + ), + lastValidatedTaskRevision: parsePositiveInteger( + value.lastValidatedTaskRevision, + 'claim lastValidatedTaskRevision', + ), + implementationScopeDigest: parseDigest( + value.implementationScopeDigest, + 'claim implementationScopeDigest', + ), + ownershipEpochAtAcquire: parseNonNegativeInteger( + value.ownershipEpochAtAcquire, + 'claim ownershipEpochAtAcquire', + ), + ownerActorId: value.ownerActorId, + state, + revision: parsePositiveInteger(value.revision, 'claim revision'), + scope, + scopeDigest, + codeRefAtAcquire: parseCodeRef( + value.codeRefAtAcquire, + 'claim codeRefAtAcquire', + ), + lastValidatedCodeRef: parseCodeRef( + value.lastValidatedCodeRef, + 'claim lastValidatedCodeRef', + ), + acquisitionEnforcement: parseCapabilityLevel( + value.acquisitionEnforcement, + 'claim acquisitionEnforcement', + ), + writeGuard: parseCapabilityLevel(value.writeGuard, 'claim writeGuard'), + expiresAt: parseTimestamp(value.expiresAt, 'claim expiresAt'), + predecessorClaimId: parseUlidOrNull( + value.predecessorClaimId, + 'claim predecessorClaimId', + ), + successorClaimId: parseUlidOrNull( + value.successorClaimId, + 'claim successorClaimId', + ), + lastOperationId: parseUlidOrNull( + value.lastOperationId, + 'claim lastOperationId', + ), + createdAt: parseTimestamp(value.createdAt, 'claim createdAt'), + updatedAt: parseTimestamp(value.updatedAt, 'claim updatedAt'), + }; + assertClaimStateShape(claim); + return claim; +} + +export function assertClaimTransition(previous: ClaimV1, next: ClaimV1): void { + assertClaimIdentityIsStable(previous, next); + if (next.revision !== previous.revision + 1) { + throw new Error('claim revision must increase exactly once per mutation'); + } + if (next.lastValidatedTaskRevision < previous.lastValidatedTaskRevision) { + throw new Error('claim lastValidatedTaskRevision cannot regress'); + } + if (previous.state === next.state) return; + if (!allowedClaimTransitions(previous.state).has(next.state)) { + throw new Error( + `invalid claim state transition: ${previous.state} -> ${next.state}`, + ); + } +} + +function parseClaimState(value: unknown): ClaimState { + if (typeof value !== 'string' || !CLAIM_STATES.has(value as ClaimState)) { + throw new Error('claim state is invalid'); + } + return value as ClaimState; +} + +export function parseClaimScope(value: unknown): ClaimScope { + assertRecord(value, 'claim scope'); + assertKnownKeys( + value, + ['paths', 'modules', 'apis', 'schemas'], + 'claim scope', + ); + const scope: ClaimScope = { + paths: parseStringSet(value.paths, 'claim scope paths'), + modules: parseStringSet(value.modules, 'claim scope modules'), + apis: parseStringSet(value.apis, 'claim scope apis'), + schemas: parseStringSet(value.schemas, 'claim scope schemas'), + }; + if ( + scope.paths.length === 0 && + scope.modules.length === 0 && + scope.apis.length === 0 && + scope.schemas.length === 0 + ) { + throw new Error( + 'claim scope must include at least one path, module, API, or schema', + ); + } + for (const [label, values] of [ + ['paths', scope.paths], + ['modules', scope.modules], + ['apis', scope.apis], + ['schemas', scope.schemas], + ] as const) { + for (const item of values) { + assertSharedTextSafe(item, `claim scope ${label}`); + if (label === 'paths') assertClaimScopePath(item); + } + } + return scope; +} + +/** Normalizes interactive input before applying the stricter stored schema. */ +export function normalizeClaimScope(value: unknown): ClaimScope { + assertRecord(value, 'claim scope'); + assertKnownKeys( + value, + ['paths', 'modules', 'apis', 'schemas'], + 'claim scope', + ); + return parseClaimScope({ + paths: normalizeStringSet(value.paths, 'claim scope paths'), + modules: normalizeStringSet(value.modules, 'claim scope modules'), + apis: normalizeStringSet(value.apis, 'claim scope apis'), + schemas: normalizeStringSet(value.schemas, 'claim scope schemas'), + }); +} + +/** Claim globs are repository-relative, POSIX-style patterns only. */ +function assertClaimScopePath(value: string): void { + if ( + value.startsWith('/') || + value.startsWith('~') || + /^[A-Za-z]:/.test(value) || + value.includes('\\') || + value + .split('/') + .some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw new Error('claim scope paths must be safe repository-relative globs'); + } +} + +function parseAuthority( + value: unknown, + state: ClaimState, +): ClaimV1['authority'] { + assertRecord(value, 'claim authority'); + assertKnownKeys(value, ['mode', 'remoteRevision'], 'claim authority'); + if (value.mode !== 'local' && value.mode !== 'git-ref') { + throw new Error('claim authority mode must be local or git-ref'); + } + if (value.mode === 'local' && value.remoteRevision !== null) { + throw new Error('local claim authority must not carry a remote revision'); + } + if ( + value.mode === 'git-ref' && + state === 'active' && + (typeof value.remoteRevision !== 'string' || !value.remoteRevision.trim()) + ) { + throw new Error('active git-ref claims require a remote revision'); + } + if ( + value.remoteRevision !== null && + (typeof value.remoteRevision !== 'string' || !value.remoteRevision.trim()) + ) { + throw new Error('claim remote revision must be a non-empty string or null'); + } + return { mode: value.mode, remoteRevision: value.remoteRevision }; +} + +function parseCodeRef(value: unknown, label: string): CodeRef { + assertRecord(value, label); + assertKnownKeys(value, ['branch', 'head'], label); + if ( + typeof value.branch !== 'string' || + !value.branch.trim() || + typeof value.head !== 'string' || + !value.head.trim() + ) { + throw new Error(`${label} branch and head are required`); + } + return { branch: value.branch, head: value.head }; +} + +function parseStringSet(value: unknown, label: string): string[] { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== 'string' || !item.trim()) + ) { + throw new Error(`${label} must be an array of non-empty strings`); + } + const normalized = sortUtf8StringSet(value); + if (normalized.length !== value.length) { + throw new Error(`${label} must not contain duplicates`); + } + return normalized; +} + +function normalizeStringSet(value: unknown, label: string): string[] { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== 'string' || !item.trim()) + ) { + throw new Error(`${label} must be an array of non-empty strings`); + } + return sortUtf8StringSet(value); +} + +function parseCoordinationDomainId(value: unknown): string { + if ( + typeof value !== 'string' || + !/^(local|git-ref):[^\0]+$/.test(value) || + value.includes('..') + ) { + throw new Error('claim coordinationDomainId is invalid'); + } + return value; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parseNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseUlidOrNull(value: unknown, label: string): Ulid | null { + if (value === null) return null; + assertUlid(value, label); + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function assertClaimStateShape(claim: ClaimV1): void { + if (claim.state === 'transferred' && claim.successorClaimId === null) { + throw new Error('transferred claims require successorClaimId'); + } + if (claim.state === 'active' && claim.successorClaimId !== null) { + throw new Error('active claims cannot have successorClaimId'); + } + if ( + claim.lastValidatedTaskRevision < claim.taskRevisionAtAcquire || + claim.lastValidatedCodeRef.head.length === 0 + ) { + throw new Error('claim validation snapshot is invalid'); + } +} + +function assertClaimIdentityIsStable(previous: ClaimV1, next: ClaimV1): void { + if ( + previous.claimId !== next.claimId || + previous.workspaceId !== next.workspaceId || + previous.coordinationDomainId !== next.coordinationDomainId || + previous.authority.mode !== next.authority.mode || + !sameTaskRef(previous.taskRef, next.taskRef) || + previous.taskRevisionAtAcquire !== next.taskRevisionAtAcquire || + previous.implementationScopeDigest !== next.implementationScopeDigest || + previous.ownershipEpochAtAcquire !== next.ownershipEpochAtAcquire || + previous.ownerActorId !== next.ownerActorId || + previous.scopeDigest !== next.scopeDigest || + JSON.stringify(previous.scope) !== JSON.stringify(next.scope) || + JSON.stringify(previous.codeRefAtAcquire) !== + JSON.stringify(next.codeRefAtAcquire) || + previous.createdAt !== next.createdAt + ) { + throw new Error('claim acquire snapshot and scope are immutable'); + } +} + +function allowedClaimTransitions(from: ClaimState): Set { + switch (from) { + case 'pending': + return new Set(['active', 'cancelled']); + case 'active': + return new Set(['released', 'expired', 'transferred']); + case 'released': + case 'expired': + case 'transferred': + case 'cancelled': + return new Set(); + } +} diff --git a/src/team/conflicts.ts b/src/team/conflicts.ts new file mode 100644 index 0000000..3e62b66 --- /dev/null +++ b/src/team/conflicts.ts @@ -0,0 +1,354 @@ +import { type TaskRef, sameTaskRef } from '../context/task-ref.js'; +import type { WorkflowMetadataV3 } from '../context/workflow-metadata.js'; +import type { CapabilityLevel, Freshness } from './capabilities.js'; +import type { ClaimScope, ClaimV1 } from './claims.js'; + +export type ClaimValidity = + | 'inactive' + | 'reclaim_required' + | 'needs_revalidation' + | 'code_ref_stale' + | 'expired' + | 'expiry_candidate' + | 'fresh'; +export type ClaimConflictLevel = + | 'none' + | 'info' + | 'warning' + | 'blocker' + | 'unknown'; +export type ClaimAcquisitionDecision = + | 'allow' + | 'confirm_or_narrow' + | 'reject' + | 'sync_or_confirm' + | 'unavailable'; + +export interface ClaimValidationContext { + taskRef: TaskRef; + taskRevision: number; + implementationScopeDigest: string; + ownershipEpoch: number; + codeRefHead: string; + now?: Date; + transportFreshness: Freshness; +} + +export interface ClaimScopeBoundary { + source: WorkflowMetadataV3['implementationScope']['source']; + include: string[]; + exclude: string[]; + modules: string[]; + apis?: string[]; + schemas?: string[]; +} + +export interface ClaimScopeSubsetResult { + allowed: boolean; + reasons: Array< + | 'scope_confirmation_required' + | 'path_outside_include' + | 'path_overlaps_exclude' + | 'module_outside_scope' + | 'api_unverifiable' + | 'api_outside_scope' + | 'schema_unverifiable' + | 'schema_outside_scope' + >; +} + +export interface ClaimConflictAssessment { + level: ClaimConflictLevel; + acquisition: ClaimAcquisitionDecision; + conflictingClaimIds: string[]; + reasons: string[]; +} + +/** + * Derives, rather than persists, stale claim state. Scope/owner drift can + * never be repaired by renew: callers must terminate and re-claim with a new + * identity. A git-ref expiry is only a candidate until remote CAS decides it. + */ +export function deriveClaimValidity( + claim: ClaimV1, + context: ClaimValidationContext, +): ClaimValidity { + assertClaimValidationContext(context); + if (claim.state !== 'active') return 'inactive'; + if (!sameTaskRef(claim.taskRef, context.taskRef)) return 'reclaim_required'; + if ( + claim.implementationScopeDigest !== context.implementationScopeDigest || + claim.ownershipEpochAtAcquire !== context.ownershipEpoch + ) { + return 'reclaim_required'; + } + if (claim.lastValidatedTaskRevision < context.taskRevision) { + return 'needs_revalidation'; + } + if (claim.lastValidatedCodeRef.head !== context.codeRefHead) { + return 'code_ref_stale'; + } + const now = context.now ?? new Date(); + if (Date.parse(claim.expiresAt) <= now.getTime()) { + return claim.authority.mode === 'git-ref' ? 'expiry_candidate' : 'expired'; + } + return 'fresh'; +} + +/** Conservative scope proof; unknown API/schema boundaries are never assumed safe. */ +export function evaluateClaimScopeSubset( + scope: ClaimScope, + boundary: ClaimScopeBoundary, +): ClaimScopeSubsetResult { + const reasons: ClaimScopeSubsetResult['reasons'] = []; + if (boundary.source === 'legacy_unspecified') { + reasons.push('scope_confirmation_required'); + return { allowed: false, reasons }; + } + for (const claimPath of scope.paths) { + if ( + !boundary.include.some((include) => isPathSubsetOf(claimPath, include)) + ) { + reasons.push('path_outside_include'); + continue; + } + if ( + boundary.exclude.some((exclude) => + globPatternsMayOverlap(claimPath, exclude), + ) + ) { + reasons.push('path_overlaps_exclude'); + } + } + for (const module of scope.modules) { + if (!boundary.modules.includes(module)) + reasons.push('module_outside_scope'); + } + evaluateNamedScopeSubset(scope.apis, boundary.apis, 'api', reasons); + evaluateNamedScopeSubset(scope.schemas, boundary.schemas, 'schema', reasons); + return { allowed: reasons.length === 0, reasons: unique(reasons) }; +} + +export function assertClaimScopeSubset( + scope: ClaimScope, + boundary: ClaimScopeBoundary, +): void { + const result = evaluateClaimScopeSubset(scope, boundary); + if (result.allowed) return; + if (result.reasons.includes('scope_confirmation_required')) { + throw new Error('MANCODE_SCOPE_CONFIRMATION_REQUIRED'); + } + throw new Error( + `MANCODE_SCOPE_OUTSIDE_IMPLEMENTATION_SCOPE: ${result.reasons.join(',')}`, + ); +} + +/** + * Computes conflict policy without mutating any claim. Only a fresh authority + * can claim a blocker; stale/unknown remote state deliberately becomes + * unknown rather than a false "no conflict" assertion. + */ +export function assessClaimConflicts( + candidateScope: ClaimScope, + activeClaims: readonly ClaimV1[], + options: { + transportFreshness: Freshness; + claimAcquisition: CapabilityLevel; + }, +): ClaimConflictAssessment { + if ( + options.claimAcquisition === 'unavailable' || + options.transportFreshness === 'unavailable' + ) { + return emptyAssessment('unknown', 'unavailable', [ + 'claim acquisition unavailable', + ]); + } + if ( + options.transportFreshness === 'stale' || + options.transportFreshness === 'unknown' + ) { + return emptyAssessment('unknown', 'sync_or_confirm', [ + 'coordination freshness is not proven', + ]); + } + let level: ClaimConflictLevel = 'none'; + const claimIds: string[] = []; + const reasons: string[] = []; + for (const claim of activeClaims) { + if (claim.state !== 'active') continue; + const relation = compareClaimScopes(candidateScope, claim.scope); + if (relation === 'none') continue; + claimIds.push(claim.claimId); + reasons.push(`${claim.claimId}:${relation}`); + level = strongestLevel(level, relation); + } + const acquisition = acquisitionFor(level, options.claimAcquisition); + return { + level, + acquisition, + conflictingClaimIds: [...new Set(claimIds)].sort(), + reasons: [...new Set(reasons)].sort(), + }; +} + +function evaluateNamedScopeSubset( + values: string[], + boundaryValues: string[] | undefined, + kind: 'api' | 'schema', + reasons: ClaimScopeSubsetResult['reasons'], +): void { + if (values.length === 0) return; + if (boundaryValues === undefined) { + reasons.push(kind === 'api' ? 'api_unverifiable' : 'schema_unverifiable'); + return; + } + for (const value of values) { + if (!boundaryValues.includes(value)) { + reasons.push( + kind === 'api' ? 'api_outside_scope' : 'schema_outside_scope', + ); + } + } +} + +function compareClaimScopes( + candidate: ClaimScope, + existing: ClaimScope, +): ClaimConflictLevel { + if (hasIntersection(candidate.schemas, existing.schemas)) return 'blocker'; + if (hasExactPathIntersection(candidate.paths, existing.paths)) + return 'blocker'; + if (hasPotentialPathIntersection(candidate.paths, existing.paths)) + return 'warning'; + if ( + hasIntersection(candidate.modules, existing.modules) || + hasIntersection(candidate.apis, existing.apis) + ) { + return 'info'; + } + return 'none'; +} + +function hasExactPathIntersection(left: string[], right: string[]): boolean { + return left.some( + (value) => !containsGlob(value) && right.some((other) => other === value), + ); +} + +function hasPotentialPathIntersection( + left: string[], + right: string[], +): boolean { + return left.some((value) => + right.some( + (other) => + globPatternsMayOverlap(value, other) && + (value !== other || containsGlob(value) || containsGlob(other)), + ), + ); +} + +function isPathSubsetOf(candidate: string, boundary: string): boolean { + if (candidate === boundary) return true; + const candidatePrefix = staticPathPrefix(candidate); + const boundaryPrefix = staticPathPrefix(boundary); + if (!candidatePrefix.startsWith(boundaryPrefix)) return false; + if (!containsGlob(boundary)) return candidate === boundary; + return boundary.endsWith('/**') || boundary.endsWith('*'); +} + +function globPatternsMayOverlap(left: string, right: string): boolean { + if (left === right) return true; + const leftPrefix = staticPathPrefix(left); + const rightPrefix = staticPathPrefix(right); + return ( + leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix) + ); +} + +function staticPathPrefix(value: string): string { + const wildcard = value.search(/[*!?\[]/); + const prefix = wildcard === -1 ? value : value.slice(0, wildcard); + return prefix.endsWith('/') + ? prefix + : prefix.slice(0, prefix.lastIndexOf('/') + 1); +} + +function containsGlob(value: string): boolean { + return /[*!?\[]/.test(value); +} + +function hasIntersection(left: string[], right: string[]): boolean { + return left.some((value) => right.includes(value)); +} + +function strongestLevel( + left: ClaimConflictLevel, + right: ClaimConflictLevel, +): ClaimConflictLevel { + const priority: Record = { + none: 0, + info: 1, + warning: 2, + blocker: 3, + unknown: 4, + }; + return priority[right] > priority[left] ? right : left; +} + +function acquisitionFor( + level: ClaimConflictLevel, + enforcement: CapabilityLevel, +): ClaimAcquisitionDecision { + if (enforcement === 'unavailable') return 'unavailable'; + switch (level) { + case 'none': + case 'info': + return 'allow'; + case 'warning': + return 'confirm_or_narrow'; + case 'blocker': + return enforcement === 'enforced' ? 'reject' : 'confirm_or_narrow'; + case 'unknown': + return 'sync_or_confirm'; + } +} + +function emptyAssessment( + level: ClaimConflictLevel, + acquisition: ClaimAcquisitionDecision, + reasons: string[], +): ClaimConflictAssessment { + return { level, acquisition, conflictingClaimIds: [], reasons }; +} + +function assertClaimValidationContext(context: ClaimValidationContext): void { + if (!Number.isSafeInteger(context.taskRevision) || context.taskRevision < 1) { + throw new Error('claim validation taskRevision must be a positive integer'); + } + if (!/^sha256:[a-f0-9]{64}$/.test(context.implementationScopeDigest)) { + throw new Error('claim validation implementationScopeDigest is invalid'); + } + if ( + !Number.isSafeInteger(context.ownershipEpoch) || + context.ownershipEpoch < 0 + ) { + throw new Error('claim validation ownershipEpoch must be non-negative'); + } + if (typeof context.codeRefHead !== 'string' || !context.codeRefHead.trim()) { + throw new Error('claim validation codeRefHead is required'); + } + if ( + context.transportFreshness !== 'fresh' && + context.transportFreshness !== 'stale' && + context.transportFreshness !== 'unknown' && + context.transportFreshness !== 'unavailable' + ) { + throw new Error('claim validation transportFreshness is invalid'); + } +} + +function unique(values: T[]): T[] { + return [...new Set(values)]; +} diff --git a/src/team/events.ts b/src/team/events.ts new file mode 100644 index 0000000..0595190 --- /dev/null +++ b/src/team/events.ts @@ -0,0 +1,366 @@ +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { type TaskRef, parseTaskRefValue } from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; + +/** + * Events are intentionally a narrow audit projection. They do not include a + * summary, command, prompt, path, or arbitrary metadata field: those values + * belong in the authoritative entity or a local-only artifact. + */ +export interface TeamEventV1 { + schemaVersion: 1; + eventId: Ulid; + eventType: string; + operationId: Ulid; + entityRef: TeamEventEntityRef; + taskRef: TaskRef | null; + actorId: Ulid; + taskRevision: number | null; + createdAt: string; +} + +export interface TeamEventEntityRef { + kind: TeamEventEntityKind; + id: Ulid; +} + +export type TeamEventEntityKind = + | 'workflow' + | 'claim' + | 'checkpoint' + | 'handoff' + | 'actor' + | 'decision' + | 'team_policy' + | 'project_config' + | 'transport'; + +const EVENT_ENTITY_KINDS = new Set([ + 'workflow', + 'claim', + 'checkpoint', + 'handoff', + 'actor', + 'decision', + 'team_policy', + 'project_config', + 'transport', +]); +const TASK_SCOPED_EVENT_KINDS = new Set([ + 'workflow', + 'claim', + 'checkpoint', + 'handoff', +]); +const EVENT_TYPE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/; + +export function parseTeamEvent(value: unknown): TeamEventV1 { + assertRecord(value, 'team event'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'eventId', + 'eventType', + 'operationId', + 'entityRef', + 'taskRef', + 'actorId', + 'taskRevision', + 'createdAt', + ], + 'team event', + ); + if (value.schemaVersion !== 1) { + throw new Error('team event schemaVersion must be 1'); + } + assertUlid(value.eventId, 'team event eventId'); + assertUlid(value.operationId, 'team event operationId'); + assertUlid(value.actorId, 'team event actorId'); + const event: TeamEventV1 = { + schemaVersion: 1, + eventId: value.eventId, + eventType: parseEventType(value.eventType), + operationId: value.operationId, + entityRef: parseEntityRef(value.entityRef), + taskRef: value.taskRef === null ? null : parseTaskRefValue(value.taskRef), + actorId: value.actorId, + taskRevision: parseTaskRevision(value.taskRevision), + createdAt: parseTimestamp(value.createdAt, 'team event createdAt'), + }; + assertTeamEventShape(event); + return event; +} + +/** The only idempotency key for an audit projection. */ +export function teamEventDedupeKey(event: TeamEventV1): string { + return `${event.operationId}:${event.eventType}`; +} + +/** Full immutable event digest, suitable for a file integrity manifest. */ +export function teamEventDigest(event: TeamEventV1): string { + return digestCanonicalJson(event); +} + +/** + * Event files are a shared audit projection, outside the runtime journal + * store. Callers must emit only after the operation commit point; a failed + * event write never invalidates the authoritative business mutation. + */ +export async function writeTeamEvent( + projectRoot: string, + event: TeamEventV1, +): Promise { + const parsed = parseTeamEvent(event); + const existingForOperation = await findTeamEventByDedupeKey( + projectRoot, + parsed.operationId, + parsed.eventType, + ); + if (existingForOperation !== null) { + assertTeamEventDedupeCompatible(existingForOperation, parsed); + return existingForOperation; + } + + const target = teamEventPath(projectRoot, parsed.eventId); + await mkdir(path.dirname(target), { recursive: true }); + try { + await writeFile(target, serialize(parsed), { + encoding: 'utf8', + flag: 'wx', + }); + return parsed; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readTeamEvent(projectRoot, parsed.eventId); + if ( + existing !== null && + teamEventDigest(existing) === teamEventDigest(parsed) + ) { + return existing; + } + throw new Error('MANCODE_TEAM_EVENT_ID_CONFLICT'); + } +} + +export async function readTeamEvent( + projectRoot: string, + eventId: string, +): Promise { + assertUlid(eventId, 'team event eventId'); + try { + return parseTeamEvent( + JSON.parse(await readFile(teamEventPath(projectRoot, eventId), 'utf8')), + ); + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) + throw new Error('MANCODE_TEAM_EVENT_CORRUPT'); + throw error; + } +} + +export async function listTeamEvents( + projectRoot: string, +): Promise { + let entries: string[]; + try { + entries = await readdir(teamEventDirectory(projectRoot)); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const events: TeamEventV1[] = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const eventId = entry.slice(0, -'.json'.length); + try { + assertUlid(eventId, 'team event filename'); + } catch { + throw new Error('MANCODE_TEAM_EVENT_CORRUPT'); + } + const event = await readTeamEvent(projectRoot, eventId); + if (event === null) throw new Error('MANCODE_TEAM_EVENT_CORRUPT'); + events.push(event); + } + return events.sort((left, right) => compareUtf8(left.eventId, right.eventId)); +} + +export async function findTeamEventByDedupeKey( + projectRoot: string, + operationId: string, + eventType: string, +): Promise { + assertUlid(operationId, 'team event operationId'); + const parsedEventType = parseEventType(eventType); + const matches = (await listTeamEvents(projectRoot)).filter( + (event) => + event.operationId === operationId && event.eventType === parsedEventType, + ); + if (matches.length === 0) return null; + return dedupeTeamEvents(matches)[0] ?? null; +} + +export function teamEventDirectory(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'shared', + 'team', + 'events', + ); +} + +export function teamEventPath(projectRoot: string, eventId: string): string { + assertUlid(eventId, 'team event eventId'); + return path.join(teamEventDirectory(projectRoot), `${eventId}.json`); +} + +/** + * Deduplicates a materialized event collection without treating event order as + * state. Retried emission may have a different eventId or timestamp, but it + * must describe the exact same operation outcome. + */ +export function dedupeTeamEvents( + events: readonly TeamEventV1[], +): TeamEventV1[] { + const byEventId = new Map(); + const byOperationAndType = new Map(); + for (const rawEvent of events) { + const event = parseTeamEvent(rawEvent); + const existingById = byEventId.get(event.eventId); + if ( + existingById !== undefined && + teamEventDigest(existingById) !== teamEventDigest(event) + ) { + throw new Error('MANCODE_TEAM_EVENT_ID_CONFLICT'); + } + byEventId.set(event.eventId, event); + + const key = teamEventDedupeKey(event); + const existing = byOperationAndType.get(key); + if (existing === undefined) { + byOperationAndType.set(key, event); + continue; + } + assertTeamEventDedupeCompatible(existing, event); + if (compareUtf8(event.eventId, existing.eventId) < 0) { + byOperationAndType.set(key, event); + } + } + return [...byOperationAndType.values()].sort((left, right) => + compareUtf8(left.eventId, right.eventId), + ); +} + +export function assertTeamEventDedupeCompatible( + existing: TeamEventV1, + candidate: TeamEventV1, +): void { + if (teamEventDedupeKey(existing) !== teamEventDedupeKey(candidate)) { + throw new Error('team events do not share an operation/event-type key'); + } + if ( + digestCanonicalJson(teamEventDedupePayload(existing)) !== + digestCanonicalJson(teamEventDedupePayload(candidate)) + ) { + throw new Error('MANCODE_TEAM_EVENT_DEDUPE_CONFLICT'); + } +} + +function teamEventDedupePayload(event: TeamEventV1): object { + return { + eventType: event.eventType, + operationId: event.operationId, + entityRef: event.entityRef, + taskRef: event.taskRef, + actorId: event.actorId, + taskRevision: event.taskRevision, + }; +} + +function parseEventType(value: unknown): string { + if (typeof value !== 'string' || !EVENT_TYPE_PATTERN.test(value)) { + throw new Error('team event eventType is invalid'); + } + return value; +} + +function parseEntityRef(value: unknown): TeamEventEntityRef { + assertRecord(value, 'team event entityRef'); + assertKnownKeys(value, ['kind', 'id'], 'team event entityRef'); + if ( + typeof value.kind !== 'string' || + !EVENT_ENTITY_KINDS.has(value.kind as TeamEventEntityKind) + ) { + throw new Error('team event entityRef kind is invalid'); + } + assertUlid(value.id, 'team event entityRef id'); + return { kind: value.kind as TeamEventEntityKind, id: value.id }; +} + +function parseTaskRevision(value: unknown): number | null { + if (value === null) return null; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error( + 'team event taskRevision must be a positive integer or null', + ); + } + return value; +} + +function assertTeamEventShape(event: TeamEventV1): void { + const taskScoped = TASK_SCOPED_EVENT_KINDS.has(event.entityRef.kind); + if (taskScoped) { + if (event.taskRef === null || event.taskRevision === null) { + throw new Error( + 'task-scoped team events require taskRef and taskRevision', + ); + } + if (event.taskRef.namespace !== 'shared') { + throw new Error('team events may only reference shared TaskRefs'); + } + return; + } + if (event.taskRef !== null || event.taskRevision !== null) { + throw new Error( + 'non-task team events cannot carry taskRef or taskRevision', + ); + } +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function serialize(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/team/git-ref-bundle.ts b/src/team/git-ref-bundle.ts new file mode 100644 index 0000000..d3aed09 --- /dev/null +++ b/src/team/git-ref-bundle.ts @@ -0,0 +1,195 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { lstat, mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { digestCanonicalJson } from '../context/canonical.js'; +import type { StoredTaskSnapshot } from '../context/store.js'; +import { formatTaskRef } from '../context/task-ref.js'; +import { + type GitRefJsonValue, + type GitRefTaskBundleArtifactKind, + type GitRefTaskBundleArtifactV1, + type GitRefTaskBundleV1, + gitRefTaskBundleDigest, + parseGitRefTaskBundle, +} from './git-ref-transport.js'; + +const execFile = promisify(execFileCallback); + +export interface CreateGitRefTaskBundleInput { + task: StoredTaskSnapshot; + codeRef: { branch: string; head: string }; + now?: Date; +} + +/** Builds and re-parses the exact transport representation before any push. */ +export function createGitRefTaskBundle( + input: CreateGitRefTaskBundleInput, +): GitRefTaskBundleV1 { + const { task } = input; + if (task.metadata.taskRef.namespace !== 'shared') { + throw new Error('MANCODE_REMOTE_COORDINATION_REQUIRES_SHARED_TASK'); + } + if (task.aggregate === null) { + throw new Error('MANCODE_TASK_UNAVAILABLE'); + } + const artifacts: GitRefTaskBundleArtifactV1[] = [ + artifact('metadata', 'metadata.json', task.metadata), + artifact('requirements', 'requirements.json', task.requirements), + artifact('review', 'review-ledger.json', task.review), + artifact('verification', 'verification-ledger.json', task.verification), + ]; + if (task.latestCheckpoint !== null) { + artifacts.push( + artifact( + 'checkpoint', + `checkpoints/${task.latestCheckpoint.checkpointId}.json`, + task.latestCheckpoint, + ), + ); + } + if (task.plan !== null) { + artifacts.push(artifact('plan', 'plan.md', task.plan.content)); + } else { + artifacts.push( + artifact( + 'summary', + 'summary.md', + task.latestCheckpoint?.summary ?? + `Task ${formatTaskRef(task.metadata.taskRef)} revision ${task.metadata.revision}.`, + ), + ); + } + artifacts.sort((left, right) => + left.kind < right.kind ? -1 : left.kind > right.kind ? 1 : 0, + ); + const body: Omit = { + schemaVersion: 1, + taskRef: task.metadata.taskRef, + taskRevision: task.metadata.revision, + ownershipEpoch: task.metadata.ownershipEpoch, + aggregate: task.aggregate, + aggregateDigest: digestCanonicalJson(task.aggregate), + codeRef: parseCodeRef(input.codeRef), + artifacts, + createdAt: (input.now ?? new Date()).toISOString(), + }; + return parseGitRefTaskBundle({ + ...body, + bundleDigest: gitRefTaskBundleDigest(body), + }); +} + +/** Git reachability is checked before quarantine may become a usable task. */ +export async function assertGitRefBundleCodeReachable( + projectRoot: string, + bundle: GitRefTaskBundleV1, +): Promise { + const parsed = parseGitRefTaskBundle(bundle); + try { + await execFile( + 'git', + ['cat-file', '-e', `${parsed.codeRef.head}^{commit}`], + { + cwd: path.resolve(projectRoot), + windowsHide: true, + }, + ); + } catch { + throw new Error('MANCODE_TASK_BUNDLE_CODE_UNREACHABLE'); + } +} + +/** Writes an immutable local-only candidate; this is never task authority. */ +export async function quarantineGitRefTaskBundle( + projectRoot: string, + remoteRevision: number, + bundle: GitRefTaskBundleV1, +): Promise { + if (!Number.isSafeInteger(remoteRevision) || remoteRevision < 1) { + throw new Error('MANCODE_TRANSPORT_REVISION_INVALID'); + } + const parsed = parseGitRefTaskBundle(bundle); + const directory = path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'quarantine', + 'git-ref', + parsed.taskRef.taskId, + String(remoteRevision), + ); + await ensureFixedDirectory(projectRoot, [ + '.mancode', + 'local', + 'quarantine', + 'git-ref', + parsed.taskRef.taskId, + String(remoteRevision), + ]); + const target = path.join(directory, `${parsed.bundleDigest.slice(7)}.json`); + try { + await writeFile(target, `${JSON.stringify(parsed, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + return target; +} + +function artifact( + kind: GitRefTaskBundleArtifactKind, + relativePath: string, + value: unknown, +): GitRefTaskBundleArtifactV1 { + const content = JSON.parse(JSON.stringify(value)) as GitRefJsonValue; + return { + kind, + relativePath, + content, + contentDigest: digestCanonicalJson(content), + }; +} + +function parseCodeRef(value: { branch: string; head: string }) { + if ( + typeof value.branch !== 'string' || + !value.branch.trim() || + value.branch.includes('\0') || + typeof value.head !== 'string' || + !/^[0-9a-f]{40,64}$/.test(value.head) + ) { + throw new Error('MANCODE_TASK_BUNDLE_CODE_REF_INVALID'); + } + return { branch: value.branch, head: value.head }; +} + +async function ensureFixedDirectory( + projectRoot: string, + segments: string[], +): Promise { + let current = path.resolve(projectRoot); + for (const segment of segments) { + current = path.join(current, segment); + try { + await mkdir(current); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + const entry = await lstat(current); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} diff --git a/src/team/git-ref-cache.ts b/src/team/git-ref-cache.ts new file mode 100644 index 0000000..e0f60a1 --- /dev/null +++ b/src/team/git-ref-cache.ts @@ -0,0 +1,301 @@ +import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { replaceFileAtomically } from '../runtime/atomic-file.js'; +import { + type GitRefTeamManifestSnapshot, + type GitRefTeamManifestV1, + parseGitRefTeamManifest, +} from './git-ref-transport.js'; +import type { ProjectConfigV1 } from './policy.js'; +import type { CoordinationCapabilitiesV1 } from './transport.js'; + +export const DEFAULT_GIT_REF_FRESHNESS_TTL_MS = 5 * 60 * 1000; + +export interface GitRefTeamCacheV1 { + schemaVersion: 1; + workspaceId: Ulid; + remote: string; + transportEpoch: number; + fetchedAt: string; + commit: string | null; + receipt: string | null; + manifest: GitRefTeamManifestV1 | null; +} + +/** Persists only a snapshot already validated by GitRefTeamManifestStore.pull. */ +export async function writeGitRefTeamCache( + projectRoot: string, + config: ProjectConfigV1, + snapshot: GitRefTeamManifestSnapshot, +): Promise { + const remote = gitRefRemote(config); + const cache = parseGitRefTeamCache({ + schemaVersion: 1, + workspaceId: config.workspaceId, + remote, + transportEpoch: config.transport.epoch, + fetchedAt: snapshot.fetchedAt, + commit: snapshot.commit, + receipt: snapshot.receipt, + manifest: snapshot.manifest, + }); + const directory = await ensureSafeCacheDirectory(projectRoot); + const target = gitRefCachePath(projectRoot); + const temporary = path.join( + directory, + `.snapshot.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, `${JSON.stringify(cache, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + await replaceFileAtomically(temporary, target); + return cache; +} + +export async function readGitRefTeamCache( + projectRoot: string, + config: ProjectConfigV1, +): Promise { + if (config.transport.mode !== 'git-ref') return null; + const target = gitRefCachePath(projectRoot); + try { + await assertSafeCacheDirectory(projectRoot); + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_TRANSPORT_CACHE_UNSAFE'); + } + const cache = parseGitRefTeamCache( + JSON.parse(await readFile(target, 'utf8')), + ); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_TRANSPORT_CACHE_CHANGED_DURING_READ'); + } + if ( + cache.workspaceId !== config.workspaceId || + cache.remote !== config.transport.remote || + cache.transportEpoch !== config.transport.epoch + ) { + return null; + } + return cache; + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_TRANSPORT_CACHE_CORRUPT'); + } + throw error; + } +} + +/** Derives status without network access; only explicit sync refreshes the cache. */ +export function capabilitiesFromGitRefCache( + config: ProjectConfigV1, + cache: GitRefTeamCacheV1 | null, + now: Date = new Date(), + freshnessTtlMs: number = DEFAULT_GIT_REF_FRESHNESS_TTL_MS, +): CoordinationCapabilitiesV1 { + if (config.transport.mode !== 'git-ref') { + throw new Error('MANCODE_TRANSPORT_MODE_INVALID'); + } + if (!Number.isSafeInteger(freshnessTtlMs) || freshnessTtlMs < 1) { + throw new Error('MANCODE_TRANSPORT_FRESHNESS_TTL_INVALID'); + } + if (cache === null) { + return { + claimAcquisition: 'unavailable', + writeGuard: 'advisory', + transport: 'git-ref', + transportFreshness: 'unknown', + lastSuccessfulSyncAt: null, + remoteRevision: null, + }; + } + const age = now.getTime() - Date.parse(cache.fetchedAt); + const fresh = age >= 0 && age <= freshnessTtlMs; + const authorityActive = cache.manifest?.authorityState === 'active'; + return { + claimAcquisition: authorityActive + ? fresh + ? 'enforced' + : 'advisory' + : 'unavailable', + writeGuard: 'advisory', + transport: 'git-ref', + transportFreshness: fresh ? 'fresh' : 'stale', + lastSuccessfulSyncAt: cache.fetchedAt, + remoteRevision: cache.manifest?.revision ?? 0, + }; +} + +export function parseGitRefTeamCache(value: unknown): GitRefTeamCacheV1 { + assertRecord(value, 'git-ref cache'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'workspaceId', + 'remote', + 'transportEpoch', + 'fetchedAt', + 'commit', + 'receipt', + 'manifest', + ], + 'git-ref cache', + ); + if (value.schemaVersion !== 1) { + throw new Error('git-ref cache schemaVersion is invalid'); + } + assertUlid(value.workspaceId, 'git-ref cache workspaceId'); + const remote = parseRemote(value.remote); + const transportEpoch = parsePositiveInteger( + value.transportEpoch, + 'git-ref cache transportEpoch', + ); + const fetchedAt = parseTimestamp(value.fetchedAt, 'git-ref cache fetchedAt'); + const commit = parseCommitOrNull(value.commit); + const receipt = parseReceiptOrNull(value.receipt); + const manifest = + value.manifest === null ? null : parseGitRefTeamManifest(value.manifest); + if ( + (commit === null) !== (manifest === null) || + (receipt === null) !== (manifest === null) + ) { + throw new Error('git-ref cache commit, receipt, and manifest must coexist'); + } + if ( + manifest !== null && + (manifest.workspaceId !== value.workspaceId || + manifest.transportEpoch !== transportEpoch) + ) { + throw new Error('MANCODE_TRANSPORT_CACHE_IDENTITY_MISMATCH'); + } + return { + schemaVersion: 1, + workspaceId: value.workspaceId, + remote, + transportEpoch, + fetchedAt, + commit, + receipt, + manifest, + }; +} + +export function gitRefCachePath(projectRoot: string): string { + return path.join(gitRefCacheDirectory(projectRoot), 'snapshot.json'); +} + +function gitRefCacheDirectory(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'cache', + 'git-ref', + ); +} + +function gitRefRemote(config: ProjectConfigV1): string { + if (config.transport.mode !== 'git-ref' || config.transport.remote === null) { + throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); + } + return parseRemote(config.transport.remote); +} + +function parseRemote(value: unknown): string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error('git-ref cache remote is invalid'); + } + return value; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function parseCommitOrNull(value: unknown): string | null { + if (value === null) return null; + if (typeof value !== 'string' || !/^[0-9a-f]{40,64}$/.test(value)) { + throw new Error('git-ref cache commit is invalid'); + } + return value; +} + +function parseReceiptOrNull(value: unknown): string | null { + if (value === null) return null; + if ( + typeof value !== 'string' || + !/^git-ref:[0-9a-f]{40,64}:sha256:[0-9a-f]{64}$/.test(value) + ) { + throw new Error('git-ref cache receipt is invalid'); + } + return value; +} + +async function ensureSafeCacheDirectory(projectRoot: string): Promise { + const root = path.resolve(projectRoot); + let current = root; + for (const segment of ['.mancode', 'local', 'cache', 'git-ref']) { + current = path.join(current, segment); + try { + await mkdir(current); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + await assertDirectoryNotLinked(current); + } + return current; +} + +async function assertSafeCacheDirectory(projectRoot: string): Promise { + let current = path.resolve(projectRoot); + for (const segment of ['.mancode', 'local', 'cache', 'git-ref']) { + current = path.join(current, segment); + await assertDirectoryNotLinked(current); + } +} + +async function assertDirectoryNotLinked(directory: string): Promise { + const entry = await lstat(directory); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_TRANSPORT_CACHE_UNSAFE'); + } +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} diff --git a/src/team/git-ref-client.ts b/src/team/git-ref-client.ts new file mode 100644 index 0000000..f1069a7 --- /dev/null +++ b/src/team/git-ref-client.ts @@ -0,0 +1,25 @@ +import type { SchemaManifestV1 } from '../context/manifest.js'; +import { GitRefTeamManifestStore } from './git-ref-transport.js'; +import { type ProjectConfigV1, projectConfigDigest } from './policy.js'; + +/** Creates a strict client bound to the exact active project/transport header. */ +export function createGitRefTeamManifestStore( + projectRoot: string, + config: ProjectConfigV1, + manifest: SchemaManifestV1, +): GitRefTeamManifestStore { + if (config.transport.mode !== 'git-ref' || config.transport.remote === null) { + throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); + } + return new GitRefTeamManifestStore({ + projectRoot, + remote: config.transport.remote, + workspaceId: config.workspaceId, + schemaEpoch: manifest.epoch, + minReaderVersion: manifest.minReaderVersion, + minWriterVersion: manifest.minWriterVersion, + transportEpoch: config.transport.epoch, + configRevision: config.revision, + configDigest: projectConfigDigest(config), + }); +} diff --git a/src/team/git-ref-coordination.ts b/src/team/git-ref-coordination.ts new file mode 100644 index 0000000..4238ed9 --- /dev/null +++ b/src/team/git-ref-coordination.ts @@ -0,0 +1,1324 @@ +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { + type TaskRef, + formatTaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { + type WorkflowMetadataV3, + parseWorkflowMetadata, + workflowMetadataDigest, +} from '../context/workflow-metadata.js'; +import { type ClaimV1, assertClaimTransition, parseClaim } from './claims.js'; +import { + assertClaimScopeSubset, + assessClaimConflicts, + deriveClaimValidity, +} from './conflicts.js'; +import type { + GitRefOwnershipFenceV1, + GitRefRemoteMutationReceiptV1, + GitRefTaskBundleV1, + GitRefTeamManifestV1, + MutateGitRefCoordinationInput, +} from './git-ref-transport.js'; +import { + parseGitRefOwnershipFence, + parseGitRefRemoteMutationReceipt, + parseGitRefTaskBundle, +} from './git-ref-transport.js'; +import { + type HandoffV1, + assertHandoffTransition, + parseHandoff, +} from './handoff.js'; + +export interface MaterializedGitRefCoordinationV1 { + remoteRevision: number; + lastOperationId: Ulid; + ownershipFences: GitRefOwnershipFenceV1[]; + claims: ClaimV1[]; + handoffs: HandoffV1[]; + taskBundles: GitRefTaskBundleV1[]; + receipts: GitRefRemoteMutationReceiptV1[]; + fetchedAt: string; +} + +interface BaseGitRefMutation { + operationId: Ulid; + actorId: Ulid; + taskRef: TaskRef; + expectedRemoteRevision: number; + expectedOwnershipEpoch: number; + now?: Date; +} + +export type PrepareGitRefCoordinationMutationInput = BaseGitRefMutation & + ( + | { + kind: 'ownership_fence'; + taskBundle: GitRefTaskBundleV1; + } + | { + kind: 'claim_acquire'; + claim: ClaimV1; + confirmScopeWarning?: boolean; + } + | { + kind: 'claim_renew'; + claimId: Ulid; + expectedClaimRevision: number; + expiresAt: string; + } + | { + kind: 'claim_release'; + claimId: Ulid; + expectedClaimRevision: number; + } + | { + kind: 'claim_reclaim'; + claimId: Ulid; + expectedClaimRevision: number; + reason: string; + } + | { + kind: 'claim_revalidate'; + claimId: Ulid; + expectedClaimRevision: number; + } + | { + kind: 'claim_transfer'; + claimId: Ulid; + expectedClaimRevision: number; + toActorId: Ulid; + successorClaimId: Ulid; + } + | { + kind: 'handoff_draft'; + handoff: HandoffV1; + } + | { + kind: 'handoff_offer' | 'handoff_reject' | 'handoff_cancel'; + handoffId: Ulid; + expectedHandoffRevision: number; + reason?: string; + } + | { + kind: 'handoff_accept'; + handoffId: Ulid; + expectedHandoffRevision: number; + successorClaimIds: Ulid[]; + taskBundle: GitRefTaskBundleV1; + codeReachable: boolean; + } + ); + +export interface GitRefOwnershipForwardRepairV1 { + taskRef: TaskRef; + ownerActorId: Ulid; + ownershipEpoch: number; + taskRevision: number; + aggregateDigest: string; + operationId: Ulid; +} + +export interface PreparedGitRefCoordinationMutation + extends MutateGitRefCoordinationInput { + forwardRepair: GitRefOwnershipForwardRepairV1 | null; +} + +/** + * Converts a parsed remote manifest into a read-only local cache view. The + * fetched transport marker is a projection only and is never pushed back as + * authoritative remote state. + */ +export function materializeGitRefCoordination( + manifest: GitRefTeamManifestV1, + fetchedAt: string, +): MaterializedGitRefCoordinationV1 { + assertTimestamp(fetchedAt, 'git-ref materialization fetchedAt'); + return { + remoteRevision: manifest.revision, + lastOperationId: manifest.lastOperationId, + ownershipFences: manifest.ownershipFences + .map(parseGitRefOwnershipFence) + .sort(compareFences), + claims: manifest.claims.map(parseClaim).sort(compareClaims), + handoffs: manifest.handoffs + .map((handoff) => materializeHandoff(handoff, fetchedAt)) + .sort(compareHandoffs), + taskBundles: manifest.taskBundles + .map(parseGitRefTaskBundle) + .sort(compareTaskBundles), + receipts: manifest.receipts + .map(parseGitRefRemoteMutationReceipt) + .sort(compareReceipts), + fetchedAt, + }; +} + +/** + * Prepares one task-scoped replacement for GitRefTeamManifestStore. It does + * no I/O: all actor, remote revision, ownership epoch, state transition, and + * cross-entity checks complete before the caller attempts the remote CAS. + */ +export function prepareGitRefCoordinationMutation( + manifest: GitRefTeamManifestV1, + input: PrepareGitRefCoordinationMutationInput, +): PreparedGitRefCoordinationMutation { + const context = openMutationContext(manifest, input); + switch (input.kind) { + case 'ownership_fence': + return prepareOwnershipFence(context, input.taskBundle); + case 'claim_acquire': + return prepareClaimAcquire( + context, + input.claim, + input.confirmScopeWarning === true, + ); + case 'claim_renew': + return prepareClaimRenew( + context, + input.claimId, + input.expectedClaimRevision, + input.expiresAt, + ); + case 'claim_release': + return prepareClaimRelease( + context, + input.claimId, + input.expectedClaimRevision, + ); + case 'claim_reclaim': + return prepareClaimReclaim( + context, + input.claimId, + input.expectedClaimRevision, + input.reason, + ); + case 'claim_revalidate': + return prepareClaimRevalidate( + context, + input.claimId, + input.expectedClaimRevision, + ); + case 'claim_transfer': + return prepareClaimTransfer( + context, + input.claimId, + input.expectedClaimRevision, + input.toActorId, + input.successorClaimId, + ); + case 'handoff_draft': + return prepareHandoffDraft(context, input.handoff); + case 'handoff_offer': + case 'handoff_reject': + case 'handoff_cancel': + return prepareHandoffTransition( + context, + input.kind, + input.handoffId, + input.expectedHandoffRevision, + input.reason, + ); + case 'handoff_accept': + return prepareHandoffAccept(context, input); + } +} + +interface MutationContext { + manifest: GitRefTeamManifestV1; + input: PrepareGitRefCoordinationMutationInput; + taskRef: TaskRef; + now: Date; + timestamp: string; + nextRemoteRevision: number; + fence: GitRefOwnershipFenceV1 | null; + claims: ClaimV1[]; + handoffs: HandoffV1[]; + taskBundle: GitRefTaskBundleV1 | null; + metadata: WorkflowMetadataV3 | null; +} + +function openMutationContext( + manifest: GitRefTeamManifestV1, + input: PrepareGitRefCoordinationMutationInput, +): MutationContext { + assertUlid(input.operationId, 'git-ref coordination operationId'); + assertUlid(input.actorId, 'git-ref coordination actorId'); + const taskRef = parseTaskRefValue(input.taskRef); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_REMOTE_COORDINATION_REQUIRES_SHARED_TASK'); + } + assertNonNegativeInteger( + input.expectedRemoteRevision, + 'git-ref expectedRemoteRevision', + ); + assertNonNegativeInteger( + input.expectedOwnershipEpoch, + 'git-ref expectedOwnershipEpoch', + ); + if (manifest.revision !== input.expectedRemoteRevision) { + throw new Error('MANCODE_TRANSPORT_REVISION_CONFLICT'); + } + if ( + !manifest.actorProfiles.some((profile) => profile.actorId === input.actorId) + ) { + throw new Error('MANCODE_JOIN_REQUIRED'); + } + const now = input.now ?? new Date(); + if (Number.isNaN(now.getTime())) { + throw new Error('git-ref coordination now is invalid'); + } + const fences = manifest.ownershipFences.filter((candidate) => + sameTaskRef(candidate.taskRef, taskRef), + ); + if (fences.length > 1) throw new Error('MANCODE_REMOTE_FENCE_DUPLICATE'); + const bundles = manifest.taskBundles.filter((candidate) => + sameTaskRef(candidate.taskRef, taskRef), + ); + if (bundles.length > 1) throw new Error('MANCODE_TASK_BUNDLE_DUPLICATE'); + const fence = fences[0] ?? null; + if (fence !== null && fence.ownershipEpoch !== input.expectedOwnershipEpoch) { + throw new Error('MANCODE_OWNERSHIP_EPOCH_STALE'); + } + if ( + fence === null && + (input.kind !== 'ownership_fence' || input.expectedOwnershipEpoch !== 0) + ) { + throw new Error('MANCODE_REMOTE_OWNERSHIP_FENCE_MISSING'); + } + const taskBundle = bundles[0] ?? null; + const metadata = taskBundle === null ? null : metadataFromBundle(taskBundle); + if (fence !== null) { + if (taskBundle === null || metadata === null) { + throw new Error('MANCODE_TASK_UNAVAILABLE'); + } + assertFenceBundleConsistent(fence, taskBundle, metadata); + } + return { + manifest, + input, + taskRef, + now, + timestamp: now.toISOString(), + nextRemoteRevision: manifest.revision + 1, + fence, + claims: manifest.claims + .filter((claim) => sameTaskRef(claim.taskRef, taskRef)) + .map(parseClaim) + .sort(compareClaims), + handoffs: manifest.handoffs + .filter((handoff) => sameTaskRef(handoff.taskRef, taskRef)) + .map(parseHandoff) + .sort(compareHandoffs), + taskBundle, + metadata, + }; +} + +function prepareOwnershipFence( + context: MutationContext, + taskBundle: GitRefTaskBundleV1, +): PreparedGitRefCoordinationMutation { + const metadata = metadataFromBundle(taskBundle); + assertRemoteTaskEligible(metadata); + assertTaskBundleIdentity(taskBundle, context.taskRef); + if (metadata.ownerActorId === null) { + throw new Error('MANCODE_TASK_OWNER_REQUIRED'); + } + if (context.fence === null) { + if ( + metadata.ownerActorId !== context.input.actorId || + metadata.ownershipEpoch !== 0 || + taskBundle.ownershipEpoch !== 0 + ) { + throw new Error('MANCODE_TASK_OWNER_REQUIRED'); + } + } else { + assertCurrentTaskOwner(context); + if ( + metadata.ownerActorId !== context.fence.ownerActorId || + metadata.ownershipEpoch !== context.fence.ownershipEpoch + ) { + throw new Error('MANCODE_OWNERSHIP_EPOCH_STALE'); + } + if (taskBundle.taskRevision < context.fence.taskRevision) { + throw new Error('MANCODE_TASK_REVISION_CONFLICT'); + } + if (taskBundle.taskRevision === context.fence.taskRevision) { + if ( + taskBundle.aggregateDigest !== context.fence.aggregateDigest || + taskBundle.codeRef.head !== context.taskBundle?.codeRef.head + ) { + throw new Error('MANCODE_SPLIT_BRAIN'); + } + throw new Error('MANCODE_REMOTE_FENCE_NO_CHANGE'); + } + } + const fence = buildFence(context, taskBundle, metadata.ownerActorId); + return prepared(context, fence, context.claims, context.handoffs, taskBundle); +} + +function prepareClaimAcquire( + context: MutationContext, + proposal: ClaimV1, + confirmScopeWarning: boolean, +): PreparedGitRefCoordinationMutation { + const { fence, taskBundle, metadata } = requireTaskContext(context); + assertRemoteTaskEligible(metadata); + assertOwnerOrParticipant(context, metadata); + const source = parseClaim(proposal); + if (context.claims.some((claim) => claim.claimId === source.claimId)) { + throw new Error('MANCODE_CLAIM_ID_CONFLICT'); + } + if ( + !sameTaskRef(source.taskRef, context.taskRef) || + source.workspaceId !== context.manifest.workspaceId || + source.ownerActorId !== context.input.actorId || + source.revision !== 1 || + (source.state !== 'pending' && source.state !== 'active') || + source.predecessorClaimId !== null || + source.successorClaimId !== null + ) { + throw new Error('MANCODE_REMOTE_CLAIM_PROPOSAL_INVALID'); + } + if (!source.coordinationDomainId.startsWith('git-ref:')) { + throw new Error('MANCODE_COORDINATION_DOMAIN_MISMATCH'); + } + assertClaimSnapshotCurrent(source, fence, taskBundle, metadata, context.now); + assertClaimScopeSubset(source.scope, { + source: metadata.implementationScope.source, + include: metadata.implementationScope.include, + exclude: metadata.implementationScope.exclude, + modules: metadata.implementationScope.modules, + }); + assertAllActiveClaimsFresh(context, fence, taskBundle, metadata); + const conflict = assessClaimConflicts(source.scope, context.claims, { + transportFreshness: 'fresh', + claimAcquisition: 'enforced', + }); + if ( + conflict.acquisition === 'reject' || + (conflict.acquisition === 'confirm_or_narrow' && !confirmScopeWarning) || + conflict.acquisition === 'sync_or_confirm' || + conflict.acquisition === 'unavailable' + ) { + throw new Error('MANCODE_SCOPE_CONFLICT'); + } + const claim = bindClaim(context, { + ...source, + state: 'active', + acquisitionEnforcement: 'enforced', + revision: 1, + }); + return prepared( + context, + advanceFence(context, fence), + [...context.claims, claim], + context.handoffs, + taskBundle, + ); +} + +function prepareClaimRenew( + context: MutationContext, + claimId: Ulid, + expectedRevision: number, + expiresAt: string, +): PreparedGitRefCoordinationMutation { + const { fence, taskBundle, metadata } = requireTaskContext(context); + assertRemoteTaskEligible(metadata); + const claim = requireClaim(context, claimId, expectedRevision); + assertClaimOwner(context, claim); + if (claim.state !== 'active') throw new Error('MANCODE_CLAIM_NOT_ACTIVE'); + if ( + deriveClaimValidity( + claim, + claimValidation(fence, taskBundle, metadata, context.now), + ) !== 'fresh' + ) { + throw new Error('MANCODE_CLAIM_REVALIDATION_REQUIRED'); + } + assertTimestamp(expiresAt, 'git-ref claim expiresAt'); + if ( + Date.parse(expiresAt) <= Date.parse(claim.expiresAt) || + Date.parse(expiresAt) <= context.now.getTime() + ) { + throw new Error('MANCODE_CLAIM_TTL_INVALID'); + } + const next = bindClaim(context, { + ...claim, + revision: claim.revision + 1, + expiresAt, + }); + assertClaimTransition(claim, next); + return replaceClaim(context, fence, taskBundle, next); +} + +function prepareClaimRelease( + context: MutationContext, + claimId: Ulid, + expectedRevision: number, +): PreparedGitRefCoordinationMutation { + const { fence, taskBundle } = requireTaskContext(context); + const claim = requireClaim(context, claimId, expectedRevision); + assertClaimOwner(context, claim); + if (claim.state !== 'active') throw new Error('MANCODE_CLAIM_NOT_ACTIVE'); + const next = bindClaim(context, { + ...claim, + state: 'released', + revision: claim.revision + 1, + }); + assertClaimTransition(claim, next); + return replaceClaim(context, fence, taskBundle, next); +} + +function prepareClaimReclaim( + context: MutationContext, + claimId: Ulid, + expectedRevision: number, + reason: string, +): PreparedGitRefCoordinationMutation { + const { fence, taskBundle } = requireTaskContext(context); + assertCurrentTaskOwner(context); + if (!reason.trim()) throw new Error('MANCODE_RECLAIM_REASON_REQUIRED'); + const claim = requireClaim(context, claimId, expectedRevision); + if (claim.state !== 'active') throw new Error('MANCODE_CLAIM_NOT_ACTIVE'); + if (Date.parse(claim.expiresAt) > context.now.getTime()) { + throw new Error('MANCODE_CLAIM_NOT_EXPIRED'); + } + const next = bindClaim(context, { + ...claim, + state: 'expired', + revision: claim.revision + 1, + }); + assertClaimTransition(claim, next); + return replaceClaim(context, fence, taskBundle, next); +} + +function prepareClaimRevalidate( + context: MutationContext, + claimId: Ulid, + expectedRevision: number, +): PreparedGitRefCoordinationMutation { + const { fence, taskBundle, metadata } = requireTaskContext(context); + const claim = requireClaim(context, claimId, expectedRevision); + assertClaimOwner(context, claim); + if ( + claim.state !== 'active' || + claim.ownershipEpochAtAcquire !== fence.ownershipEpoch || + claim.implementationScopeDigest !== metadata.implementationScope.digest || + Date.parse(claim.expiresAt) <= context.now.getTime() + ) { + throw new Error('MANCODE_CLAIM_REVALIDATION_REQUIRED'); + } + const validity = deriveClaimValidity( + claim, + claimValidation(fence, taskBundle, metadata, context.now), + ); + if (validity !== 'needs_revalidation' && validity !== 'code_ref_stale') { + throw new Error('MANCODE_CLAIM_REVALIDATION_NOT_REQUIRED'); + } + const next = bindClaim(context, { + ...claim, + revision: claim.revision + 1, + lastValidatedTaskRevision: taskBundle.taskRevision, + lastValidatedCodeRef: taskBundle.codeRef, + }); + assertClaimTransition(claim, next); + return replaceClaim(context, fence, taskBundle, next); +} + +function prepareClaimTransfer( + context: MutationContext, + claimId: Ulid, + expectedRevision: number, + toActorId: Ulid, + successorClaimId: Ulid, +): PreparedGitRefCoordinationMutation { + const { fence, taskBundle, metadata } = requireTaskContext(context); + assertUlid(toActorId, 'git-ref claim transfer actorId'); + assertUlid(successorClaimId, 'git-ref successor claimId'); + const claim = requireClaim(context, claimId, expectedRevision); + assertClaimOwner(context, claim); + if ( + claim.state !== 'active' || + deriveClaimValidity( + claim, + claimValidation(fence, taskBundle, metadata, context.now), + ) !== 'fresh' + ) { + throw new Error('MANCODE_CLAIM_REVALIDATION_REQUIRED'); + } + if (toActorId === context.input.actorId) { + throw new Error('MANCODE_CLAIM_TRANSFER_TARGET_INVALID'); + } + if (!metadata.participants.includes(toActorId)) { + throw new Error('MANCODE_PARTICIPANT_REQUIRED'); + } + assertJoinedActor(context, toActorId); + if ( + successorClaimId === claim.claimId || + context.claims.some((candidate) => candidate.claimId === successorClaimId) + ) { + throw new Error('MANCODE_CLAIM_ID_CONFLICT'); + } + const predecessor = bindClaim(context, { + ...claim, + state: 'transferred', + revision: claim.revision + 1, + successorClaimId, + }); + assertClaimTransition(claim, predecessor); + const successor = bindClaim(context, { + ...claim, + claimId: successorClaimId, + taskRevisionAtAcquire: taskBundle.taskRevision, + lastValidatedTaskRevision: taskBundle.taskRevision, + ownershipEpochAtAcquire: fence.ownershipEpoch, + ownerActorId: toActorId, + state: 'active', + revision: 1, + codeRefAtAcquire: taskBundle.codeRef, + lastValidatedCodeRef: taskBundle.codeRef, + predecessorClaimId: claim.claimId, + successorClaimId: null, + createdAt: context.timestamp, + }); + return prepared( + context, + advanceFence(context, fence), + [ + ...context.claims.filter((candidate) => candidate.claimId !== claimId), + predecessor, + successor, + ], + context.handoffs, + taskBundle, + ); +} + +function prepareHandoffDraft( + context: MutationContext, + proposal: HandoffV1, +): PreparedGitRefCoordinationMutation { + const { fence, taskBundle, metadata } = requireTaskContext(context); + assertRemoteTaskEligible(metadata); + assertCurrentTaskOwner(context); + const source = parseHandoff(proposal); + if ( + context.handoffs.some((handoff) => handoff.handoffId === source.handoffId) + ) { + throw new Error('MANCODE_HANDOFF_ID_CONFLICT'); + } + if ( + !sameTaskRef(source.taskRef, context.taskRef) || + source.fromActorId !== context.input.actorId || + source.state !== 'draft' || + source.revision !== 1 || + source.taskRevision !== fence.taskRevision || + source.ownershipEpochAtOffer !== fence.ownershipEpoch || + !metadata.participants.includes(source.toActorId) + ) { + throw new Error('MANCODE_REMOTE_HANDOFF_PROPOSAL_INVALID'); + } + assertJoinedActor(context, source.toActorId); + assertHandoffClaims(source, context.claims, fence, context.now); + assertHandoffBundleCurrent(source, taskBundle, false); + const handoff = bindHandoff(context, source, taskBundle); + return prepared( + context, + advanceFence(context, fence), + context.claims, + [...context.handoffs, handoff], + taskBundle, + ); +} + +function prepareHandoffTransition( + context: MutationContext, + kind: 'handoff_offer' | 'handoff_reject' | 'handoff_cancel', + handoffId: Ulid, + expectedRevision: number, + reason: string | undefined, +): PreparedGitRefCoordinationMutation { + const { fence, taskBundle, metadata } = requireTaskContext(context); + assertRemoteTaskEligible(metadata); + const handoff = requireHandoff(context, handoffId, expectedRevision); + assertHandoffBundleCurrent(handoff, taskBundle); + if (!metadata.participants.includes(handoff.toActorId)) { + throw new Error('MANCODE_HANDOFF_RECIPIENT_NOT_PARTICIPANT'); + } + let next: HandoffV1; + if (kind === 'handoff_offer') { + if ( + handoff.state !== 'draft' || + context.input.actorId !== handoff.fromActorId || + context.input.actorId !== fence.ownerActorId + ) { + throw new Error('MANCODE_HANDOFF_NOT_DRAFT'); + } + next = parseHandoff({ + ...handoff, + state: 'offered', + revision: handoff.revision + 1, + offeredAt: context.timestamp, + updatedAt: context.timestamp, + }); + } else if (kind === 'handoff_reject') { + if (handoff.state !== 'offered') { + throw new Error('MANCODE_HANDOFF_NOT_OFFERED'); + } + if (context.input.actorId !== handoff.toActorId) { + throw new Error('MANCODE_HANDOFF_ACTOR_MISMATCH'); + } + if (reason === undefined || !reason.trim()) { + throw new Error('MANCODE_HANDOFF_REJECTION_REASON_REQUIRED'); + } + next = terminalHandoff(context, handoff, 'rejected', reason); + } else { + if (handoff.state !== 'draft' && handoff.state !== 'offered') { + throw new Error('MANCODE_HANDOFF_NOT_CANCELLABLE'); + } + if ( + context.input.actorId !== handoff.fromActorId && + context.input.actorId !== fence.ownerActorId + ) { + throw new Error('MANCODE_HANDOFF_ACTOR_MISMATCH'); + } + next = terminalHandoff(context, handoff, 'cancelled', reason ?? null); + } + next = bindHandoff(context, next, taskBundle); + assertHandoffTransition(handoff, next, context.input.actorId); + return prepared( + context, + advanceFence(context, fence), + context.claims, + context.handoffs.map((candidate) => + candidate.handoffId === handoffId ? next : candidate, + ), + taskBundle, + ); +} + +function prepareHandoffAccept( + context: MutationContext, + input: Extract< + PrepareGitRefCoordinationMutationInput, + { kind: 'handoff_accept' } + >, +): PreparedGitRefCoordinationMutation { + const { fence, taskBundle: currentBundle } = requireTaskContext(context); + const handoff = requireHandoff( + context, + input.handoffId, + input.expectedHandoffRevision, + ); + if (handoff.state !== 'offered') { + throw new Error('MANCODE_HANDOFF_NOT_OFFERED'); + } + if (context.input.actorId !== handoff.toActorId) { + throw new Error('MANCODE_HANDOFF_ACTOR_MISMATCH'); + } + if ( + fence.ownerActorId !== handoff.fromActorId || + fence.ownershipEpoch !== handoff.ownershipEpochAtOffer + ) { + throw new Error('MANCODE_OWNERSHIP_EPOCH_STALE'); + } + assertHandoffBundleCurrent(handoff, currentBundle); + if (!input.codeReachable) throw new Error('MANCODE_TASK_UNAVAILABLE'); + + const nextMetadata = metadataFromBundle(input.taskBundle); + const currentMetadata = metadataFromBundle(currentBundle); + assertRemoteTaskEligible(currentMetadata); + assertRemoteTaskEligible(nextMetadata); + assertTaskBundleIdentity(input.taskBundle, context.taskRef); + if ( + nextMetadata.ownerActorId !== handoff.toActorId || + nextMetadata.ownershipEpoch !== fence.ownershipEpoch + 1 || + input.taskBundle.ownershipEpoch !== fence.ownershipEpoch + 1 || + input.taskBundle.taskRevision !== fence.taskRevision + 2 || + nextMetadata.lastOperationId !== context.input.operationId || + nextMetadata.transitionState !== 'stable' || + input.taskBundle.codeRef.branch !== currentBundle.codeRef.branch || + input.taskBundle.codeRef.head !== currentBundle.codeRef.head || + !handoffMetadataOnlyTransfersOwner(currentMetadata, nextMetadata) + ) { + throw new Error('MANCODE_OWNERSHIP_EPOCH_STALE'); + } + const predecessors = handoff.claimIds.map((claimId) => { + const claim = context.claims.find( + (candidate) => candidate.claimId === claimId, + ); + if ( + claim === undefined || + claim.state !== 'active' || + claim.ownerActorId !== handoff.fromActorId || + claim.ownershipEpochAtAcquire !== fence.ownershipEpoch || + Date.parse(claim.expiresAt) <= context.now.getTime() + ) { + throw new Error('MANCODE_HANDOFF_CLAIM_UNAVAILABLE'); + } + return claim; + }); + assertHandoffClaims(handoff, context.claims, fence, context.now); + if (input.successorClaimIds.length !== predecessors.length) { + throw new Error('MANCODE_HANDOFF_SUCCESSOR_CLAIM_COUNT_INVALID'); + } + const successorIds = new Set(); + for (const claimId of input.successorClaimIds) { + assertUlid(claimId, 'git-ref handoff successor claimId'); + if ( + successorIds.has(claimId) || + context.claims.some((claim) => claim.claimId === claimId) + ) { + throw new Error('MANCODE_HANDOFF_SUCCESSOR_CLAIM_INVALID'); + } + successorIds.add(claimId); + } + + const transferred = predecessors.map((claim, index) => { + const successorClaimId = input.successorClaimIds[index] as Ulid; + const next = bindClaim(context, { + ...claim, + state: 'transferred', + revision: claim.revision + 1, + successorClaimId, + }); + assertClaimTransition(claim, next); + return next; + }); + const successors = predecessors.map((claim, index) => + bindClaim(context, { + ...claim, + claimId: input.successorClaimIds[index] as Ulid, + taskRevisionAtAcquire: input.taskBundle.taskRevision, + lastValidatedTaskRevision: input.taskBundle.taskRevision, + implementationScopeDigest: nextMetadata.implementationScope.digest, + ownershipEpochAtAcquire: input.taskBundle.ownershipEpoch, + ownerActorId: handoff.toActorId, + state: 'active', + revision: 1, + codeRefAtAcquire: input.taskBundle.codeRef, + lastValidatedCodeRef: input.taskBundle.codeRef, + predecessorClaimId: claim.claimId, + successorClaimId: null, + createdAt: context.timestamp, + }), + ); + let accepted = terminalHandoff(context, handoff, 'accepted', null); + accepted = bindHandoff(context, accepted, input.taskBundle); + assertHandoffTransition(handoff, accepted, context.input.actorId); + const nextFence = buildFence(context, input.taskBundle, handoff.toActorId); + const predecessorIds = new Set(predecessors.map((claim) => claim.claimId)); + const claims = [ + ...context.claims.filter((claim) => !predecessorIds.has(claim.claimId)), + ...transferred, + ...successors, + ]; + return { + ...prepared( + context, + nextFence, + claims, + context.handoffs.map((candidate) => + candidate.handoffId === handoff.handoffId ? accepted : candidate, + ), + input.taskBundle, + ), + forwardRepair: { + taskRef: context.taskRef, + ownerActorId: handoff.toActorId, + ownershipEpoch: nextFence.ownershipEpoch, + taskRevision: nextFence.taskRevision, + aggregateDigest: nextFence.aggregateDigest, + operationId: context.input.operationId, + }, + }; +} + +function requireTaskContext(context: MutationContext): { + fence: GitRefOwnershipFenceV1; + taskBundle: GitRefTaskBundleV1; + metadata: WorkflowMetadataV3; +} { + if ( + context.fence === null || + context.taskBundle === null || + context.metadata === null + ) { + throw new Error('MANCODE_TASK_UNAVAILABLE'); + } + return { + fence: context.fence, + taskBundle: context.taskBundle, + metadata: context.metadata, + }; +} + +function prepared( + context: MutationContext, + ownershipFence: GitRefOwnershipFenceV1, + claims: ClaimV1[], + handoffs: HandoffV1[], + taskBundle: GitRefTaskBundleV1 | null, +): PreparedGitRefCoordinationMutation { + if ( + taskBundle !== null && + handoffs.some( + (handoff) => + handoff.state === 'offered' && + handoff.transport.taskBundleDigest !== taskBundle.bundleDigest, + ) + ) { + throw new Error('MANCODE_TRANSPORT_HANDOFF_BUNDLE_MISMATCH'); + } + return { + operationId: context.input.operationId, + actorId: context.input.actorId, + taskRef: context.taskRef, + expectedRemoteRevision: context.input.expectedRemoteRevision, + expectedOwnershipEpoch: context.input.expectedOwnershipEpoch, + ownershipFence, + claims: claims.map(parseClaim).sort(compareClaims), + handoffs: handoffs.map(parseHandoff).sort(compareHandoffs), + taskBundle, + forwardRepair: null, + }; +} + +function replaceClaim( + context: MutationContext, + fence: GitRefOwnershipFenceV1, + taskBundle: GitRefTaskBundleV1, + next: ClaimV1, +): PreparedGitRefCoordinationMutation { + return prepared( + context, + advanceFence(context, fence), + context.claims.map((claim) => + claim.claimId === next.claimId ? next : claim, + ), + context.handoffs, + taskBundle, + ); +} + +function requireClaim( + context: MutationContext, + claimId: Ulid, + expectedRevision: number, +): ClaimV1 { + assertUlid(claimId, 'git-ref claimId'); + assertPositiveInteger(expectedRevision, 'git-ref expectedClaimRevision'); + const claim = context.claims.find( + (candidate) => candidate.claimId === claimId, + ); + if (claim === undefined) throw new Error('MANCODE_CLAIM_NOT_FOUND'); + if (claim.revision !== expectedRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + return claim; +} + +function requireHandoff( + context: MutationContext, + handoffId: Ulid, + expectedRevision: number, +): HandoffV1 { + assertUlid(handoffId, 'git-ref handoffId'); + assertPositiveInteger(expectedRevision, 'git-ref expectedHandoffRevision'); + const handoff = context.handoffs.find( + (candidate) => candidate.handoffId === handoffId, + ); + if (handoff === undefined) throw new Error('MANCODE_HANDOFF_NOT_FOUND'); + if (handoff.revision !== expectedRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + return handoff; +} + +function bindClaim(context: MutationContext, value: ClaimV1): ClaimV1 { + return parseClaim({ + ...value, + authority: { + mode: 'git-ref', + remoteRevision: String(context.nextRemoteRevision), + }, + lastOperationId: context.input.operationId, + updatedAt: context.timestamp, + }); +} + +function bindHandoff( + context: MutationContext, + value: HandoffV1, + taskBundle: GitRefTaskBundleV1, +): HandoffV1 { + return parseHandoff({ + ...value, + transport: { + ...value.transport, + mode: 'git-ref', + state: 'published', + transportRevision: context.nextRemoteRevision, + publishedAt: context.timestamp, + fetchedAt: null, + taskBundleDigest: taskBundle.bundleDigest, + codeRef: taskBundle.codeRef, + codeReachable: true, + receipt: `git-ref-revision:${context.nextRemoteRevision}:${context.input.operationId}`, + }, + lastOperationId: context.input.operationId, + updatedAt: context.timestamp, + }); +} + +function advanceFence( + context: MutationContext, + fence: GitRefOwnershipFenceV1, +): GitRefOwnershipFenceV1 { + return { + ...fence, + remoteRevision: context.nextRemoteRevision, + lastOperationId: context.input.operationId, + updatedAt: context.timestamp, + }; +} + +function buildFence( + context: MutationContext, + taskBundle: GitRefTaskBundleV1, + ownerActorId: Ulid, +): GitRefOwnershipFenceV1 { + assertUlid(ownerActorId, 'git-ref ownership fence ownerActorId'); + return { + schemaVersion: 1, + taskRef: context.taskRef, + ownerActorId, + ownershipEpoch: taskBundle.ownershipEpoch, + taskRevision: taskBundle.taskRevision, + aggregateDigest: taskBundle.aggregateDigest, + remoteRevision: context.nextRemoteRevision, + lastOperationId: context.input.operationId, + updatedAt: context.timestamp, + }; +} + +function assertCurrentTaskOwner(context: MutationContext): void { + if ( + context.fence === null || + context.fence.ownerActorId !== context.input.actorId + ) { + throw new Error('MANCODE_TASK_OWNER_REQUIRED'); + } +} + +function assertOwnerOrParticipant( + context: MutationContext, + metadata: WorkflowMetadataV3, +): void { + if ( + context.fence?.ownerActorId !== context.input.actorId && + !metadata.participants.includes(context.input.actorId) + ) { + throw new Error('MANCODE_PARTICIPANT_REQUIRED'); + } +} + +function assertClaimOwner(context: MutationContext, claim: ClaimV1): void { + if (claim.ownerActorId !== context.input.actorId) { + throw new Error('MANCODE_CLAIM_OWNER_REQUIRED'); + } +} + +function assertJoinedActor(context: MutationContext, actorId: Ulid): void { + if ( + !context.manifest.actorProfiles.some( + (profile) => profile.actorId === actorId, + ) + ) { + throw new Error('MANCODE_PARTICIPANT_JOIN_REQUIRED'); + } +} + +function assertClaimSnapshotCurrent( + claim: ClaimV1, + fence: GitRefOwnershipFenceV1, + taskBundle: GitRefTaskBundleV1, + metadata: WorkflowMetadataV3, + now: Date, +): void { + if ( + claim.taskRevisionAtAcquire !== taskBundle.taskRevision || + claim.lastValidatedTaskRevision !== taskBundle.taskRevision || + claim.implementationScopeDigest !== metadata.implementationScope.digest || + claim.ownershipEpochAtAcquire !== fence.ownershipEpoch || + claim.codeRefAtAcquire.head !== taskBundle.codeRef.head || + claim.lastValidatedCodeRef.head !== taskBundle.codeRef.head || + Date.parse(claim.expiresAt) <= now.getTime() + ) { + throw new Error('MANCODE_CLAIM_REVALIDATION_REQUIRED'); + } +} + +function assertAllActiveClaimsFresh( + context: MutationContext, + fence: GitRefOwnershipFenceV1, + taskBundle: GitRefTaskBundleV1, + metadata: WorkflowMetadataV3, +): void { + if ( + context.claims.some( + (claim) => + claim.state === 'active' && + deriveClaimValidity( + claim, + claimValidation(fence, taskBundle, metadata, context.now), + ) !== 'fresh', + ) + ) { + throw new Error('MANCODE_CLAIM_REVALIDATION_REQUIRED'); + } +} + +function claimValidation( + fence: GitRefOwnershipFenceV1, + taskBundle: GitRefTaskBundleV1, + metadata: WorkflowMetadataV3, + now: Date, +) { + return { + taskRef: fence.taskRef, + taskRevision: taskBundle.taskRevision, + implementationScopeDigest: metadata.implementationScope.digest, + ownershipEpoch: fence.ownershipEpoch, + codeRefHead: taskBundle.codeRef.head, + now, + transportFreshness: 'fresh' as const, + }; +} + +function assertHandoffClaims( + handoff: HandoffV1, + claims: ClaimV1[], + fence: GitRefOwnershipFenceV1, + now: Date, +): void { + const owned = claims.filter( + (claim) => + claim.state === 'active' && + claim.ownerActorId === handoff.fromActorId && + sameTaskRef(claim.taskRef, handoff.taskRef), + ); + const expected = owned.map((claim) => claim.claimId).sort(compareUtf8); + const actual = [...handoff.claimIds].sort(compareUtf8); + if ( + expected.length !== actual.length || + expected.some((claimId, index) => claimId !== actual[index]) + ) { + throw new Error('MANCODE_HANDOFF_CLAIM_SET_INVALID'); + } + if ( + owned.some( + (claim) => + claim.ownershipEpochAtAcquire !== fence.ownershipEpoch || + Date.parse(claim.expiresAt) <= now.getTime(), + ) + ) { + throw new Error('MANCODE_HANDOFF_CLAIM_UNAVAILABLE'); + } +} + +function assertHandoffBundleCurrent( + handoff: HandoffV1, + taskBundle: GitRefTaskBundleV1, + requirePublished = true, +): void { + if ( + handoff.taskRevision !== taskBundle.taskRevision || + handoff.ownershipEpochAtOffer !== taskBundle.ownershipEpoch || + handoff.transport.taskBundleDigest !== taskBundle.bundleDigest || + handoff.transport.codeRef.head !== taskBundle.codeRef.head || + handoff.transport.codeReachable !== true || + (requirePublished && + (handoff.transport.mode !== 'git-ref' || + handoff.transport.state !== 'published')) + ) { + throw new Error('MANCODE_HANDOFF_TASK_UNAVAILABLE'); + } +} + +function terminalHandoff( + context: MutationContext, + handoff: HandoffV1, + state: 'accepted' | 'rejected' | 'cancelled', + reason: string | null, +): HandoffV1 { + return parseHandoff({ + ...handoff, + state, + revision: handoff.revision + 1, + resolution: { + state, + actorId: context.input.actorId, + at: context.timestamp, + reason, + }, + updatedAt: context.timestamp, + }); +} + +function metadataFromBundle( + taskBundle: GitRefTaskBundleV1, +): WorkflowMetadataV3 { + const artifacts = taskBundle.artifacts.filter( + (artifact) => artifact.kind === 'metadata', + ); + if (artifacts.length !== 1) throw new Error('MANCODE_TASK_UNAVAILABLE'); + const metadata = parseWorkflowMetadata(artifacts[0]?.content); + if ( + !sameTaskRef(metadata.taskRef, taskBundle.taskRef) || + metadata.revision !== taskBundle.taskRevision || + metadata.ownershipEpoch !== taskBundle.ownershipEpoch || + taskBundle.aggregate.metadataDigest !== workflowMetadataDigest(metadata) + ) { + throw new Error('MANCODE_TASK_BUNDLE_DIGEST_MISMATCH'); + } + if (metadata.transitionState !== 'stable') { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } + return metadata; +} + +function assertRemoteTaskEligible(metadata: WorkflowMetadataV3): void { + if ( + metadata.workflowMode !== 'manteam' || + metadata.coordination !== 'team' || + metadata.status !== 'in_progress' + ) { + throw new Error('MANCODE_REMOTE_COORDINATION_TASK_INVALID'); + } +} + +function handoffMetadataOnlyTransfersOwner( + previous: WorkflowMetadataV3, + next: WorkflowMetadataV3, +): boolean { + if ( + next.revision !== previous.revision + 2 || + !sameTaskRef(previous.taskRef, next.taskRef) || + digestCanonicalJson(previous.latestCheckpointRef) !== + digestCanonicalJson(next.latestCheckpointRef) + ) { + return false; + } + return ( + workflowMetadataDigest({ + ...next, + ownerActorId: previous.ownerActorId, + ownershipEpoch: previous.ownershipEpoch, + }) === workflowMetadataDigest(previous) + ); +} + +function assertFenceBundleConsistent( + fence: GitRefOwnershipFenceV1, + taskBundle: GitRefTaskBundleV1, + metadata: WorkflowMetadataV3, +): void { + if ( + !sameTaskRef(fence.taskRef, taskBundle.taskRef) || + fence.taskRevision !== taskBundle.taskRevision || + fence.ownershipEpoch !== taskBundle.ownershipEpoch || + fence.aggregateDigest !== taskBundle.aggregateDigest || + fence.ownerActorId !== metadata.ownerActorId + ) { + throw new Error('MANCODE_REMOTE_OWNERSHIP_DIVERGED'); + } +} + +function assertTaskBundleIdentity( + taskBundle: GitRefTaskBundleV1, + taskRef: TaskRef, +): void { + if ( + !sameTaskRef(taskBundle.taskRef, taskRef) || + taskBundle.aggregate.taskRevision !== taskBundle.taskRevision || + taskBundle.aggregate.ownershipEpoch !== taskBundle.ownershipEpoch || + taskBundle.aggregateDigest !== digestCanonicalJson(taskBundle.aggregate) + ) { + throw new Error('MANCODE_TASK_BUNDLE_DIGEST_MISMATCH'); + } +} + +function materializeHandoff(raw: HandoffV1, fetchedAt: string): HandoffV1 { + const handoff = parseHandoff(raw); + if (handoff.transport.mode !== 'git-ref') { + throw new Error('MANCODE_COORDINATION_DOMAIN_MISMATCH'); + } + return parseHandoff({ + ...handoff, + transport: { + ...handoff.transport, + state: 'fetched', + fetchedAt, + }, + }); +} + +function assertTimestamp(value: string, label: string): void { + if (!value || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} is invalid`); + } +} + +function assertNonNegativeInteger(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } +} + +function assertPositiveInteger(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } +} + +function compareFences( + left: GitRefOwnershipFenceV1, + right: GitRefOwnershipFenceV1, +): number { + return compareUtf8(formatTaskRef(left.taskRef), formatTaskRef(right.taskRef)); +} + +function compareClaims(left: ClaimV1, right: ClaimV1): number { + return compareUtf8(left.claimId, right.claimId); +} + +function compareHandoffs(left: HandoffV1, right: HandoffV1): number { + return compareUtf8(left.handoffId, right.handoffId); +} + +function compareTaskBundles( + left: GitRefTaskBundleV1, + right: GitRefTaskBundleV1, +): number { + return compareUtf8(formatTaskRef(left.taskRef), formatTaskRef(right.taskRef)); +} + +function compareReceipts( + left: GitRefRemoteMutationReceiptV1, + right: GitRefRemoteMutationReceiptV1, +): number { + return left.remoteRevision - right.remoteRevision; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} diff --git a/src/team/git-ref-handoff-repair.ts b/src/team/git-ref-handoff-repair.ts new file mode 100644 index 0000000..442463a --- /dev/null +++ b/src/team/git-ref-handoff-repair.ts @@ -0,0 +1,627 @@ +import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { V3ContextStore } from '../context/store.js'; +import { taskRootPath } from '../context/task-locator.js'; +import { sameTaskRef } from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { + type WorkflowMetadataV3, + parseWorkflowMetadata, +} from '../context/workflow-metadata.js'; +import { replaceFileAtomically } from '../runtime/atomic-file.js'; +import { resolveCoordinationEntityHomeStore } from '../runtime/entity-home-store.js'; +import { acquireEntityLocks } from '../runtime/local-lock.js'; +import { readProjectRuntimeContext } from '../runtime/project-runtime.js'; +import { taskEntityKey } from '../runtime/task-operation.js'; +import { createGitRefTeamManifestStore } from './git-ref-client.js'; +import { materializeGitRefTaskBundle } from './git-ref-materialization.js'; +import { + type AcceptGitRefHandoffInput, + type AcceptedGitRefHandoff, + type PreparedGitRefHandoffAcceptV1, + acceptGitRefHandoff, +} from './git-ref-operation.js'; +import { + type GitRefOwnershipFenceV1, + type GitRefRemoteMutationReceiptV1, + type GitRefTaskBundleV1, + parseGitRefRemoteMutationReceipt, + parseGitRefTaskBundle, +} from './git-ref-transport.js'; + +type RepairState = + | 'awaiting_remote' + | 'applying' + | 'committed' + | 'aborted' + | 'repair_required'; + +interface GitRefHandoffRepairJournalV1 { + schemaVersion: 1; + operationId: Ulid; + workspaceId: Ulid; + state: RepairState; + prepared: PreparedGitRefHandoffAcceptV1; + pendingMetadata: WorkflowMetadataV3; + remoteReceipt: GitRefRemoteMutationReceiptV1 | null; + transportReceipt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface RecoveredGitRefHandoffRepair { + operationId: Ulid; + state: Extract; + taskRef: GitRefTaskBundleV1['taskRef']; + remoteRevision: number; +} + +/** Runs remote accept with an exact local write-ahead and forward repair. */ +export async function acceptGitRefHandoffWithRepair( + input: AcceptGitRefHandoffInput, +): Promise { + let preparedOperationId: Ulid | null = null; + try { + const accepted = await acceptGitRefHandoff({ + ...input, + beforeRemoteCommit: async (prepared) => { + await prepareHandoffRepairWhileTaskLocked(input.projectRoot, prepared); + preparedOperationId = prepared.operationId; + await input.beforeRemoteCommit?.(prepared); + }, + }); + await recoverGitRefHandoffRepair( + input.projectRoot, + accepted.operationId, + accepted.receipt, + ); + return accepted; + } catch (error) { + if (preparedOperationId !== null) { + try { + await recoverGitRefHandoffRepair( + input.projectRoot, + preparedOperationId, + null, + ); + } catch { + // The durable journal remains repairable; preserve the remote error. + } + } + throw error; + } +} + +/** Recovers every handoff external-commit journal in deterministic order. */ +export async function recoverGitRefHandoffRepairs( + projectRoot: string, +): Promise { + const results: RecoveredGitRefHandoffRepair[] = []; + for (const journal of await listJournals(projectRoot)) { + if (journal.state === 'committed' || journal.state === 'aborted') continue; + results.push( + await recoverGitRefHandoffRepair( + projectRoot, + journal.operationId, + journal.transportReceipt, + ), + ); + } + return results; +} + +export async function recoverGitRefHandoffRepair( + projectRoot: string, + operationId: Ulid, + transportReceipt: string | null, +): Promise { + assertUlid(operationId, 'git-ref handoff repair operationId'); + const root = path.resolve(projectRoot); + let journal = await requireJournal(root, operationId); + if (journal.state === 'committed' || journal.state === 'aborted') { + return recoveryResult(journal); + } + const runtime = await readProjectRuntimeContext(root); + const store = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const recoveryOperationId = createUlid(); + const locks = await acquireEntityLocks(store, recoveryOperationId, [ + taskEntityKey(journal.prepared.targetBundle.taskRef), + ]); + try { + journal = await requireJournal(root, operationId); + const project = await new V3ContextStore(root).readProjectSnapshot(); + const transport = createGitRefTeamManifestStore( + root, + project.config, + project.manifest, + ); + const snapshot = await transport.pull(); + const manifest = snapshot.manifest; + const receipt = manifest?.receipts.find( + (candidate) => candidate.operationId === operationId, + ); + if (receipt === undefined) { + if ( + remoteReceiptAbsenceIsProven( + journal, + manifest?.receipts ?? [], + manifest?.revision ?? 0, + ) + ) { + await restorePredecessorMetadata(root, journal); + journal = await transitionJournal(root, journal, 'aborted', null, null); + return recoveryResult(journal); + } + journal = await transitionJournal( + root, + journal, + 'repair_required', + null, + transportReceipt, + ); + return recoveryResult(journal); + } + assertRemoteCommitMatches(journal, receipt); + journal = await transitionJournal( + root, + journal, + 'applying', + receipt, + transportReceipt ?? snapshot.receipt, + ); + const ownershipFence = ownershipFenceFromJournal(journal); + await materializeGitRefTaskBundle({ + projectRoot: root, + remoteRevision: journal.prepared.targetRemoteRevision, + ownershipFence, + bundle: journal.prepared.targetBundle, + predecessorBundle: journal.prepared.predecessorBundle, + pendingMetadata: journal.pendingMetadata, + operationId: createUlid(), + }); + journal = await transitionJournal( + root, + journal, + 'committed', + journal.remoteReceipt, + journal.transportReceipt, + ); + return recoveryResult(journal); + } finally { + await Promise.allSettled( + [...locks].reverse().map((lock) => lock.release()), + ); + } +} + +async function prepareHandoffRepairWhileTaskLocked( + projectRoot: string, + rawPrepared: PreparedGitRefHandoffAcceptV1, +): Promise { + const root = path.resolve(projectRoot); + const prepared = parsePrepared(rawPrepared); + const runtime = await readProjectRuntimeContext(root); + const previousMetadata = bundleMetadata(prepared.predecessorBundle); + const targetMetadata = bundleMetadata(prepared.targetBundle); + const pendingMetadata = parseWorkflowMetadata({ + ...previousMetadata, + revision: previousMetadata.revision + 1, + transitionState: 'operation_pending', + lastOperationId: prepared.operationId, + updatedAt: targetMetadata.updatedAt, + }); + const current = await new V3ContextStore(root).readTaskSnapshot( + prepared.predecessorBundle.taskRef, + ); + if ( + current.aggregate === null || + current.metadata.transitionState !== 'stable' || + digestCanonicalJson(current.metadata) !== + digestCanonicalJson(previousMetadata) + ) { + throw new Error('MANCODE_SPLIT_BRAIN'); + } + const timestamp = new Date().toISOString(); + const journal: GitRefHandoffRepairJournalV1 = { + schemaVersion: 1, + operationId: prepared.operationId, + workspaceId: runtime.workspaceId, + state: 'awaiting_remote', + prepared, + pendingMetadata, + remoteReceipt: null, + transportReceipt: null, + createdAt: timestamp, + updatedAt: timestamp, + }; + await createJournal(root, journal); + await replaceMetadataVerified( + root, + previousMetadata, + pendingMetadata, + pendingMetadata, + ); +} + +function assertRemoteCommitMatches( + journal: GitRefHandoffRepairJournalV1, + receipt: GitRefRemoteMutationReceiptV1, +): void { + const prepared = journal.prepared; + if ( + receipt.kind !== 'coordination' || + receipt.remoteRevision !== prepared.targetRemoteRevision || + receipt.ownershipEpoch !== prepared.targetOwnershipEpoch || + receipt.taskRef === null || + !sameTaskRef(receipt.taskRef, prepared.targetBundle.taskRef) || + receipt.entityDigests.taskBundle !== + digestCanonicalJson(prepared.targetBundle) + ) { + throw new Error('MANCODE_REMOTE_RECEIPT_MISMATCH'); + } +} + +function remoteReceiptAbsenceIsProven( + journal: GitRefHandoffRepairJournalV1, + receipts: GitRefRemoteMutationReceiptV1[], + remoteRevision: number, +): boolean { + if (remoteRevision <= journal.prepared.expectedRemoteRevision) return true; + if (receipts.length === 0) return false; + const minimumRetainedRevision = Math.min( + ...receipts.map((receipt) => receipt.remoteRevision), + ); + return journal.prepared.targetRemoteRevision >= minimumRetainedRevision; +} + +async function restorePredecessorMetadata( + projectRoot: string, + journal: GitRefHandoffRepairJournalV1, +): Promise { + const predecessor = bundleMetadata(journal.prepared.predecessorBundle); + await replaceMetadataVerified( + projectRoot, + journal.pendingMetadata, + predecessor, + predecessor, + ); +} + +async function replaceMetadataVerified( + projectRoot: string, + expected: WorkflowMetadataV3, + targetMetadata: WorkflowMetadataV3, + alternateExpected: WorkflowMetadataV3, +): Promise { + const target = path.join( + taskRootPath(projectRoot, targetMetadata.taskRef), + 'metadata.json', + ); + const current = parseWorkflowMetadata( + JSON.parse(await readSafeFile(target)) as unknown, + ); + if (digestCanonicalJson(current) === digestCanonicalJson(targetMetadata)) { + return; + } + if ( + digestCanonicalJson(current) !== digestCanonicalJson(expected) && + digestCanonicalJson(current) !== digestCanonicalJson(alternateExpected) + ) { + throw new Error('MANCODE_SPLIT_BRAIN'); + } + await atomicWrite(target, `${JSON.stringify(targetMetadata, null, 2)}\n`); +} + +function ownershipFenceFromJournal( + journal: GitRefHandoffRepairJournalV1, +): GitRefOwnershipFenceV1 { + const repair = journal.prepared.forwardRepair; + const targetMetadata = bundleMetadata(journal.prepared.targetBundle); + return { + schemaVersion: 1, + taskRef: repair.taskRef, + ownerActorId: repair.ownerActorId, + ownershipEpoch: repair.ownershipEpoch, + taskRevision: repair.taskRevision, + aggregateDigest: repair.aggregateDigest, + remoteRevision: repair.remoteRevision, + lastOperationId: repair.operationId, + updatedAt: targetMetadata.updatedAt, + }; +} + +function parsePrepared( + value: PreparedGitRefHandoffAcceptV1, +): PreparedGitRefHandoffAcceptV1 { + assertUlid(value.operationId, 'git-ref handoff prepared operationId'); + const predecessorBundle = parseGitRefTaskBundle(value.predecessorBundle); + const targetBundle = parseGitRefTaskBundle(value.targetBundle); + if ( + !sameTaskRef(predecessorBundle.taskRef, targetBundle.taskRef) || + value.targetRemoteRevision !== value.expectedRemoteRevision + 1 || + value.targetOwnershipEpoch !== value.expectedOwnershipEpoch + 1 || + value.forwardRepair.operationId !== value.operationId || + value.forwardRepair.remoteRevision !== value.targetRemoteRevision || + value.forwardRepair.bundleDigest !== targetBundle.bundleDigest || + value.forwardRepair.aggregateDigest !== targetBundle.aggregateDigest || + value.forwardRepair.taskRevision !== targetBundle.taskRevision || + value.forwardRepair.ownershipEpoch !== targetBundle.ownershipEpoch + ) { + throw new Error('MANCODE_REMOTE_FORWARD_REPAIR_TARGET_INVALID'); + } + return { ...value, predecessorBundle, targetBundle }; +} + +function bundleMetadata(bundle: GitRefTaskBundleV1): WorkflowMetadataV3 { + const artifact = bundle.artifacts.find( + (candidate) => candidate.kind === 'metadata', + ); + if (artifact === undefined) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_ARTIFACT_INVALID'); + } + return parseWorkflowMetadata(artifact.content); +} + +function recoveryResult( + journal: GitRefHandoffRepairJournalV1, +): RecoveredGitRefHandoffRepair { + const state = journal.state; + if ( + state !== 'committed' && + state !== 'aborted' && + state !== 'repair_required' + ) { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } + return { + operationId: journal.operationId, + state, + taskRef: journal.prepared.targetBundle.taskRef, + remoteRevision: journal.prepared.targetRemoteRevision, + }; +} + +async function transitionJournal( + projectRoot: string, + journal: GitRefHandoffRepairJournalV1, + state: RepairState, + remoteReceipt: GitRefRemoteMutationReceiptV1 | null, + transportReceipt: string | null, +): Promise { + const next = parseJournal({ + ...journal, + state, + remoteReceipt, + transportReceipt, + updatedAt: new Date().toISOString(), + }); + await replaceJournal(projectRoot, next); + return next; +} + +function parseJournal(value: unknown): GitRefHandoffRepairJournalV1 { + assertRecord(value, 'git-ref handoff repair journal'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'workspaceId', + 'state', + 'prepared', + 'pendingMetadata', + 'remoteReceipt', + 'transportReceipt', + 'createdAt', + 'updatedAt', + ], + 'git-ref handoff repair journal', + ); + if (value.schemaVersion !== 1) { + throw new Error('MANCODE_HANDOFF_REPAIR_JOURNAL_CORRUPT'); + } + assertUlid(value.operationId, 'git-ref handoff repair operationId'); + assertUlid(value.workspaceId, 'git-ref handoff repair workspaceId'); + if ( + value.state !== 'awaiting_remote' && + value.state !== 'applying' && + value.state !== 'committed' && + value.state !== 'aborted' && + value.state !== 'repair_required' + ) { + throw new Error('MANCODE_HANDOFF_REPAIR_JOURNAL_CORRUPT'); + } + const prepared = parsePrepared( + value.prepared as unknown as PreparedGitRefHandoffAcceptV1, + ); + if (prepared.operationId !== value.operationId) { + throw new Error('MANCODE_HANDOFF_REPAIR_JOURNAL_CORRUPT'); + } + const pendingMetadata = parseWorkflowMetadata(value.pendingMetadata); + const remoteReceipt = + value.remoteReceipt === null + ? null + : parseGitRefRemoteMutationReceipt(value.remoteReceipt); + if ( + value.transportReceipt !== null && + (typeof value.transportReceipt !== 'string' || + !value.transportReceipt.trim() || + value.transportReceipt.includes('\0')) + ) { + throw new Error('MANCODE_HANDOFF_REPAIR_JOURNAL_CORRUPT'); + } + return { + schemaVersion: 1, + operationId: value.operationId, + workspaceId: value.workspaceId, + state: value.state, + prepared, + pendingMetadata, + remoteReceipt, + transportReceipt: value.transportReceipt as string | null, + createdAt: parseTimestamp(value.createdAt), + updatedAt: parseTimestamp(value.updatedAt), + }; +} + +async function createJournal( + projectRoot: string, + journal: GitRefHandoffRepairJournalV1, +): Promise { + await ensureJournalDirectory(projectRoot); + const target = journalPath(projectRoot, journal.operationId); + try { + await writeFile(target, serialize(journal), { + encoding: 'utf8', + flag: 'wx', + }); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await requireJournal(projectRoot, journal.operationId); + if (digestCanonicalJson(existing) !== digestCanonicalJson(journal)) { + throw new Error('MANCODE_HANDOFF_REPAIR_JOURNAL_CONFLICT'); + } + } +} + +async function replaceJournal( + projectRoot: string, + journal: GitRefHandoffRepairJournalV1, +): Promise { + const target = journalPath(projectRoot, journal.operationId); + await atomicWrite(target, serialize(journal)); +} + +async function requireJournal( + projectRoot: string, + operationId: Ulid, +): Promise { + try { + return parseJournal( + JSON.parse(await readFile(journalPath(projectRoot, operationId), 'utf8')), + ); + } catch (error) { + if (error instanceof SyntaxError || isNotFound(error)) { + throw new Error('MANCODE_HANDOFF_REPAIR_JOURNAL_NOT_FOUND'); + } + throw error; + } +} + +async function listJournals( + projectRoot: string, +): Promise { + let entries: string[]; + try { + entries = await readdir(journalDirectory(projectRoot)); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const journals: GitRefHandoffRepairJournalV1[] = []; + for (const entry of entries.sort()) { + if (!entry.endsWith('.json')) continue; + journals.push( + parseJournal( + JSON.parse( + await readFile( + path.join(journalDirectory(projectRoot), entry), + 'utf8', + ), + ), + ), + ); + } + return journals; +} + +async function readSafeFile(target: string): Promise { + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const content = await readFile(target, 'utf8'); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return content; +} + +async function atomicWrite(target: string, content: string): Promise { + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' }); + await replaceFileAtomically(temporary, target); +} + +async function ensureJournalDirectory(projectRoot: string): Promise { + let current = path.resolve(projectRoot); + for (const segment of ['.mancode', 'local', 'journals', 'git-ref-handoff']) { + current = path.join(current, segment); + try { + await mkdir(current); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + const entry = await lstat(current); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } +} + +function journalDirectory(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'journals', + 'git-ref-handoff', + ); +} + +function journalPath(projectRoot: string, operationId: Ulid): string { + assertUlid(operationId, 'git-ref handoff repair operationId'); + return path.join(journalDirectory(projectRoot), `${operationId}.json`); +} + +function serialize(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function parseTimestamp(value: unknown): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error('MANCODE_HANDOFF_REPAIR_JOURNAL_CORRUPT'); + } + return value; +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/team/git-ref-materialization.ts b/src/team/git-ref-materialization.ts new file mode 100644 index 0000000..619c998 --- /dev/null +++ b/src/team/git-ref-materialization.ts @@ -0,0 +1,838 @@ +import { + lstat, + mkdir, + readFile, + readdir, + unlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { taskAggregateDigest } from '../context/aggregate.js'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { V3ContextStore } from '../context/store.js'; +import { taskRootPath } from '../context/task-locator.js'; +import { sameTaskRef } from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { + type WorkflowMetadataV3, + parseWorkflowMetadata, +} from '../context/workflow-metadata.js'; +import { replaceFileAtomically } from '../runtime/atomic-file.js'; +import { resolveCoordinationEntityHomeStore } from '../runtime/entity-home-store.js'; +import { acquireEntityLocks } from '../runtime/local-lock.js'; +import { readProjectRuntimeContext } from '../runtime/project-runtime.js'; +import { + type TaskHeadFenceV1, + parseTaskHeadFence, +} from '../runtime/task-head-fence.js'; +import { + createTaskHeadFence, + readTaskHeadFence, + replaceTaskHeadFence, +} from '../runtime/task-head-store.js'; +import { + type GitRefOwnershipFenceV1, + type GitRefTaskBundleArtifactV1, + type GitRefTaskBundleV1, + parseGitRefOwnershipFence, + parseGitRefTaskBundle, +} from './git-ref-transport.js'; + +type MaterializationState = 'prepared' | 'applying' | 'committed'; + +interface GitRefMaterializationJournalV1 { + schemaVersion: 1; + operationId: Ulid; + workspaceId: Ulid; + remoteRevision: number; + state: MaterializationState; + predecessorBundle: GitRefTaskBundleV1 | null; + pendingMetadata: WorkflowMetadataV3 | null; + targetBundle: GitRefTaskBundleV1; + targetFence: TaskHeadFenceV1; + createdAt: string; + updatedAt: string; +} + +export interface MaterializeGitRefTaskBundleInput { + projectRoot: string; + remoteRevision: number; + ownershipFence: GitRefOwnershipFenceV1; + bundle: GitRefTaskBundleV1; + predecessorBundle?: GitRefTaskBundleV1 | null; + pendingMetadata?: WorkflowMetadataV3 | null; + operationId?: Ulid; + now?: Date; +} + +export interface MaterializedGitRefTaskBundleResult { + status: 'created' | 'updated' | 'unchanged'; + taskRevision: number; + aggregateDigest: string; + journalPath: string | null; + taskHeadFence: TaskHeadFenceV1; +} + +/** + * Materializes only a missing task or an exact verified predecessor. The + * local journal makes every visible write idempotently forward-repairable. + */ +export async function materializeGitRefTaskBundle( + input: MaterializeGitRefTaskBundleInput, +): Promise { + const projectRoot = path.resolve(input.projectRoot); + const remoteRevision = positiveInteger( + input.remoteRevision, + 'git-ref materialization remoteRevision', + ); + const bundle = parseGitRefTaskBundle(input.bundle); + const ownershipFence = parseGitRefOwnershipFence(input.ownershipFence); + if ( + !sameTaskRef(bundle.taskRef, ownershipFence.taskRef) || + bundle.taskRevision !== ownershipFence.taskRevision || + bundle.ownershipEpoch !== ownershipFence.ownershipEpoch || + bundle.aggregateDigest !== ownershipFence.aggregateDigest || + ownershipFence.remoteRevision > remoteRevision + ) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_FENCE_MISMATCH'); + } + const predecessor = + input.predecessorBundle === undefined || input.predecessorBundle === null + ? null + : parseGitRefTaskBundle(input.predecessorBundle); + const pendingMetadata = + input.pendingMetadata === undefined || input.pendingMetadata === null + ? null + : parseWorkflowMetadata(input.pendingMetadata); + assertPendingMetadata(pendingMetadata, predecessor, bundle); + const runtime = await readProjectRuntimeContext(projectRoot); + const operationId = + input.operationId ?? createUlid((input.now ?? new Date()).getTime()); + assertUlid(operationId, 'git-ref materialization operationId'); + const now = input.now ?? new Date(); + const store = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const locks = await acquireEntityLocks( + store, + operationId, + [`remote_task:${bundle.taskRef.taskId}`], + { now }, + ); + try { + await recoverTaskMaterializationsWhileLocked( + projectRoot, + bundle.taskRef.taskId, + ); + const current = await readLocalTaskOrNull(projectRoot, bundle); + const currentFence = await readTaskHeadFence(store, bundle.taskRef); + const status = classifyMaterialization( + current, + bundle, + predecessor, + pendingMetadata, + ); + if (status === 'created' && currentFence !== null) { + throw new Error('MANCODE_SPLIT_BRAIN'); + } + const materializedPredecessor = status === 'created' ? null : predecessor; + const targetFence = buildTargetFence({ + runtime, + remoteRevision, + ownershipFence, + bundle, + predecessor: materializedPredecessor, + currentFence, + now, + }); + if (status === 'unchanged') { + await writeTargetFence(store, currentFence, targetFence, predecessor); + return result(status, bundle, null, targetFence); + } + const timestamp = now.toISOString(); + let journal: GitRefMaterializationJournalV1 = { + schemaVersion: 1, + operationId, + workspaceId: runtime.workspaceId, + remoteRevision, + state: 'prepared', + predecessorBundle: materializedPredecessor, + pendingMetadata, + targetBundle: bundle, + targetFence, + createdAt: timestamp, + updatedAt: timestamp, + }; + await createJournal(projectRoot, journal); + journal = { ...journal, state: 'applying', updatedAt: timestamp }; + await replaceJournal(projectRoot, journal); + await applyJournal(projectRoot, journal); + journal = { + ...journal, + state: 'committed', + updatedAt: new Date().toISOString(), + }; + await replaceJournal(projectRoot, journal); + return result( + status, + bundle, + journalPath(projectRoot, operationId), + targetFence, + ); + } finally { + await Promise.allSettled( + [...locks].reverse().map((lock) => lock.release()), + ); + } +} + +/** Repairs durable pull intents under each task's canonical local lock. */ +export async function recoverGitRefTaskMaterializations( + projectRoot: string, +): Promise { + const root = path.resolve(projectRoot); + const runtime = await readProjectRuntimeContext(root); + const store = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const journals = await listJournals(root); + let repaired = 0; + for (const journal of journals) { + if (journal.state === 'committed') continue; + const recoveryOperationId = createUlid(); + const locks = await acquireEntityLocks(store, recoveryOperationId, [ + `remote_task:${journal.targetBundle.taskRef.taskId}`, + ]); + try { + repaired += await recoverTaskMaterializationsWhileLocked( + root, + journal.targetBundle.taskRef.taskId, + ); + } finally { + await Promise.allSettled( + [...locks].reverse().map((lock) => lock.release()), + ); + } + } + return repaired; +} + +async function recoverTaskMaterializationsWhileLocked( + projectRoot: string, + taskId: string, +): Promise { + let repaired = 0; + for (const journal of await listJournals(projectRoot)) { + if ( + journal.state === 'committed' || + journal.targetBundle.taskRef.taskId !== taskId + ) { + continue; + } + await applyJournal(projectRoot, journal); + await replaceJournal(projectRoot, { + ...journal, + state: 'committed', + updatedAt: new Date().toISOString(), + }); + repaired += 1; + } + return repaired; +} + +async function applyJournal( + projectRoot: string, + journal: GitRefMaterializationJournalV1, +): Promise { + const target = bundleFiles(journal.targetBundle); + const predecessor = + journal.predecessorBundle === null + ? new Map() + : bundleFiles(journal.predecessorBundle); + const pendingMetadataContent = + journal.pendingMetadata === null + ? null + : `${JSON.stringify(journal.pendingMetadata, null, 2)}\n`; + const taskRoot = taskRootPath(projectRoot, journal.targetBundle.taskRef); + await ensureSafeDirectory(projectRoot, [ + '.mancode', + 'shared', + 'workflows', + journal.targetBundle.taskRef.taskId, + ]); + for (const [relativePath, content] of target) { + await replaceVerifiedFile( + taskRoot, + relativePath, + content, + predecessor.get(relativePath) ?? null, + relativePath === 'metadata.json' ? pendingMetadataContent : null, + ); + } + for (const optional of ['plan.md', 'summary.md']) { + if (!target.has(optional)) { + await removeVerifiedFile( + taskRoot, + optional, + predecessor.get(optional) ?? null, + ); + } + } + const runtime = await readProjectRuntimeContext(projectRoot); + if (runtime.workspaceId !== journal.workspaceId) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + const store = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const currentFence = await readTaskHeadFence( + store, + journal.targetBundle.taskRef, + ); + await writeTargetFence( + store, + currentFence, + journal.targetFence, + journal.predecessorBundle, + ); + const materialized = await new V3ContextStore(projectRoot).readTaskSnapshot( + journal.targetBundle.taskRef, + ); + if ( + materialized.aggregate === null || + taskAggregateDigest(materialized.aggregate) !== + journal.targetBundle.aggregateDigest + ) { + throw new Error('MANCODE_SPLIT_BRAIN'); + } +} + +function classifyMaterialization( + current: Awaited>, + target: GitRefTaskBundleV1, + predecessor: GitRefTaskBundleV1 | null, + pendingMetadata: WorkflowMetadataV3 | null, +): 'created' | 'updated' | 'unchanged' { + if (current === null) return 'created'; + if ( + current.aggregate !== null && + current.metadata.revision === target.taskRevision && + taskAggregateDigest(current.aggregate) === target.aggregateDigest + ) { + return 'unchanged'; + } + const matchesPredecessor = + predecessor !== null && + current.aggregate !== null && + current.metadata.revision === predecessor.taskRevision && + taskAggregateDigest(current.aggregate) === predecessor.aggregateDigest; + const matchesPending = + pendingMetadata !== null && + digestCanonicalJson(current.metadata) === + digestCanonicalJson(pendingMetadata); + if ( + predecessor === null || + !sameTaskRef(predecessor.taskRef, target.taskRef) || + predecessor.taskRevision >= target.taskRevision || + (!matchesPredecessor && !matchesPending) + ) { + throw new Error('MANCODE_SPLIT_BRAIN'); + } + return 'updated'; +} + +async function readLocalTaskOrNull( + projectRoot: string, + bundle: GitRefTaskBundleV1, +) { + try { + return await new V3ContextStore(projectRoot).readTaskSnapshot( + bundle.taskRef, + ); + } catch (error) { + if (error instanceof Error && error.message === 'MANCODE_TASK_NOT_FOUND') { + return null; + } + const root = taskRootPath(projectRoot, bundle.taskRef); + if (!(await pathExists(root))) return null; + throw new Error('MANCODE_SPLIT_BRAIN'); + } +} + +function buildTargetFence(input: { + runtime: Awaited>; + remoteRevision: number; + ownershipFence: GitRefOwnershipFenceV1; + bundle: GitRefTaskBundleV1; + predecessor: GitRefTaskBundleV1 | null; + currentFence: TaskHeadFenceV1 | null; + now: Date; +}): TaskHeadFenceV1 { + if (input.currentFence !== null) { + const matchesTarget = + input.currentFence.taskRevision === input.bundle.taskRevision && + input.currentFence.aggregateDigest === input.bundle.aggregateDigest; + const matchesPredecessor = + input.predecessor !== null && + input.currentFence.taskRevision === input.predecessor.taskRevision && + input.currentFence.aggregateDigest === input.predecessor.aggregateDigest; + if (!matchesTarget && !matchesPredecessor) { + throw new Error('MANCODE_SPLIT_BRAIN'); + } + } + return parseTaskHeadFence({ + schemaVersion: 1, + workspaceId: input.runtime.workspaceId, + taskRef: input.bundle.taskRef, + fenceRevision: (input.currentFence?.fenceRevision ?? 0) + 1, + taskRevision: input.bundle.taskRevision, + aggregateDigest: input.bundle.aggregateDigest, + ownershipEpoch: input.bundle.ownershipEpoch, + codeRef: { head: input.bundle.codeRef.head }, + checkoutId: input.runtime.checkoutId, + remoteRevision: input.remoteRevision, + lastOperationId: input.ownershipFence.lastOperationId, + updatedAt: input.now.toISOString(), + }); +} + +async function writeTargetFence( + store: ReturnType, + current: TaskHeadFenceV1 | null, + target: TaskHeadFenceV1, + predecessor: GitRefTaskBundleV1 | null, +): Promise { + if ( + current !== null && + digestCanonicalJson(current) === digestCanonicalJson(target) + ) { + return; + } + if (current === null) { + await createTaskHeadFence(store, target); + return; + } + const matchesTarget = + current.taskRevision === target.taskRevision && + current.aggregateDigest === target.aggregateDigest; + const matchesPredecessor = + predecessor !== null && + current.taskRevision === predecessor.taskRevision && + current.aggregateDigest === predecessor.aggregateDigest; + if (!matchesTarget && !matchesPredecessor) { + throw new Error('MANCODE_SPLIT_BRAIN'); + } + await replaceTaskHeadFence(store, target); +} + +function bundleFiles(bundle: GitRefTaskBundleV1): Map { + const files = new Map(); + for (const artifact of bundle.artifacts) { + files.set(materializedPath(artifact), serializeArtifact(artifact)); + } + return files; +} + +function materializedPath(artifact: GitRefTaskBundleArtifactV1): string { + switch (artifact.kind) { + case 'metadata': + return 'metadata.json'; + case 'requirements': + return 'requirements.json'; + case 'review': + return 'review-ledger.json'; + case 'verification': + return 'verification-ledger.json'; + case 'plan': + return 'plan.md'; + case 'summary': + return 'summary.md'; + case 'checkpoint': + return artifact.relativePath; + } +} + +function serializeArtifact(artifact: GitRefTaskBundleArtifactV1): string { + if (artifact.kind === 'plan' || artifact.kind === 'summary') { + if (typeof artifact.content !== 'string') { + throw new Error('MANCODE_TRANSPORT_BUNDLE_ARTIFACT_INVALID'); + } + return artifact.content; + } + return `${JSON.stringify(artifact.content, null, 2)}\n`; +} + +async function replaceVerifiedFile( + taskRoot: string, + relativePath: string, + targetContent: string, + predecessorContent: string | null, + alternatePredecessorContent: string | null, +): Promise { + const target = safeTaskPath(taskRoot, relativePath); + await ensureSafeDirectory( + taskRoot, + path.dirname(relativePath).split(path.sep), + ); + const current = await readSafeFileOrNull(target); + if (contentMatches(relativePath, current, targetContent)) return; + if ( + current !== null && + !contentMatches(relativePath, current, predecessorContent) && + !contentMatches(relativePath, current, alternatePredecessorContent) + ) { + throw new Error('MANCODE_SPLIT_BRAIN'); + } + if ( + current === null && + (predecessorContent !== null || alternatePredecessorContent !== null) && + relativePath !== 'summary.md' + ) { + throw new Error('MANCODE_SPLIT_BRAIN'); + } + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, targetContent, { encoding: 'utf8', flag: 'wx' }); + await replaceFileAtomically(temporary, target); +} + +function contentMatches( + relativePath: string, + current: string | null, + expected: string | null, +): boolean { + if (current === null || expected === null) return current === expected; + if (!relativePath.endsWith('.json')) return current === expected; + try { + return ( + digestCanonicalJson(JSON.parse(current)) === + digestCanonicalJson(JSON.parse(expected)) + ); + } catch { + return false; + } +} + +function assertPendingMetadata( + pending: WorkflowMetadataV3 | null, + predecessor: GitRefTaskBundleV1 | null, + target: GitRefTaskBundleV1, +): void { + if (pending === null) return; + if (predecessor === null) { + throw new Error('MANCODE_MATERIALIZATION_PENDING_PREDECESSOR_INVALID'); + } + const previousMetadata = bundleMetadata(predecessor); + const targetMetadata = bundleMetadata(target); + const normalized = parseWorkflowMetadata({ + ...pending, + revision: previousMetadata.revision, + transitionState: previousMetadata.transitionState, + lastOperationId: previousMetadata.lastOperationId, + updatedAt: previousMetadata.updatedAt, + }); + if ( + pending.transitionState !== 'operation_pending' || + pending.revision !== previousMetadata.revision + 1 || + pending.lastOperationId === null || + pending.lastOperationId !== targetMetadata.lastOperationId || + targetMetadata.revision !== pending.revision + 1 || + digestCanonicalJson(normalized) !== digestCanonicalJson(previousMetadata) + ) { + throw new Error('MANCODE_MATERIALIZATION_PENDING_PREDECESSOR_INVALID'); + } +} + +function bundleMetadata(bundle: GitRefTaskBundleV1): WorkflowMetadataV3 { + const artifact = bundle.artifacts.find( + (candidate) => candidate.kind === 'metadata', + ); + if (artifact === undefined) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_ARTIFACT_INVALID'); + } + return parseWorkflowMetadata(artifact.content); +} + +async function removeVerifiedFile( + taskRoot: string, + relativePath: string, + predecessorContent: string | null, +): Promise { + const target = safeTaskPath(taskRoot, relativePath); + const current = await readSafeFileOrNull(target); + if (current === null) return; + if (predecessorContent === null || current !== predecessorContent) { + throw new Error('MANCODE_SPLIT_BRAIN'); + } + await unlink(target); +} + +async function readSafeFileOrNull(target: string): Promise { + try { + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + const content = await readFile(target, 'utf8'); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return content; + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +async function ensureSafeDirectory( + root: string, + segments: string[], +): Promise { + let current = path.resolve(root); + for (const segment of segments) { + if (!segment || segment === '.') continue; + if (segment === '..' || segment.includes('/') || segment.includes('\\')) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + current = path.join(current, segment); + try { + await mkdir(current); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + const entry = await lstat(current); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + } +} + +function safeTaskPath(taskRoot: string, relativePath: string): string { + const target = path.resolve(taskRoot, relativePath); + const relative = path.relative(taskRoot, target); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE'); + } + return target; +} + +async function createJournal( + projectRoot: string, + journal: GitRefMaterializationJournalV1, +): Promise { + await ensureSafeDirectory(projectRoot, [ + '.mancode', + 'local', + 'journals', + 'git-ref-materialize', + ]); + await writeFile( + journalPath(projectRoot, journal.operationId), + serialize(journal), + { + encoding: 'utf8', + flag: 'wx', + }, + ); +} + +async function replaceJournal( + projectRoot: string, + journal: GitRefMaterializationJournalV1, +): Promise { + const target = journalPath(projectRoot, journal.operationId); + const temporary = path.join( + path.dirname(target), + `.${journal.operationId}.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(temporary, serialize(journal), { + encoding: 'utf8', + flag: 'wx', + }); + await replaceFileAtomically(temporary, target); +} + +async function readJournal( + target: string, +): Promise { + try { + const raw = JSON.parse(await readFile(target, 'utf8')) as unknown; + return parseJournal(raw); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('MANCODE_MATERIALIZATION_JOURNAL_CORRUPT'); + } + throw error; + } +} + +async function listJournals( + projectRoot: string, +): Promise { + const directory = journalDirectory(projectRoot); + let entries: string[]; + try { + entries = await readdir(directory); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const journals: GitRefMaterializationJournalV1[] = []; + for (const entry of entries.sort()) { + if (!entry.endsWith('.json')) continue; + journals.push(await readJournal(path.join(directory, entry))); + } + return journals; +} + +function parseJournal(value: unknown): GitRefMaterializationJournalV1 { + assertRecord(value, 'git-ref materialization journal'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'workspaceId', + 'remoteRevision', + 'state', + 'predecessorBundle', + 'pendingMetadata', + 'targetBundle', + 'targetFence', + 'createdAt', + 'updatedAt', + ], + 'git-ref materialization journal', + ); + if (value.schemaVersion !== 1) { + throw new Error('MANCODE_MATERIALIZATION_JOURNAL_CORRUPT'); + } + assertUlid(value.operationId, 'git-ref materialization operationId'); + assertUlid(value.workspaceId, 'git-ref materialization workspaceId'); + if ( + value.state !== 'prepared' && + value.state !== 'applying' && + value.state !== 'committed' + ) { + throw new Error('MANCODE_MATERIALIZATION_JOURNAL_CORRUPT'); + } + const targetBundle = parseGitRefTaskBundle(value.targetBundle); + const predecessorBundle = + value.predecessorBundle === null + ? null + : parseGitRefTaskBundle(value.predecessorBundle); + const pendingMetadata = + value.pendingMetadata === null + ? null + : parseWorkflowMetadata(value.pendingMetadata); + assertPendingMetadata(pendingMetadata, predecessorBundle, targetBundle); + const targetFence = parseTaskHeadFence(value.targetFence); + if ( + !sameTaskRef(targetFence.taskRef, targetBundle.taskRef) || + targetFence.taskRevision !== targetBundle.taskRevision || + targetFence.aggregateDigest !== targetBundle.aggregateDigest + ) { + throw new Error('MANCODE_MATERIALIZATION_JOURNAL_CORRUPT'); + } + return { + schemaVersion: 1, + operationId: value.operationId, + workspaceId: value.workspaceId, + remoteRevision: positiveInteger( + value.remoteRevision, + 'git-ref materialization remoteRevision', + ), + state: value.state, + predecessorBundle, + pendingMetadata, + targetBundle, + targetFence, + createdAt: timestamp(value.createdAt), + updatedAt: timestamp(value.updatedAt), + }; +} + +function result( + status: MaterializedGitRefTaskBundleResult['status'], + bundle: GitRefTaskBundleV1, + localJournalPath: string | null, + taskHeadFence: TaskHeadFenceV1, +): MaterializedGitRefTaskBundleResult { + return { + status, + taskRevision: bundle.taskRevision, + aggregateDigest: bundle.aggregateDigest, + journalPath: localJournalPath, + taskHeadFence, + }; +} + +function journalDirectory(projectRoot: string): string { + return path.join( + path.resolve(projectRoot), + '.mancode', + 'local', + 'journals', + 'git-ref-materialize', + ); +} + +function journalPath(projectRoot: string, operationId: Ulid): string { + assertUlid(operationId, 'git-ref materialization operationId'); + return path.join(journalDirectory(projectRoot), `${operationId}.json`); +} + +function serialize(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function timestamp(value: unknown): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error('MANCODE_MATERIALIZATION_JOURNAL_CORRUPT'); + } + return value; +} + +async function pathExists(target: string): Promise { + try { + await lstat(target); + return true; + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/team/git-ref-operation.ts b/src/team/git-ref-operation.ts new file mode 100644 index 0000000..a2dcb04 --- /dev/null +++ b/src/team/git-ref-operation.ts @@ -0,0 +1,1048 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { promisify } from 'node:util'; +import { + buildTaskAggregateManifest, + taskAggregateDigest, +} from '../context/aggregate.js'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { V3ContextStore } from '../context/store.js'; +import { + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { parseWorkflowMetadata } from '../context/workflow-metadata.js'; +import { readCheckoutBranch } from '../runtime/project-runtime.js'; +import { + type OpenedV3TaskOperation, + openV3TaskOperation, +} from '../runtime/task-operation.js'; +import { gitRefCoordinationDomainId } from '../runtime/workspace-binding.js'; +import type { CheckpointV1 } from './checkpoints.js'; +import { parseClaimTtl } from './claim-acquisition.js'; +import { + type ClaimScope, + type ClaimV1, + normalizeClaimScope, + parseClaim, +} from './claims.js'; +import { + assertGitRefBundleCodeReachable, + createGitRefTaskBundle, +} from './git-ref-bundle.js'; +import { writeGitRefTeamCache } from './git-ref-cache.js'; +import { createGitRefTeamManifestStore } from './git-ref-client.js'; +import { + type GitRefOwnershipForwardRepairV1, + prepareGitRefCoordinationMutation, +} from './git-ref-coordination.js'; +import { + type GitRefTaskBundleV1, + type GitRefTeamManifestSnapshot, + type GitRefTeamManifestStore, + type GitRefTeamManifestV1, + resolveGitRefRemoteIdentityHash, +} from './git-ref-transport.js'; +import { handoffSuccessorClaimId } from './handoff-operation.js'; +import { + type HandoffSummary, + type HandoffV1, + parseHandoff, +} from './handoff.js'; + +const execFile = promisify(execFileCallback); + +export interface SyncGitRefTaskInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + operationId?: Ulid; + now?: Date; +} + +export interface SyncGitRefTaskResult { + bundle: GitRefTaskBundleV1; + remoteRevision: number; + ownershipEpoch: number; + receipt: string | null; + changed: boolean; +} + +export interface AcquireGitRefClaimInput extends SyncGitRefTaskInput { + scope: unknown; + ttlMs?: number; + claimId?: Ulid; + confirmScopeWarning?: boolean; +} + +export interface AcquiredGitRefClaim { + claim: ClaimV1; + remoteRevision: number; + ownershipEpoch: number; + receipt: string; +} + +export type GitRefClaimMutation = + | { kind: 'renew'; ttlMs?: number } + | { kind: 'release' } + | { kind: 'reclaim'; reason: string } + | { kind: 'revalidate' } + | { kind: 'transfer'; toActorId: Ulid; successorClaimId?: Ulid }; + +export interface MutateGitRefClaimInput { + projectRoot: string; + claimId: Ulid; + sessionId: Ulid; + expectedClaimRevision: number; + mutation: GitRefClaimMutation; + operationId?: Ulid; + now?: Date; +} + +export interface MutatedGitRefClaim { + claims: ClaimV1[]; + remoteRevision: number; + ownershipEpoch: number; + receipt: string; +} + +export interface CreateGitRefHandoffDraftInput extends SyncGitRefTaskInput { + toActorId: Ulid; + claimIds?: Ulid[]; + summary?: HandoffSummary; + handoffId?: Ulid; +} + +export interface MutateGitRefHandoffInput { + projectRoot: string; + handoffId: Ulid; + sessionId: Ulid; + expectedHandoffRevision: number; + mutation: + | { kind: 'offer' | 'cancel'; reason?: string } + | { + kind: 'reject'; + reason: string; + }; + operationId?: Ulid; + now?: Date; +} + +export interface AcceptGitRefHandoffInput { + projectRoot: string; + handoffId: Ulid; + sessionId: Ulid; + expectedHandoffRevision: number; + successorClaimIds?: Ulid[]; + operationId?: Ulid; + now?: Date; + /** Persists the exact deterministic recovery target before the remote CAS. */ + beforeRemoteCommit?: ( + prepared: PreparedGitRefHandoffAcceptV1, + ) => void | Promise; +} + +export interface MutatedGitRefHandoff { + operationId: Ulid; + handoff: HandoffV1; + claims: ClaimV1[]; + remoteRevision: number; + ownershipEpoch: number; + receipt: string; +} + +export interface GitRefHandoffForwardRepairPlanV1 + extends GitRefOwnershipForwardRepairV1 { + schemaVersion: 1; + handoffId: Ulid; + predecessorClaimIds: Ulid[]; + successorClaimIds: Ulid[]; + bundleDigest: string; + remoteRevision: number; +} + +export interface GitRefHandoffForwardRepairTargetV1 + extends GitRefHandoffForwardRepairPlanV1 { + receipt: string; +} + +/** Immutable write-ahead payload produced from the fresh locked snapshot. */ +export interface PreparedGitRefHandoffAcceptV1 { + schemaVersion: 1; + operationId: Ulid; + expectedRemoteRevision: number; + expectedOwnershipEpoch: number; + targetRemoteRevision: number; + targetOwnershipEpoch: number; + predecessorBundle: GitRefTaskBundleV1; + targetBundle: GitRefTaskBundleV1; + forwardRepair: GitRefHandoffForwardRepairPlanV1; +} + +export interface AcceptedGitRefHandoff extends MutatedGitRefHandoff { + taskBundle: GitRefTaskBundleV1; + forwardRepair: GitRefHandoffForwardRepairTargetV1; +} + +/** Publishes only after a fresh pull and a task/owner/epoch CAS. */ +export async function syncGitRefTask( + input: SyncGitRefTaskInput, +): Promise { + const taskRef = requireSharedTask(input.taskRef); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId, + extraEntityLocks: [`remote:${contextLockKey(taskRef)}`], + now, + }); + try { + await assertCleanGitWorktree(context.projectRoot); + const transport = createGitRefTeamManifestStore( + context.projectRoot, + context.project.config, + context.project.manifest, + ); + const snapshot = await transport.pull(); + const bundle = await bundleFromContext(context, now); + const result = await synchronizeBundle( + context, + transport, + snapshot, + bundle, + operationId, + now, + ); + const refreshed = await transport.pull(); + await writeGitRefTeamCache( + context.projectRoot, + context.project.config, + refreshed, + ); + return { bundle, ...result }; + } finally { + await context.release(); + } +} + +/** Creates an active remote claim only after the remote CAS succeeds. */ +export async function acquireGitRefClaim( + input: AcquireGitRefClaimInput, +): Promise { + const taskRef = requireSharedTask(input.taskRef); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + const claimId = input.claimId ?? createUlid(now.getTime()); + assertUlid(operationId, 'git-ref claim operationId'); + assertUlid(claimId, 'git-ref claimId'); + const scope = normalizeClaimScope(input.scope); + const ttlMs = parseClaimTtl(input.ttlMs); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId, + extraEntityLocks: [`claim:${claimId}`, `remote:${contextLockKey(taskRef)}`], + now, + }); + try { + const transport = createGitRefTeamManifestStore( + context.projectRoot, + context.project.config, + context.project.manifest, + ); + let snapshot = await transport.pull(); + const bundle = await bundleFromContext(context, now); + if (!remoteBundleMatches(snapshot, bundle)) { + await assertCleanGitWorktree(context.projectRoot); + const bootstrapOperationId = createUlid(now.getTime()); + await synchronizeBundle( + context, + transport, + snapshot, + bundle, + bootstrapOperationId, + now, + ); + snapshot = await transport.pull(); + } + const manifest = requireRemoteManifest(snapshot); + const fence = requireRemoteFence(manifest, taskRef); + const remote = context.project.config.transport.remote; + if (remote === null) throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); + const remoteIdentityHash = await resolveGitRefRemoteIdentityHash( + context.projectRoot, + remote, + ); + const timestamp = now.toISOString(); + const proposal = pendingRemoteClaim({ + context, + taskRef, + bundle, + scope, + claimId, + operationId, + remoteIdentityHash, + expiresAt: new Date(now.getTime() + ttlMs).toISOString(), + timestamp, + }); + const mutation = prepareGitRefCoordinationMutation(manifest, { + kind: 'claim_acquire', + operationId, + actorId: context.session.actorId, + taskRef, + expectedRemoteRevision: manifest.revision, + expectedOwnershipEpoch: fence.ownershipEpoch, + claim: proposal, + confirmScopeWarning: input.confirmScopeWarning, + now, + }); + const result = await transport.mutateCoordination(mutation); + const refreshed = await transport.pull(); + await writeGitRefTeamCache( + context.projectRoot, + context.project.config, + refreshed, + ); + const claim = requireRemoteManifest(refreshed).claims.find( + (candidate) => candidate.claimId === claimId, + ); + if (claim === undefined || claim.state !== 'active') { + throw new Error('MANCODE_REMOTE_RECEIPT_MISMATCH'); + } + return { + claim, + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + }; + } finally { + await context.release(); + } +} + +/** Applies one lifecycle transition against the remote claim authority. */ +export async function mutateGitRefClaim( + input: MutateGitRefClaimInput, +): Promise { + assertUlid(input.claimId, 'git-ref claimId'); + assertUlid(input.sessionId, 'git-ref claim sessionId'); + if ( + !Number.isSafeInteger(input.expectedClaimRevision) || + input.expectedClaimRevision < 1 + ) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + const projectStore = new V3ContextStore(input.projectRoot); + const project = await projectStore.readProjectSnapshot(); + const transport = createGitRefTeamManifestStore( + input.projectRoot, + project.config, + project.manifest, + ); + const snapshot = await transport.pull(); + const manifest = requireRemoteManifest(snapshot); + const claim = manifest.claims.find( + (candidate) => candidate.claimId === input.claimId, + ); + if (claim === undefined) throw new Error('MANCODE_CLAIM_NOT_FOUND'); + const taskRef = claim.taskRef; + const bundle = requireRemoteBundle(manifest, taskRef); + const fence = requireRemoteFence(manifest, taskRef); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: bundle.taskRevision, + operationId, + extraEntityLocks: [ + `claim:${input.claimId}`, + `remote:${contextLockKey(taskRef)}`, + ], + now, + }); + try { + assertContextMatchesRemoteBundle(context, bundle); + const base = { + operationId, + actorId: context.session.actorId, + taskRef, + expectedRemoteRevision: manifest.revision, + expectedOwnershipEpoch: fence.ownershipEpoch, + claimId: input.claimId, + expectedClaimRevision: input.expectedClaimRevision, + now, + }; + const mutation = + input.mutation.kind === 'renew' + ? prepareGitRefCoordinationMutation(manifest, { + ...base, + kind: 'claim_renew', + expiresAt: new Date( + now.getTime() + parseClaimTtl(input.mutation.ttlMs), + ).toISOString(), + }) + : input.mutation.kind === 'release' + ? prepareGitRefCoordinationMutation(manifest, { + ...base, + kind: 'claim_release', + }) + : input.mutation.kind === 'reclaim' + ? prepareGitRefCoordinationMutation(manifest, { + ...base, + kind: 'claim_reclaim', + reason: input.mutation.reason, + }) + : input.mutation.kind === 'revalidate' + ? prepareGitRefCoordinationMutation(manifest, { + ...base, + kind: 'claim_revalidate', + }) + : prepareGitRefCoordinationMutation(manifest, { + ...base, + kind: 'claim_transfer', + toActorId: input.mutation.toActorId, + successorClaimId: + input.mutation.successorClaimId ?? + createUlid(now.getTime()), + }); + const result = await transport.mutateCoordination(mutation); + const refreshed = await transport.pull(); + await writeGitRefTeamCache( + context.projectRoot, + context.project.config, + refreshed, + ); + return { + claims: requireRemoteManifest(refreshed).claims.filter((candidate) => + sameTaskRef(candidate.taskRef, taskRef), + ), + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + }; + } finally { + await context.release(); + } +} + +/** Publishes a named remote draft from an already-synced checkpoint bundle. */ +export async function createGitRefHandoffDraft( + input: CreateGitRefHandoffDraftInput, +): Promise { + const taskRef = requireSharedTask(input.taskRef); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + const handoffId = input.handoffId ?? createUlid(now.getTime()); + assertUlid(operationId, 'git-ref handoff draft operationId'); + assertUlid(handoffId, 'git-ref handoffId'); + assertUlid(input.toActorId, 'git-ref handoff recipient actorId'); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId, + extraEntityLocks: [ + `handoff:${handoffId}`, + `remote:${contextLockKey(taskRef)}`, + ], + now, + }); + try { + const transport = createGitRefTeamManifestStore( + context.projectRoot, + context.project.config, + context.project.manifest, + ); + const snapshot = await transport.pull(); + const manifest = requireRemoteManifest(snapshot); + const bundle = requireRemoteBundle(manifest, taskRef); + const fence = requireRemoteFence(manifest, taskRef); + assertContextMatchesRemoteBundle(context, bundle); + await Promise.all([ + assertGitRefBundleCodeReachable(context.projectRoot, bundle), + assertCleanGitWorktree(context.projectRoot), + ]); + const checkpoint = context.task.latestCheckpoint; + if ( + checkpoint === null || + bundle.aggregate.latestCheckpointId !== checkpoint.checkpointId + ) { + throw new Error('MANCODE_HANDOFF_CHECKPOINT_REQUIRED'); + } + const claimIds = + input.claimIds ?? + manifest.claims + .filter( + (claim) => + sameTaskRef(claim.taskRef, taskRef) && + claim.state === 'active' && + claim.ownerActorId === context.session.actorId, + ) + .map((claim) => claim.claimId); + const timestamp = now.toISOString(); + const proposal = parseHandoff({ + schemaVersion: 1, + handoffId, + taskRef, + taskRevision: bundle.taskRevision, + ownershipEpochAtOffer: fence.ownershipEpoch, + state: 'draft', + revision: 1, + fromActorId: context.session.actorId, + toActorId: input.toActorId, + claimIds, + checkpointRef: { + taskRef, + kind: 'checkpoint', + artifactId: checkpoint.checkpointId, + }, + summary: input.summary ?? defaultRemoteHandoffSummary(checkpoint), + transport: { + mode: 'git-ref', + state: 'stale', + transportRevision: null, + publishedAt: null, + fetchedAt: null, + taskBundleDigest: bundle.bundleDigest, + codeRef: bundle.codeRef, + codeReachable: true, + receipt: null, + }, + lastOperationId: null, + offeredAt: null, + resolution: null, + createdAt: timestamp, + updatedAt: timestamp, + }); + const mutation = prepareGitRefCoordinationMutation(manifest, { + kind: 'handoff_draft', + operationId, + actorId: context.session.actorId, + taskRef, + expectedRemoteRevision: manifest.revision, + expectedOwnershipEpoch: fence.ownershipEpoch, + handoff: proposal, + now, + }); + const result = await transport.mutateCoordination(mutation); + return finishHandoffMutation( + context, + transport, + handoffId, + operationId, + result, + 'draft', + ); + } finally { + await context.release(); + } +} + +/** Applies offer/reject/cancel after a fresh pull and one remote CAS. */ +export async function mutateGitRefHandoff( + input: MutateGitRefHandoffInput, +): Promise { + assertUlid(input.handoffId, 'git-ref handoffId'); + assertUlid(input.sessionId, 'git-ref handoff sessionId'); + assertPositiveRevision( + input.expectedHandoffRevision, + 'git-ref expectedHandoffRevision', + ); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + const opened = await openRemoteHandoffOperation({ + projectRoot: input.projectRoot, + handoffId: input.handoffId, + sessionId: input.sessionId, + operationId, + expectedHandoffRevision: input.expectedHandoffRevision, + now, + }); + try { + if (input.mutation.kind === 'offer') { + await assertCleanGitWorktree(opened.context.projectRoot); + } + const kind = + input.mutation.kind === 'offer' + ? 'handoff_offer' + : input.mutation.kind === 'reject' + ? 'handoff_reject' + : 'handoff_cancel'; + const mutation = prepareGitRefCoordinationMutation(opened.manifest, { + kind, + operationId, + actorId: opened.context.session.actorId, + taskRef: opened.handoff.taskRef, + expectedRemoteRevision: opened.manifest.revision, + expectedOwnershipEpoch: opened.fence.ownershipEpoch, + handoffId: opened.handoff.handoffId, + expectedHandoffRevision: input.expectedHandoffRevision, + reason: input.mutation.reason, + now, + }); + const result = await opened.transport.mutateCoordination(mutation); + return finishHandoffMutation( + opened.context, + opened.transport, + opened.handoff.handoffId, + operationId, + result, + input.mutation.kind === 'offer' + ? 'offered' + : input.mutation.kind === 'reject' + ? 'rejected' + : 'cancelled', + ); + } finally { + await opened.context.release(); + } +} + +/** + * Commits the remote ownership transfer and returns the exact durable target + * a local journal must converge to. This function does not edit local task + * metadata after the external commit point. + */ +export async function acceptGitRefHandoff( + input: AcceptGitRefHandoffInput, +): Promise { + assertUlid(input.handoffId, 'git-ref handoffId'); + assertUlid(input.sessionId, 'git-ref handoff sessionId'); + assertPositiveRevision( + input.expectedHandoffRevision, + 'git-ref expectedHandoffRevision', + ); + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + const opened = await openRemoteHandoffOperation({ + projectRoot: input.projectRoot, + handoffId: input.handoffId, + sessionId: input.sessionId, + operationId, + expectedHandoffRevision: input.expectedHandoffRevision, + now, + }); + try { + const successorClaimIds = + input.successorClaimIds ?? + opened.handoff.claimIds.map((claimId) => + handoffSuccessorClaimId(operationId, claimId, opened.handoff.createdAt), + ); + const taskBundle = buildAcceptedHandoffBundle( + opened.context, + opened.bundle, + opened.handoff, + operationId, + now, + ); + const mutation = prepareGitRefCoordinationMutation(opened.manifest, { + kind: 'handoff_accept', + operationId, + actorId: opened.context.session.actorId, + taskRef: opened.handoff.taskRef, + expectedRemoteRevision: opened.manifest.revision, + expectedOwnershipEpoch: opened.fence.ownershipEpoch, + handoffId: opened.handoff.handoffId, + expectedHandoffRevision: input.expectedHandoffRevision, + successorClaimIds, + taskBundle, + codeReachable: true, + now, + }); + if (mutation.forwardRepair === null) { + throw new Error('MANCODE_REMOTE_FORWARD_REPAIR_TARGET_MISSING'); + } + const forwardRepair: GitRefHandoffForwardRepairPlanV1 = { + schemaVersion: 1, + ...mutation.forwardRepair, + handoffId: opened.handoff.handoffId, + predecessorClaimIds: [...opened.handoff.claimIds], + successorClaimIds: [...successorClaimIds], + bundleDigest: taskBundle.bundleDigest, + remoteRevision: opened.manifest.revision + 1, + }; + const prepared = freezeDeep({ + schemaVersion: 1 as const, + operationId, + expectedRemoteRevision: opened.manifest.revision, + expectedOwnershipEpoch: opened.fence.ownershipEpoch, + targetRemoteRevision: opened.manifest.revision + 1, + targetOwnershipEpoch: mutation.forwardRepair.ownershipEpoch, + predecessorBundle: opened.bundle, + targetBundle: taskBundle, + forwardRepair, + }); + await input.beforeRemoteCommit?.(prepared); + const result = await opened.transport.mutateCoordination(mutation); + const completed = await finishHandoffMutation( + opened.context, + opened.transport, + opened.handoff.handoffId, + operationId, + result, + 'accepted', + ); + return { + ...completed, + taskBundle, + forwardRepair: { + ...forwardRepair, + receipt: result.receipt, + }, + }; + } finally { + await opened.context.release(); + } +} + +interface OpenedRemoteHandoffOperation { + context: OpenedV3TaskOperation; + transport: GitRefTeamManifestStore; + manifest: GitRefTeamManifestV1; + fence: GitRefTeamManifestV1['ownershipFences'][number]; + bundle: GitRefTaskBundleV1; + handoff: HandoffV1; +} + +async function openRemoteHandoffOperation(input: { + projectRoot: string; + handoffId: Ulid; + sessionId: Ulid; + expectedHandoffRevision: number; + operationId: Ulid; + now: Date; +}): Promise { + const project = await new V3ContextStore( + input.projectRoot, + ).readProjectSnapshot(); + const transport = createGitRefTeamManifestStore( + input.projectRoot, + project.config, + project.manifest, + ); + const manifest = requireRemoteManifest(await transport.pull()); + const handoff = manifest.handoffs.find( + (candidate) => candidate.handoffId === input.handoffId, + ); + if (handoff === undefined) throw new Error('MANCODE_HANDOFF_NOT_FOUND'); + if (handoff.revision !== input.expectedHandoffRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + const bundle = requireRemoteBundle(manifest, handoff.taskRef); + const fence = requireRemoteFence(manifest, handoff.taskRef); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef: handoff.taskRef, + sessionId: input.sessionId, + expectedTaskRevision: bundle.taskRevision, + operationId: input.operationId, + extraEntityLocks: [ + `handoff:${handoff.handoffId}`, + ...handoff.claimIds.map((claimId) => `claim:${claimId}`), + `remote:${contextLockKey(handoff.taskRef)}`, + ], + now: input.now, + }); + try { + assertContextMatchesRemoteBundle(context, bundle); + await assertGitRefBundleCodeReachable(context.projectRoot, bundle); + return { context, transport, manifest, fence, bundle, handoff }; + } catch (error) { + await context.release(); + throw error; + } +} + +async function finishHandoffMutation( + context: OpenedV3TaskOperation, + transport: GitRefTeamManifestStore, + handoffId: Ulid, + operationId: Ulid, + result: { receipt: string; remoteRevision: number; ownershipEpoch: number }, + expectedState: HandoffV1['state'], +): Promise { + const refreshed = await transport.pull(); + await writeGitRefTeamCache( + context.projectRoot, + context.project.config, + refreshed, + ); + const manifest = requireRemoteManifest(refreshed); + const handoff = manifest.handoffs.find( + (candidate) => candidate.handoffId === handoffId, + ); + if ( + handoff === undefined || + handoff.state !== expectedState || + handoff.lastOperationId !== operationId || + manifest.lastMutation?.operationId !== operationId || + manifest.revision !== result.remoteRevision || + refreshed.receipt !== result.receipt + ) { + throw new Error('MANCODE_REMOTE_RECEIPT_MISMATCH'); + } + return { + operationId, + handoff, + claims: manifest.claims.filter((claim) => + sameTaskRef(claim.taskRef, handoff.taskRef), + ), + remoteRevision: result.remoteRevision, + ownershipEpoch: result.ownershipEpoch, + receipt: result.receipt, + }; +} + +function buildAcceptedHandoffBundle( + context: OpenedV3TaskOperation, + currentBundle: GitRefTaskBundleV1, + handoff: HandoffV1, + operationId: Ulid, + now: Date, +): GitRefTaskBundleV1 { + const metadata = parseWorkflowMetadata({ + ...context.task.metadata, + revision: context.task.metadata.revision + 2, + transitionState: 'stable', + lastOperationId: operationId, + ownerActorId: handoff.toActorId, + ownershipEpoch: context.task.metadata.ownershipEpoch + 1, + updatedAt: now.toISOString(), + }); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: context.task.latestCheckpoint, + }); + return createGitRefTaskBundle({ + task: { ...context.task, metadata, aggregate }, + codeRef: currentBundle.codeRef, + now, + }); +} + +function defaultRemoteHandoffSummary(checkpoint: CheckpointV1): HandoffSummary { + return { + completed: [], + inProgress: [], + notStarted: [], + changedFiles: [], + verification: [], + blockers: [], + risks: [], + nextAction: checkpoint.nextAction, + }; +} + +async function assertCleanGitWorktree(projectRoot: string): Promise { + const { stdout } = await execFile( + 'git', + ['status', '--porcelain=v1', '--untracked-files=all'], + { cwd: projectRoot, windowsHide: true }, + ); + if (stdout.trim()) throw new Error('MANCODE_HANDOFF_DIRTY_WORKTREE'); +} + +function assertPositiveRevision(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } +} + +function freezeDeep(value: T): Readonly { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) { + return value; + } + for (const child of Object.values(value)) freezeDeep(child); + return Object.freeze(value); +} + +async function synchronizeBundle( + context: OpenedV3TaskOperation, + transport: GitRefTeamManifestStore, + snapshot: GitRefTeamManifestSnapshot, + bundle: GitRefTaskBundleV1, + operationId: Ulid, + now: Date, +): Promise> { + const manifest = requireRemoteManifest(snapshot); + const fence = manifest.ownershipFences.find((candidate) => + sameTaskRef(candidate.taskRef, context.taskRef), + ); + if (fence === undefined) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_REQUIRED'); + } + if (remoteBundleMatches(snapshot, bundle)) { + return { + remoteRevision: manifest.revision, + ownershipEpoch: fence.ownershipEpoch, + receipt: snapshot.receipt, + changed: false, + }; + } + const mutation = prepareGitRefCoordinationMutation(manifest, { + kind: 'ownership_fence', + operationId, + actorId: context.session.actorId, + taskRef: context.taskRef, + expectedRemoteRevision: manifest.revision, + expectedOwnershipEpoch: fence.ownershipEpoch, + taskBundle: bundle, + now, + }); + const result = await transport.mutateCoordination(mutation); + return { ...result, changed: true }; +} + +async function bundleFromContext( + context: OpenedV3TaskOperation, + now: Date, +): Promise { + if (context.codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + } + return createGitRefTaskBundle({ + task: context.task, + codeRef: { + branch: (await readCheckoutBranch(context.projectRoot)) ?? 'HEAD', + head: context.codeHead, + }, + now, + }); +} + +function pendingRemoteClaim(input: { + context: OpenedV3TaskOperation; + taskRef: TaskRef; + bundle: GitRefTaskBundleV1; + scope: ClaimScope; + claimId: Ulid; + operationId: Ulid; + remoteIdentityHash: string; + expiresAt: string; + timestamp: string; +}): ClaimV1 { + return parseClaim({ + schemaVersion: 1, + claimId: input.claimId, + workspaceId: input.context.runtime.workspaceId, + coordinationDomainId: gitRefCoordinationDomainId( + input.remoteIdentityHash, + input.context.runtime.workspaceId, + input.context.project.config.transport.epoch, + ), + authority: { mode: 'git-ref', remoteRevision: null }, + taskRef: input.taskRef, + taskRevisionAtAcquire: input.bundle.taskRevision, + lastValidatedTaskRevision: input.bundle.taskRevision, + implementationScopeDigest: + input.context.task.metadata.implementationScope.digest, + ownershipEpochAtAcquire: input.bundle.ownershipEpoch, + ownerActorId: input.context.session.actorId, + state: 'pending', + revision: 1, + scope: input.scope, + scopeDigest: digestScope(input.scope), + codeRefAtAcquire: input.bundle.codeRef, + lastValidatedCodeRef: input.bundle.codeRef, + acquisitionEnforcement: 'enforced', + writeGuard: 'advisory', + expiresAt: input.expiresAt, + predecessorClaimId: null, + successorClaimId: null, + lastOperationId: input.operationId, + createdAt: input.timestamp, + updatedAt: input.timestamp, + }); +} + +function remoteBundleMatches( + snapshot: GitRefTeamManifestSnapshot, + bundle: GitRefTaskBundleV1, +): boolean { + const remote = snapshot.manifest?.taskBundles.find((candidate) => + sameTaskRef(candidate.taskRef, bundle.taskRef), + ); + return ( + remote !== undefined && + remote.aggregateDigest === bundle.aggregateDigest && + remote.taskRevision === bundle.taskRevision && + remote.ownershipEpoch === bundle.ownershipEpoch && + remote.codeRef.branch === bundle.codeRef.branch && + remote.codeRef.head === bundle.codeRef.head + ); +} + +function requireRemoteManifest(snapshot: GitRefTeamManifestSnapshot) { + if (snapshot.manifest === null) { + throw new Error('MANCODE_TRANSPORT_ACTOR_NOT_JOINED'); + } + return snapshot.manifest; +} + +function requireRemoteFence( + manifest: NonNullable, + taskRef: TaskRef, +) { + const fence = manifest.ownershipFences.find((candidate) => + sameTaskRef(candidate.taskRef, taskRef), + ); + if (fence === undefined) { + throw new Error('MANCODE_REMOTE_OWNERSHIP_FENCE_MISSING'); + } + return fence; +} + +function requireRemoteBundle( + manifest: NonNullable, + taskRef: TaskRef, +): GitRefTaskBundleV1 { + const bundle = manifest.taskBundles.find((candidate) => + sameTaskRef(candidate.taskRef, taskRef), + ); + if (bundle === undefined) throw new Error('MANCODE_TASK_UNAVAILABLE'); + return bundle; +} + +function assertContextMatchesRemoteBundle( + context: OpenedV3TaskOperation, + bundle: GitRefTaskBundleV1, +): void { + if ( + context.task.aggregate === null || + taskAggregateDigest(context.task.aggregate) !== bundle.aggregateDigest || + context.codeHead !== bundle.codeRef.head + ) { + throw new Error('MANCODE_TASK_BUNDLE_DIVERGED'); + } +} + +function requireSharedTask(value: TaskRef): TaskRef { + const taskRef = parseTaskRefValue(value); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_REMOTE_COORDINATION_REQUIRES_SHARED_TASK'); + } + return taskRef; +} + +function contextLockKey(taskRef: TaskRef): string { + return `git-ref-${taskRef.taskId}`; +} + +function digestScope(scope: ClaimScope): string { + return digestCanonicalJson(scope); +} diff --git a/src/team/git-ref-transport.ts b/src/team/git-ref-transport.ts new file mode 100644 index 0000000..d90064f --- /dev/null +++ b/src/team/git-ref-transport.ts @@ -0,0 +1,3013 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { + type TaskAggregateManifestV1, + parseTaskAggregateManifest, + taskAggregateDigest, +} from '../context/aggregate.js'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { + assertSafeSharedRelativePath, + assertSharedTextSafe, +} from '../context/privacy.js'; +import { + parseRequirementsLedger, + requirementsLedgerDigest, +} from '../context/requirements-ledger.js'; +import { + parseReviewLedger, + reviewLedgerDigest, +} from '../context/review-ledger.js'; +import { + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { + parseVerificationLedger, + verificationLedgerDigest, +} from '../context/verification-ledger.js'; +import { + type WorkflowMetadataV3, + parseWorkflowMetadata, + workflowMetadataDigest, +} from '../context/workflow-metadata.js'; +import { gitRefCoordinationDomainId } from '../runtime/workspace-binding.js'; +import { type SharedActorProfileV1, parseSharedActorProfile } from './actor.js'; +import { checkpointDigest, parseCheckpoint } from './checkpoints.js'; +import { type ClaimV1, assertClaimTransition, parseClaim } from './claims.js'; +import { + type HandoffV1, + assertHandoffTransition, + parseHandoff, +} from './handoff.js'; +import type { CoordinationCapabilitiesV1 } from './transport.js'; + +const execFile = promisify(execFileCallback); +const TEAM_REF = 'refs/mancode/team'; +const MAX_MANIFEST_BYTES = 1_000_000; +const MAX_ACTOR_PROFILES = 256; +const MAX_OWNERSHIP_FENCES = 512; +const MAX_CLAIMS = 2_048; +const MAX_HANDOFFS = 1_024; +const MAX_TASK_BUNDLES = 128; +const MAX_RECEIPTS = 256; +const MAX_BUNDLE_ARTIFACTS = 16; +const MAX_BUNDLE_ARTIFACT_BYTES = 256_000; +const MAX_JSON_DEPTH = 64; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; +const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const GIT_OBJECT_PATTERN = /^[0-9a-f]{40,64}$/; + +export interface GitRefOwnershipFenceV1 { + schemaVersion: 1; + taskRef: TaskRef; + ownerActorId: Ulid; + ownershipEpoch: number; + taskRevision: number; + aggregateDigest: string; + remoteRevision: number; + lastOperationId: Ulid; + updatedAt: string; +} + +export type GitRefTaskBundleArtifactKind = + | 'metadata' + | 'checkpoint' + | 'requirements' + | 'review' + | 'verification' + | 'plan' + | 'summary'; + +/** Runtime parsing narrows this to JSON; callers may pass typed domain values. */ +export type GitRefJsonValue = unknown; + +export interface GitRefTaskBundleArtifactV1 { + kind: GitRefTaskBundleArtifactKind; + relativePath: string; + content: GitRefJsonValue; + contentDigest: string; +} + +export interface GitRefTaskBundleV1 { + schemaVersion: 1; + taskRef: TaskRef; + taskRevision: number; + ownershipEpoch: number; + aggregate: TaskAggregateManifestV1; + aggregateDigest: string; + codeRef: { + branch: string; + head: string; + }; + artifacts: GitRefTaskBundleArtifactV1[]; + bundleDigest: string; + createdAt: string; +} + +export interface GitRefRemoteMutationReceiptV1 { + schemaVersion: 1; + kind: + | 'actor_profile' + | 'coordination' + | 'authority_establish' + | 'authority_freeze' + | 'authority_unfreeze' + | 'authority_tombstone'; + operationId: Ulid; + actorId: Ulid; + taskRef: TaskRef | null; + remoteRevision: number; + ownershipEpoch: number | null; + entityDigests: { + actorProfiles: string; + ownershipFence: string | null; + claims: string; + handoffs: string; + taskBundle: string | null; + }; + committedAt: string; +} + +export interface GitRefAuthorityTombstoneV1 { + schemaVersion: 1; + successorMode: 'local' | 'git-ref'; + successorEpoch: number; + operationId: Ulid; + tombstonedAt: string; +} + +export interface GitRefAuthorityFreezeV1 { + schemaVersion: 1; + successorMode: 'local' | 'git-ref'; + successorEpoch: number; + operationId: Ulid; + frozenAt: string; +} + +export interface GitRefTeamManifestV1 { + schemaVersion: 1; + workspaceId: Ulid; + schemaEpoch: Ulid; + minReaderVersion: string; + minWriterVersion: string; + transportEpoch: number; + configRevision: number; + configDigest: string; + authorityState: 'active' | 'frozen' | 'tombstoned'; + authorityFreeze: GitRefAuthorityFreezeV1 | null; + authorityTombstone: GitRefAuthorityTombstoneV1 | null; + revision: number; + lastOperationId: Ulid; + actorProfiles: SharedActorProfileV1[]; + ownershipFences: GitRefOwnershipFenceV1[]; + claims: ClaimV1[]; + handoffs: HandoffV1[]; + taskBundles: GitRefTaskBundleV1[]; + receipts: GitRefRemoteMutationReceiptV1[]; + lastMutation: GitRefRemoteMutationReceiptV1 | null; + updatedAt: string; +} + +export interface GitRefTeamManifestSnapshot { + manifest: GitRefTeamManifestV1 | null; + commit: string | null; + receipt: string | null; + fetchedAt: string; +} + +export interface GitRefTeamManifestStoreOptions { + projectRoot: string; + remote: string; + workspaceId: Ulid; + schemaEpoch?: Ulid; + minReaderVersion?: string; + minWriterVersion?: string; + transportEpoch?: number; + configRevision?: number; + configDigest?: string; + now?: () => Date; +} + +export interface PublishGitRefActorProfileInput { + operationId: Ulid; + expectedRemoteRevision: number; + profile: SharedActorProfileV1; +} + +export interface MutateGitRefCoordinationInput { + operationId: Ulid; + actorId: Ulid; + taskRef: TaskRef; + expectedRemoteRevision: number; + expectedOwnershipEpoch: number; + ownershipFence: GitRefOwnershipFenceV1; + claims: ClaimV1[]; + handoffs: HandoffV1[]; + taskBundle: GitRefTaskBundleV1 | null; +} + +export interface EstablishGitRefCoordinationAuthorityInput { + action: 'establish'; + operationId: Ulid; + actorId: Ulid; + expectedRemoteRevision: number; + expectedRemoteTransportEpoch?: number | null; + expectedPriorTransportEpoch: number | null; + targetTransportEpoch: number; + actorProfiles: SharedActorProfileV1[]; + ownershipFences: GitRefOwnershipFenceV1[]; + claims: ClaimV1[]; + handoffs: HandoffV1[]; + taskBundles: GitRefTaskBundleV1[]; +} + +export interface TombstoneGitRefCoordinationAuthorityInput { + action: 'tombstone'; + operationId: Ulid; + actorId: Ulid; + expectedRemoteRevision: number; + expectedPriorTransportEpoch: number; + successorMode: 'local' | 'git-ref'; + successorEpoch: number; +} + +export interface FreezeGitRefCoordinationAuthorityInput { + action: 'freeze'; + operationId: Ulid; + actorId: Ulid; + expectedRemoteRevision: number; + expectedPriorTransportEpoch: number; + successorMode: 'local' | 'git-ref'; + successorEpoch: number; +} + +export interface UnfreezeGitRefCoordinationAuthorityInput { + action: 'unfreeze'; + operationId: Ulid; + actorId: Ulid; + expectedRemoteRevision: number; + expectedPriorTransportEpoch: number; + freezeOperationId?: Ulid; +} + +export type MutateGitRefCoordinationAuthorityInput = + | EstablishGitRefCoordinationAuthorityInput + | FreezeGitRefCoordinationAuthorityInput + | UnfreezeGitRefCoordinationAuthorityInput + | TombstoneGitRefCoordinationAuthorityInput; + +interface ManifestHeader { + workspaceId: Ulid; + schemaEpoch: Ulid; + minReaderVersion: string; + minWriterVersion: string; + transportEpoch: number; + configRevision: number; + configDigest: string; +} + +/** Stable, credential-free identity for a configured remote, not its local alias. */ +export async function resolveGitRefRemoteIdentityHash( + projectRoot: string, + remote: string, +): Promise { + if (!projectRoot.trim() || !remote.trim() || remote.includes('\0')) { + throw new Error('MANCODE_TRANSPORT_REMOTE_INVALID'); + } + let configured = remote; + try { + const output = await runGit(path.resolve(projectRoot), [ + 'remote', + 'get-url', + '--all', + remote, + ]); + if (output.trim()) configured = output; + } catch (error) { + if (!isGitFailure(error)) throw error; + } + const identities = [ + ...new Set( + configured + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => normalizeRemoteIdentity(projectRoot, value)), + ), + ].sort(compareUtf8); + if (identities.length === 0) { + throw new Error('MANCODE_TRANSPORT_REMOTE_INVALID'); + } + return digestCanonicalJson({ remoteIdentities: identities }); +} + +/** Git plumbing and the single-ref CAS boundary for remote coordination. */ +export class GitRefTeamManifestStore { + private readonly projectRoot: string; + private readonly remote: string; + private readonly workspaceId: Ulid; + private readonly expectedHeader: Partial>; + private readonly initialHeader: ManifestHeader; + private readonly now: () => Date; + private remoteIdentityHashPromise: Promise | null = null; + + constructor(options: GitRefTeamManifestStoreOptions) { + if (!options.projectRoot.trim()) { + throw new Error('MANCODE_TRANSPORT_PROJECT_ROOT_INVALID'); + } + if (!options.remote.trim() || options.remote.includes('\0')) { + throw new Error('MANCODE_TRANSPORT_REMOTE_INVALID'); + } + assertUlid(options.workspaceId, 'git-ref workspaceId'); + if (options.schemaEpoch !== undefined) { + assertUlid(options.schemaEpoch, 'git-ref schemaEpoch'); + } + parseOptionalVersion(options.minReaderVersion, 'git-ref minReaderVersion'); + parseOptionalVersion(options.minWriterVersion, 'git-ref minWriterVersion'); + parseOptionalPositiveInteger( + options.transportEpoch, + 'git-ref transportEpoch', + ); + parseOptionalPositiveInteger( + options.configRevision, + 'git-ref configRevision', + ); + parseOptionalDigest(options.configDigest, 'git-ref configDigest'); + this.projectRoot = path.resolve(options.projectRoot); + this.remote = options.remote; + this.workspaceId = options.workspaceId; + this.expectedHeader = { + ...(options.schemaEpoch === undefined + ? {} + : { schemaEpoch: options.schemaEpoch }), + ...(options.minReaderVersion === undefined + ? {} + : { minReaderVersion: options.minReaderVersion }), + ...(options.minWriterVersion === undefined + ? {} + : { minWriterVersion: options.minWriterVersion }), + ...(options.transportEpoch === undefined + ? {} + : { transportEpoch: options.transportEpoch }), + ...(options.configRevision === undefined + ? {} + : { configRevision: options.configRevision }), + ...(options.configDigest === undefined + ? {} + : { configDigest: options.configDigest }), + }; + this.initialHeader = compatibilityHeader(options); + this.now = options.now ?? (() => new Date()); + } + + async pull(): Promise { + return this.pullManifest(true); + } + + private async pullManifest( + validateExpectedHeader: boolean, + ): Promise { + const remoteCommit = await readRemoteCommit(this.projectRoot, this.remote); + const fetchedAt = this.now().toISOString(); + if (remoteCommit === null) { + return { manifest: null, commit: null, receipt: null, fetchedAt }; + } + await runGit(this.projectRoot, [ + 'fetch', + '--no-tags', + this.remote, + TEAM_REF, + ]); + const content = await gitShow( + this.projectRoot, + `${remoteCommit}:manifest.json`, + ); + if (Buffer.byteLength(content, 'utf8') > MAX_MANIFEST_BYTES) { + throw new Error('MANCODE_TRANSPORT_MANIFEST_TOO_LARGE'); + } + let value: unknown; + try { + value = JSON.parse(content); + } catch { + throw new Error('MANCODE_TRANSPORT_MANIFEST_INVALID'); + } + const manifest = parseGitRefTeamManifest(value); + if (manifest.workspaceId !== this.workspaceId) { + throw new Error('MANCODE_TRANSPORT_WORKSPACE_MISMATCH'); + } + if (validateExpectedHeader) { + assertExpectedHeader(manifest, this.expectedHeader); + } + assertManifestCoordinationDomain( + manifest, + gitRefCoordinationDomainId( + await this.remoteIdentityHash(), + manifest.workspaceId, + manifest.transportEpoch, + ), + ); + return { + manifest, + commit: remoteCommit, + receipt: receiptFor(remoteCommit, manifest), + fetchedAt, + }; + } + + async capabilities(): Promise { + const snapshot = await this.pull(); + return { + claimAcquisition: + snapshot.manifest?.authorityState === 'active' + ? 'enforced' + : 'unavailable', + writeGuard: 'advisory', + transport: 'git-ref', + transportFreshness: 'fresh', + lastSuccessfulSyncAt: snapshot.fetchedAt, + remoteRevision: snapshot.manifest?.revision ?? 0, + }; + } + + async publishActorProfile( + input: PublishGitRefActorProfileInput, + ): Promise<{ receipt: string; remoteRevision: number }> { + assertUlid(input.operationId, 'git-ref operationId'); + const expectedRevision = parseExpectedRevision( + input.expectedRemoteRevision, + ); + const profile = parseSharedActorProfile(input.profile); + const current = await this.pull(); + assertActiveAuthority(current.manifest); + const currentRevision = current.manifest?.revision ?? 0; + if (currentRevision !== expectedRevision) { + throw new Error('MANCODE_TRANSPORT_REVISION_CONFLICT'); + } + const timestamp = this.now().toISOString(); + const actorProfiles = upsertActorProfile( + current.manifest?.actorProfiles ?? [], + profile, + ); + const base = current.manifest ?? emptyManifest(this.initialHeader); + const receipt = createMutationReceipt({ + kind: 'actor_profile', + operationId: input.operationId, + actorId: profile.actorId, + taskRef: null, + remoteRevision: currentRevision + 1, + ownershipEpoch: null, + actorProfiles, + ownershipFence: null, + claims: [], + handoffs: [], + taskBundle: null, + committedAt: timestamp, + }); + const receipts = appendReceipt(base.receipts, receipt); + const next = parseGitRefTeamManifest({ + ...base, + revision: currentRevision + 1, + lastOperationId: input.operationId, + actorProfiles, + receipts, + lastMutation: receipt, + updatedAt: timestamp, + }); + return this.commitMutation(current, next); + } + + async mutateCoordination(input: MutateGitRefCoordinationInput): Promise<{ + receipt: string; + remoteRevision: number; + ownershipEpoch: number; + }> { + assertUlid(input.operationId, 'git-ref coordination operationId'); + assertUlid(input.actorId, 'git-ref coordination actorId'); + const taskRef = parseSharedTaskRef( + input.taskRef, + 'git-ref coordination taskRef', + ); + const expectedRevision = parseExpectedRevision( + input.expectedRemoteRevision, + ); + const expectedOwnershipEpoch = parseNonNegativeInteger( + input.expectedOwnershipEpoch, + 'git-ref coordination expectedOwnershipEpoch', + ); + const current = await this.pull(); + assertActiveAuthority(current.manifest); + const currentRevision = current.manifest?.revision ?? 0; + if (currentRevision !== expectedRevision) { + throw new Error('MANCODE_TRANSPORT_REVISION_CONFLICT'); + } + const base = current.manifest ?? emptyManifest(this.initialHeader); + const previousFence = base.ownershipFences.find((candidate) => + sameTaskRef(candidate.taskRef, taskRef), + ); + if ( + !base.actorProfiles.some((profile) => profile.actorId === input.actorId) + ) { + throw new Error('MANCODE_TRANSPORT_ACTOR_NOT_JOINED'); + } + if ((previousFence?.ownershipEpoch ?? 0) !== expectedOwnershipEpoch) { + throw new Error('MANCODE_TRANSPORT_OWNERSHIP_EPOCH_CONFLICT'); + } + const nextRevision = currentRevision + 1; + const fence = parseGitRefOwnershipFence(input.ownershipFence); + const claims = parseReplacementClaims( + input.claims, + taskRef, + base.workspaceId, + gitRefCoordinationDomainId( + await this.remoteIdentityHash(), + base.workspaceId, + base.transportEpoch, + ), + ); + const handoffs = parseReplacementHandoffs(input.handoffs, taskRef); + const taskBundle = + input.taskBundle === null + ? null + : parseGitRefTaskBundle(input.taskBundle); + assertCoordinationMutation({ + operationId: input.operationId, + actorId: input.actorId, + taskRef, + previousFence, + previousClaims: base.claims.filter((claim) => + sameTaskRef(claim.taskRef, taskRef), + ), + previousHandoffs: base.handoffs.filter((handoff) => + sameTaskRef(handoff.taskRef, taskRef), + ), + previousTaskBundle: base.taskBundles.find((bundle) => + sameTaskRef(bundle.taskRef, taskRef), + ), + expectedOwnershipEpoch, + nextRevision, + fence, + claims, + handoffs, + taskBundle, + }); + const timestamp = this.now().toISOString(); + const ownershipFences = replaceTaskEntity( + base.ownershipFences, + taskRef, + [fence], + (value) => value.taskRef, + ).sort(compareByTaskRef); + const nextClaims = replaceTaskEntity( + base.claims, + taskRef, + claims, + (value) => value.taskRef, + ).sort(compareClaims); + const nextHandoffs = replaceTaskEntity( + base.handoffs, + taskRef, + handoffs, + (value) => value.taskRef, + ).sort(compareHandoffs); + const taskBundles = replaceTaskEntity( + base.taskBundles, + taskRef, + taskBundle === null ? [] : [taskBundle], + (value) => value.taskRef, + ).sort(compareByTaskRef); + const receipt = createMutationReceipt({ + kind: 'coordination', + operationId: input.operationId, + actorId: input.actorId, + taskRef, + remoteRevision: nextRevision, + ownershipEpoch: fence.ownershipEpoch, + actorProfiles: base.actorProfiles, + ownershipFence: fence, + claims, + handoffs, + taskBundle, + committedAt: timestamp, + }); + const next = parseGitRefTeamManifest({ + ...base, + revision: nextRevision, + lastOperationId: input.operationId, + ownershipFences, + claims: nextClaims, + handoffs: nextHandoffs, + taskBundles, + receipts: appendReceipt(base.receipts, receipt), + lastMutation: receipt, + updatedAt: timestamp, + }); + const result = await this.commitMutation(current, next); + return { ...result, ownershipEpoch: fence.ownershipEpoch }; + } + + async mutateCoordinationAuthority( + input: MutateGitRefCoordinationAuthorityInput, + ): Promise<{ + receipt: string; + remoteRevision: number; + transportEpoch: number; + }> { + assertUlid(input.operationId, 'git-ref authority operationId'); + assertUlid(input.actorId, 'git-ref authority actorId'); + const expectedRevision = parseExpectedRevision( + input.expectedRemoteRevision, + ); + const current = await this.pullManifest(false); + const currentRevision = current.manifest?.revision ?? 0; + if (currentRevision !== expectedRevision) { + const committed = committedAuthorityMutation(current, input); + if (committed !== null) return committed; + throw new Error('MANCODE_TRANSPORT_REVISION_CONFLICT'); + } + switch (input.action) { + case 'establish': + return this.establishAuthority(current, input); + case 'freeze': + return this.freezeAuthority(current, input); + case 'unfreeze': + return this.unfreezeAuthority(current, input); + case 'tombstone': + return this.tombstoneAuthority(current, input); + } + } + + async establishCoordinationAuthority( + input: Omit, + ): Promise<{ + receipt: string; + remoteRevision: number; + transportEpoch: number; + }> { + return this.mutateCoordinationAuthority({ ...input, action: 'establish' }); + } + + async tombstoneCoordinationAuthority( + input: Omit, + ): Promise<{ + receipt: string; + remoteRevision: number; + transportEpoch: number; + }> { + return this.mutateCoordinationAuthority({ ...input, action: 'tombstone' }); + } + + async freezeCoordinationAuthority( + input: Omit, + ): Promise<{ + receipt: string; + remoteRevision: number; + transportEpoch: number; + }> { + return this.mutateCoordinationAuthority({ ...input, action: 'freeze' }); + } + + async unfreezeCoordinationAuthority( + input: Omit, + ): Promise<{ + receipt: string; + remoteRevision: number; + transportEpoch: number; + }> { + return this.mutateCoordinationAuthority({ ...input, action: 'unfreeze' }); + } + + private async establishAuthority( + current: GitRefTeamManifestSnapshot, + input: EstablishGitRefCoordinationAuthorityInput, + ): Promise<{ + receipt: string; + remoteRevision: number; + transportEpoch: number; + }> { + const expectedPriorEpoch = parsePositiveIntegerOrNull( + input.expectedPriorTransportEpoch, + 'git-ref authority expectedPriorTransportEpoch', + ); + const remoteEpoch = current.manifest?.transportEpoch ?? null; + const expectedRemoteEpoch = + input.expectedRemoteTransportEpoch === undefined + ? expectedPriorEpoch + : parsePositiveIntegerOrNull( + input.expectedRemoteTransportEpoch, + 'git-ref authority expectedRemoteTransportEpoch', + ); + if (remoteEpoch !== expectedRemoteEpoch) { + throw new Error('MANCODE_TRANSPORT_EPOCH_CONFLICT'); + } + const targetEpoch = parsePositiveInteger( + input.targetTransportEpoch, + 'git-ref authority targetTransportEpoch', + ); + if (expectedPriorEpoch !== null && targetEpoch <= expectedPriorEpoch) { + throw new Error('MANCODE_TRANSPORT_EPOCH_CONFLICT'); + } + if (this.initialHeader.transportEpoch !== targetEpoch) { + throw new Error('MANCODE_TRANSPORT_TRANSPORT_EPOCH_MISMATCH'); + } + if ( + current.manifest !== null && + (current.manifest.schemaEpoch !== this.initialHeader.schemaEpoch || + this.initialHeader.configRevision <= current.manifest.configRevision) + ) { + throw new Error('MANCODE_TRANSPORT_HEADER_TRANSITION_INVALID'); + } + if ( + current.manifest !== null && + current.manifest.authorityState !== 'tombstoned' + ) { + throw new Error('MANCODE_TRANSPORT_AUTHORITY_NOT_TOMBSTONED'); + } + if (current.manifest?.authorityState === 'tombstoned') { + const predecessor = current.manifest.authorityTombstone; + const directGitRefSuccessor = + predecessor?.successorMode === 'git-ref' && + predecessor.successorEpoch === targetEpoch && + expectedPriorEpoch === remoteEpoch; + const returnedFromLocalSuccessor = + predecessor?.successorMode === 'local' && + predecessor.successorEpoch === expectedPriorEpoch && + expectedPriorEpoch !== null && + targetEpoch > expectedPriorEpoch; + if (!directGitRefSuccessor && !returnedFromLocalSuccessor) { + throw new Error('MANCODE_TRANSPORT_TOMBSTONE_SUCCESSOR_MISMATCH'); + } + } + const nextRevision = + current.manifest === null ? 1 : current.manifest.revision + 1; + const timestamp = this.now().toISOString(); + const actorProfiles = parseBoundedArray( + input.actorProfiles, + MAX_ACTOR_PROFILES, + 'git-ref authority actorProfiles', + parseSharedActorProfile, + ).sort(compareActorProfiles); + assertUnique( + actorProfiles, + (profile) => profile.actorId, + 'git-ref authority actorProfiles', + ); + if (!actorProfiles.some((profile) => profile.actorId === input.actorId)) { + throw new Error('MANCODE_TRANSPORT_ACTOR_NOT_JOINED'); + } + const ownershipFences = parseBoundedArray( + input.ownershipFences, + MAX_OWNERSHIP_FENCES, + 'git-ref authority ownershipFences', + parseGitRefOwnershipFence, + ).sort(compareByTaskRef); + const claims = parseBoundedArray( + input.claims, + MAX_CLAIMS, + 'git-ref authority claims', + parseClaim, + ).sort(compareClaims); + const coordinationDomainId = gitRefCoordinationDomainId( + await this.remoteIdentityHash(), + this.workspaceId, + targetEpoch, + ); + const handoffs = parseBoundedArray( + input.handoffs, + MAX_HANDOFFS, + 'git-ref authority handoffs', + parseHandoff, + ).sort(compareHandoffs); + const taskBundles = parseBoundedArray( + input.taskBundles, + MAX_TASK_BUNDLES, + 'git-ref authority taskBundles', + parseGitRefTaskBundle, + ).sort(compareByTaskRef); + for (const fence of ownershipFences) { + if ( + fence.remoteRevision !== nextRevision || + fence.lastOperationId !== input.operationId + ) { + throw new Error('MANCODE_TRANSPORT_FENCE_REVISION_INVALID'); + } + } + for (const claim of claims) { + if ( + claim.workspaceId !== this.workspaceId || + claim.authority.mode !== 'git-ref' || + claim.coordinationDomainId !== coordinationDomainId || + claim.state !== 'active' || + claim.lastOperationId !== input.operationId || + claim.authority.remoteRevision !== String(nextRevision) + ) { + throw new Error('MANCODE_TRANSPORT_CLAIM_AUTHORITY_MISMATCH'); + } + } + for (const handoff of handoffs) { + if (handoff.transport.mode !== 'git-ref') { + throw new Error('MANCODE_TRANSPORT_HANDOFF_AUTHORITY_MISMATCH'); + } + } + const receipt = createMutationReceipt({ + kind: 'authority_establish', + operationId: input.operationId, + actorId: input.actorId, + taskRef: null, + remoteRevision: nextRevision, + ownershipEpoch: null, + actorProfiles, + ownershipFence: ownershipFences, + claims, + handoffs, + taskBundle: taskBundles, + committedAt: timestamp, + }); + const next = parseGitRefTeamManifest({ + schemaVersion: 1, + ...this.initialHeader, + transportEpoch: targetEpoch, + authorityState: 'active', + authorityFreeze: null, + authorityTombstone: null, + revision: nextRevision, + lastOperationId: input.operationId, + actorProfiles, + ownershipFences, + claims, + handoffs, + taskBundles, + receipts: appendReceipt(current.manifest?.receipts ?? [], receipt), + lastMutation: receipt, + updatedAt: timestamp, + }); + assertExpectedHeader(next, { + ...this.expectedHeader, + transportEpoch: targetEpoch, + }); + const result = await this.commitMutation(current, next); + return { ...result, transportEpoch: targetEpoch }; + } + + private async freezeAuthority( + current: GitRefTeamManifestSnapshot, + input: FreezeGitRefCoordinationAuthorityInput, + ): Promise<{ + receipt: string; + remoteRevision: number; + transportEpoch: number; + }> { + const manifest = requireAuthorityState( + current, + 'active', + input.expectedPriorTransportEpoch, + ); + assertExpectedHeader(manifest, this.expectedHeader); + assertAuthorityActorJoined(manifest, input.actorId); + const successorEpoch = parsePositiveInteger( + input.successorEpoch, + 'git-ref authority successorEpoch', + ); + if (successorEpoch <= manifest.transportEpoch) { + throw new Error('MANCODE_TRANSPORT_EPOCH_CONFLICT'); + } + const nextRevision = manifest.revision + 1; + const timestamp = this.now().toISOString(); + const receipt = authoritySnapshotReceipt( + manifest, + 'authority_freeze', + input.operationId, + input.actorId, + nextRevision, + timestamp, + ); + const next = parseGitRefTeamManifest({ + ...manifest, + authorityState: 'frozen', + authorityFreeze: { + schemaVersion: 1, + successorMode: input.successorMode, + successorEpoch, + operationId: input.operationId, + frozenAt: timestamp, + }, + authorityTombstone: null, + revision: nextRevision, + lastOperationId: input.operationId, + receipts: appendReceipt(manifest.receipts, receipt), + lastMutation: receipt, + updatedAt: timestamp, + }); + const result = await this.commitMutation(current, next); + return { ...result, transportEpoch: next.transportEpoch }; + } + + private async unfreezeAuthority( + current: GitRefTeamManifestSnapshot, + input: UnfreezeGitRefCoordinationAuthorityInput, + ): Promise<{ + receipt: string; + remoteRevision: number; + transportEpoch: number; + }> { + const freezeOperationId = input.freezeOperationId ?? input.operationId; + assertUlid(freezeOperationId, 'git-ref authority freezeOperationId'); + const manifest = requireAuthorityState( + current, + 'frozen', + input.expectedPriorTransportEpoch, + ); + assertExpectedHeader(manifest, this.expectedHeader); + assertAuthorityActorJoined(manifest, input.actorId); + if (manifest.authorityFreeze?.operationId !== freezeOperationId) { + throw new Error('MANCODE_TRANSPORT_FREEZE_OPERATION_MISMATCH'); + } + const nextRevision = manifest.revision + 1; + const timestamp = this.now().toISOString(); + const receipt = authoritySnapshotReceipt( + manifest, + 'authority_unfreeze', + input.operationId, + input.actorId, + nextRevision, + timestamp, + ); + const next = parseGitRefTeamManifest({ + ...manifest, + authorityState: 'active', + authorityFreeze: null, + authorityTombstone: null, + revision: nextRevision, + lastOperationId: input.operationId, + receipts: appendReceipt(manifest.receipts, receipt), + lastMutation: receipt, + updatedAt: timestamp, + }); + const result = await this.commitMutation(current, next); + return { ...result, transportEpoch: next.transportEpoch }; + } + + private async tombstoneAuthority( + current: GitRefTeamManifestSnapshot, + input: TombstoneGitRefCoordinationAuthorityInput, + ): Promise<{ + receipt: string; + remoteRevision: number; + transportEpoch: number; + }> { + const manifest = requireAuthorityState( + current, + 'frozen', + input.expectedPriorTransportEpoch, + ); + assertExpectedHeader(manifest, this.expectedHeader); + assertAuthorityActorJoined(manifest, input.actorId); + const successorEpoch = parsePositiveInteger( + input.successorEpoch, + 'git-ref authority successorEpoch', + ); + if ( + successorEpoch <= manifest.transportEpoch || + manifest.authorityFreeze?.operationId !== input.operationId || + manifest.authorityFreeze.successorMode !== input.successorMode || + manifest.authorityFreeze.successorEpoch !== successorEpoch + ) { + throw new Error('MANCODE_TRANSPORT_EPOCH_CONFLICT'); + } + const nextRevision = manifest.revision + 1; + const timestamp = this.now().toISOString(); + const receipt = authoritySnapshotReceipt( + manifest, + 'authority_tombstone', + input.operationId, + input.actorId, + nextRevision, + timestamp, + ); + const next = parseGitRefTeamManifest({ + ...manifest, + authorityState: 'tombstoned', + authorityTombstone: { + schemaVersion: 1, + successorMode: input.successorMode, + successorEpoch, + operationId: input.operationId, + tombstonedAt: timestamp, + }, + revision: nextRevision, + lastOperationId: input.operationId, + receipts: appendReceipt(manifest.receipts, receipt), + lastMutation: receipt, + updatedAt: timestamp, + }); + const result = await this.commitMutation(current, next); + return { ...result, transportEpoch: next.transportEpoch }; + } + + private remoteIdentityHash(): Promise { + this.remoteIdentityHashPromise ??= resolveGitRefRemoteIdentityHash( + this.projectRoot, + this.remote, + ); + return this.remoteIdentityHashPromise; + } + + private async commitMutation( + current: GitRefTeamManifestSnapshot, + next: GitRefTeamManifestV1, + ): Promise<{ receipt: string; remoteRevision: number }> { + const commit = await writeManifestCommit( + this.projectRoot, + current.commit, + next, + this.now(), + ); + await pushWithLease(this.projectRoot, this.remote, current.commit, commit); + return { receipt: receiptFor(commit, next), remoteRevision: next.revision }; + } +} + +function committedAuthorityMutation( + snapshot: GitRefTeamManifestSnapshot, + input: MutateGitRefCoordinationAuthorityInput, +): { receipt: string; remoteRevision: number; transportEpoch: number } | null { + const manifest = snapshot.manifest; + if (manifest === null) return null; + const lastMutation = manifest.lastMutation; + if ( + lastMutation === null || + snapshot.receipt === null || + lastMutation.operationId !== input.operationId || + lastMutation.actorId !== input.actorId + ) { + return null; + } + const committed = (() => { + switch (input.action) { + case 'establish': + return ( + manifest.authorityState === 'active' && + manifest.transportEpoch === input.targetTransportEpoch && + lastMutation.kind === 'authority_establish' + ); + case 'freeze': + return ( + manifest.authorityState === 'frozen' && + lastMutation.kind === 'authority_freeze' && + manifest.authorityFreeze?.successorMode === input.successorMode && + manifest.authorityFreeze.successorEpoch === input.successorEpoch + ); + case 'unfreeze': + return ( + manifest.authorityState === 'active' && + manifest.transportEpoch === input.expectedPriorTransportEpoch && + lastMutation.kind === 'authority_unfreeze' + ); + case 'tombstone': + return ( + manifest.authorityState === 'tombstoned' && + lastMutation.kind === 'authority_tombstone' && + manifest.authorityTombstone?.successorMode === input.successorMode && + manifest.authorityTombstone.successorEpoch === input.successorEpoch + ); + } + })(); + return committed + ? { + receipt: snapshot.receipt, + remoteRevision: manifest.revision, + transportEpoch: manifest.transportEpoch, + } + : null; +} + +function requireAuthorityState( + current: GitRefTeamManifestSnapshot, + state: GitRefTeamManifestV1['authorityState'], + expectedTransportEpoch: number, +): GitRefTeamManifestV1 { + const manifest = current.manifest; + if ( + manifest === null || + manifest.authorityState !== state || + manifest.transportEpoch !== expectedTransportEpoch + ) { + throw new Error('MANCODE_TRANSPORT_AUTHORITY_STATE_CONFLICT'); + } + return manifest; +} + +function assertAuthorityActorJoined( + manifest: GitRefTeamManifestV1, + actorId: Ulid, +): void { + if (!manifest.actorProfiles.some((profile) => profile.actorId === actorId)) { + throw new Error('MANCODE_TRANSPORT_ACTOR_NOT_JOINED'); + } +} + +function authoritySnapshotReceipt( + manifest: GitRefTeamManifestV1, + kind: Extract< + GitRefRemoteMutationReceiptV1['kind'], + 'authority_freeze' | 'authority_unfreeze' | 'authority_tombstone' + >, + operationId: Ulid, + actorId: Ulid, + remoteRevision: number, + committedAt: string, +): GitRefRemoteMutationReceiptV1 { + return createMutationReceipt({ + kind, + operationId, + actorId, + taskRef: null, + remoteRevision, + ownershipEpoch: null, + actorProfiles: manifest.actorProfiles, + ownershipFence: manifest.ownershipFences, + claims: manifest.claims, + handoffs: manifest.handoffs, + taskBundle: manifest.taskBundles, + committedAt, + }); +} + +export function parseGitRefTeamManifest(value: unknown): GitRefTeamManifestV1 { + assertManifestSize(value); + assertRecord(value, 'git-ref team manifest'); + const legacyProfileManifest = isLegacyProfileManifest(value); + const normalized = legacyProfileManifest + ? normalizeLegacyProfileManifest(value) + : value; + assertKnownKeys( + normalized, + [ + 'schemaVersion', + 'workspaceId', + 'schemaEpoch', + 'minReaderVersion', + 'minWriterVersion', + 'transportEpoch', + 'configRevision', + 'configDigest', + 'authorityState', + 'authorityFreeze', + 'authorityTombstone', + 'revision', + 'lastOperationId', + 'actorProfiles', + 'ownershipFences', + 'claims', + 'handoffs', + 'taskBundles', + 'receipts', + 'lastMutation', + 'updatedAt', + ], + 'git-ref team manifest', + ); + if (normalized.schemaVersion !== 1) { + throw new Error('git-ref team manifest schemaVersion is invalid'); + } + assertUlid(normalized.workspaceId, 'git-ref team manifest workspaceId'); + assertUlid(normalized.schemaEpoch, 'git-ref team manifest schemaEpoch'); + assertUlid( + normalized.lastOperationId, + 'git-ref team manifest lastOperationId', + ); + const revision = parsePositiveInteger( + normalized.revision, + 'git-ref team manifest revision', + ); + const manifest: GitRefTeamManifestV1 = { + schemaVersion: 1, + workspaceId: normalized.workspaceId, + schemaEpoch: normalized.schemaEpoch, + minReaderVersion: parseVersion( + normalized.minReaderVersion, + 'git-ref team manifest minReaderVersion', + ), + minWriterVersion: parseVersion( + normalized.minWriterVersion, + 'git-ref team manifest minWriterVersion', + ), + transportEpoch: parsePositiveInteger( + normalized.transportEpoch, + 'git-ref team manifest transportEpoch', + ), + configRevision: parsePositiveInteger( + normalized.configRevision, + 'git-ref team manifest configRevision', + ), + configDigest: parseDigest( + normalized.configDigest, + 'git-ref team manifest configDigest', + ), + authorityState: parseAuthorityState(normalized.authorityState), + authorityFreeze: + normalized.authorityFreeze === null + ? null + : parseGitRefAuthorityFreeze(normalized.authorityFreeze), + authorityTombstone: + normalized.authorityTombstone === null + ? null + : parseGitRefAuthorityTombstone(normalized.authorityTombstone), + revision, + lastOperationId: normalized.lastOperationId, + actorProfiles: parseBoundedArray( + normalized.actorProfiles, + MAX_ACTOR_PROFILES, + 'git-ref team manifest actorProfiles', + parseSharedActorProfile, + ).sort(compareActorProfiles), + ownershipFences: parseBoundedArray( + normalized.ownershipFences, + MAX_OWNERSHIP_FENCES, + 'git-ref team manifest ownershipFences', + parseGitRefOwnershipFence, + ).sort(compareByTaskRef), + claims: parseBoundedArray( + normalized.claims, + MAX_CLAIMS, + 'git-ref team manifest claims', + parseClaim, + ).sort(compareClaims), + handoffs: parseBoundedArray( + normalized.handoffs, + MAX_HANDOFFS, + 'git-ref team manifest handoffs', + parseHandoff, + ).sort(compareHandoffs), + taskBundles: parseBoundedArray( + normalized.taskBundles, + MAX_TASK_BUNDLES, + 'git-ref team manifest taskBundles', + parseGitRefTaskBundle, + ).sort(compareByTaskRef), + receipts: parseBoundedArray( + normalized.receipts, + MAX_RECEIPTS, + 'git-ref team manifest receipts', + parseGitRefRemoteMutationReceipt, + ).sort(compareReceipts), + lastMutation: + normalized.lastMutation === null + ? null + : parseGitRefRemoteMutationReceipt(normalized.lastMutation), + updatedAt: parseTimestamp( + normalized.updatedAt, + 'git-ref team manifest updatedAt', + ), + }; + assertManifestUniqueness(manifest); + assertManifestCrossEntityConsistency(manifest, legacyProfileManifest); + return manifest; +} + +export function parseGitRefOwnershipFence( + value: unknown, +): GitRefOwnershipFenceV1 { + assertRecord(value, 'git-ref ownership fence'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'taskRef', + 'ownerActorId', + 'ownershipEpoch', + 'taskRevision', + 'aggregateDigest', + 'remoteRevision', + 'lastOperationId', + 'updatedAt', + ], + 'git-ref ownership fence', + ); + if (value.schemaVersion !== 1) { + throw new Error('git-ref ownership fence schemaVersion must be 1'); + } + assertUlid(value.ownerActorId, 'git-ref ownership fence ownerActorId'); + assertUlid(value.lastOperationId, 'git-ref ownership fence lastOperationId'); + return { + schemaVersion: 1, + taskRef: parseSharedTaskRef( + value.taskRef, + 'git-ref ownership fence taskRef', + ), + ownerActorId: value.ownerActorId, + ownershipEpoch: parseNonNegativeInteger( + value.ownershipEpoch, + 'git-ref ownership fence ownershipEpoch', + ), + taskRevision: parsePositiveInteger( + value.taskRevision, + 'git-ref ownership fence taskRevision', + ), + aggregateDigest: parseDigest( + value.aggregateDigest, + 'git-ref ownership fence aggregateDigest', + ), + remoteRevision: parsePositiveInteger( + value.remoteRevision, + 'git-ref ownership fence remoteRevision', + ), + lastOperationId: value.lastOperationId, + updatedAt: parseTimestamp( + value.updatedAt, + 'git-ref ownership fence updatedAt', + ), + }; +} + +export function gitRefTaskBundleDigest( + bundle: Omit, +): string { + return digestCanonicalJson(bundle); +} + +export function parseGitRefTaskBundle(value: unknown): GitRefTaskBundleV1 { + assertRecord(value, 'git-ref task bundle'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'taskRef', + 'taskRevision', + 'ownershipEpoch', + 'aggregate', + 'aggregateDigest', + 'codeRef', + 'artifacts', + 'bundleDigest', + 'createdAt', + ], + 'git-ref task bundle', + ); + if (value.schemaVersion !== 1) { + throw new Error('git-ref task bundle schemaVersion must be 1'); + } + const taskRef = parseSharedTaskRef( + value.taskRef, + 'git-ref task bundle taskRef', + ); + const aggregate = parseTaskAggregateManifest(value.aggregate); + const artifacts = parseBoundedArray( + value.artifacts, + MAX_BUNDLE_ARTIFACTS, + 'git-ref task bundle artifacts', + parseGitRefTaskBundleArtifact, + ); + const bundle: GitRefTaskBundleV1 = { + schemaVersion: 1, + taskRef, + taskRevision: parsePositiveInteger( + value.taskRevision, + 'git-ref task bundle taskRevision', + ), + ownershipEpoch: parseNonNegativeInteger( + value.ownershipEpoch, + 'git-ref task bundle ownershipEpoch', + ), + aggregate, + aggregateDigest: parseDigest( + value.aggregateDigest, + 'git-ref task bundle aggregateDigest', + ), + codeRef: parseBundleCodeRef(value.codeRef), + artifacts: artifacts.sort(compareBundleArtifacts), + bundleDigest: parseDigest( + value.bundleDigest, + 'git-ref task bundle bundleDigest', + ), + createdAt: parseTimestamp(value.createdAt, 'git-ref task bundle createdAt'), + }; + assertTaskBundleConsistency(bundle); + const { bundleDigest: _digest, ...body } = bundle; + if (bundle.bundleDigest !== gitRefTaskBundleDigest(body)) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_DIGEST_MISMATCH'); + } + return bundle; +} + +export function parseGitRefRemoteMutationReceipt( + value: unknown, +): GitRefRemoteMutationReceiptV1 { + assertRecord(value, 'git-ref mutation receipt'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'kind', + 'operationId', + 'actorId', + 'taskRef', + 'remoteRevision', + 'ownershipEpoch', + 'entityDigests', + 'committedAt', + ], + 'git-ref mutation receipt', + ); + if (value.schemaVersion !== 1) { + throw new Error('git-ref mutation receipt schemaVersion must be 1'); + } + if ( + value.kind !== 'actor_profile' && + value.kind !== 'coordination' && + value.kind !== 'authority_establish' && + value.kind !== 'authority_freeze' && + value.kind !== 'authority_unfreeze' && + value.kind !== 'authority_tombstone' + ) { + throw new Error('git-ref mutation receipt kind is invalid'); + } + assertUlid(value.operationId, 'git-ref mutation receipt operationId'); + assertUlid(value.actorId, 'git-ref mutation receipt actorId'); + const taskRef = + value.taskRef === null + ? null + : parseSharedTaskRef(value.taskRef, 'git-ref mutation receipt taskRef'); + const ownershipEpoch = + value.ownershipEpoch === null + ? null + : parseNonNegativeInteger( + value.ownershipEpoch, + 'git-ref mutation receipt ownershipEpoch', + ); + if ( + (value.kind !== 'coordination' && + (taskRef !== null || ownershipEpoch !== null)) || + (value.kind === 'coordination' && + (taskRef === null || ownershipEpoch === null)) + ) { + throw new Error( + 'git-ref mutation receipt kind does not match task fencing', + ); + } + return { + schemaVersion: 1, + kind: value.kind, + operationId: value.operationId, + actorId: value.actorId, + taskRef, + remoteRevision: parsePositiveInteger( + value.remoteRevision, + 'git-ref mutation receipt remoteRevision', + ), + ownershipEpoch, + entityDigests: parseReceiptEntityDigests(value.entityDigests), + committedAt: parseTimestamp( + value.committedAt, + 'git-ref mutation receipt committedAt', + ), + }; +} + +export function parseGitRefAuthorityTombstone( + value: unknown, +): GitRefAuthorityTombstoneV1 { + assertRecord(value, 'git-ref authority tombstone'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'successorMode', + 'successorEpoch', + 'operationId', + 'tombstonedAt', + ], + 'git-ref authority tombstone', + ); + if (value.schemaVersion !== 1) { + throw new Error('git-ref authority tombstone schemaVersion must be 1'); + } + if (value.successorMode !== 'local' && value.successorMode !== 'git-ref') { + throw new Error('git-ref authority tombstone successorMode is invalid'); + } + assertUlid(value.operationId, 'git-ref authority tombstone operationId'); + return { + schemaVersion: 1, + successorMode: value.successorMode, + successorEpoch: parsePositiveInteger( + value.successorEpoch, + 'git-ref authority tombstone successorEpoch', + ), + operationId: value.operationId, + tombstonedAt: parseTimestamp( + value.tombstonedAt, + 'git-ref authority tombstone tombstonedAt', + ), + }; +} + +export function parseGitRefAuthorityFreeze( + value: unknown, +): GitRefAuthorityFreezeV1 { + assertRecord(value, 'git-ref authority freeze'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'successorMode', + 'successorEpoch', + 'operationId', + 'frozenAt', + ], + 'git-ref authority freeze', + ); + if (value.schemaVersion !== 1) { + throw new Error('git-ref authority freeze schemaVersion must be 1'); + } + if (value.successorMode !== 'local' && value.successorMode !== 'git-ref') { + throw new Error('git-ref authority freeze successorMode is invalid'); + } + assertUlid(value.operationId, 'git-ref authority freeze operationId'); + return { + schemaVersion: 1, + successorMode: value.successorMode, + successorEpoch: parsePositiveInteger( + value.successorEpoch, + 'git-ref authority freeze successorEpoch', + ), + operationId: value.operationId, + frozenAt: parseTimestamp( + value.frozenAt, + 'git-ref authority freeze frozenAt', + ), + }; +} + +function assertCoordinationMutation(input: { + operationId: Ulid; + actorId: Ulid; + taskRef: TaskRef; + previousFence: GitRefOwnershipFenceV1 | undefined; + previousClaims: ClaimV1[]; + previousHandoffs: HandoffV1[]; + previousTaskBundle: GitRefTaskBundleV1 | undefined; + expectedOwnershipEpoch: number; + nextRevision: number; + fence: GitRefOwnershipFenceV1; + claims: ClaimV1[]; + handoffs: HandoffV1[]; + taskBundle: GitRefTaskBundleV1 | null; +}): void { + const { + operationId, + actorId, + taskRef, + previousFence, + previousClaims, + previousHandoffs, + previousTaskBundle, + expectedOwnershipEpoch, + nextRevision, + fence, + claims, + handoffs, + taskBundle, + } = input; + if (!sameTaskRef(fence.taskRef, taskRef)) { + throw new Error('MANCODE_TRANSPORT_TASK_MISMATCH'); + } + if ( + fence.remoteRevision !== nextRevision || + fence.lastOperationId !== operationId + ) { + throw new Error('MANCODE_TRANSPORT_FENCE_REVISION_INVALID'); + } + const changes = assertEntityReplacementTransitions({ + operationId, + actorId, + nextRevision, + previousClaims, + claims, + previousHandoffs, + handoffs, + }); + if (previousTaskBundle !== undefined && taskBundle === null) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_REMOVAL_FORBIDDEN'); + } + const metadata = + taskBundle === null ? null : metadataFromTaskBundle(taskBundle); + if ( + metadata !== null && + (metadata.ownerActorId !== fence.ownerActorId || + metadata.ownershipEpoch !== fence.ownershipEpoch) + ) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_FENCE_MISMATCH'); + } + if (previousFence === undefined) { + if ( + expectedOwnershipEpoch !== 0 || + fence.ownershipEpoch !== 0 || + fence.ownerActorId !== actorId + ) { + throw new Error('MANCODE_TRANSPORT_OWNERSHIP_FENCE_BOOTSTRAP_INVALID'); + } + } else { + if (fence.taskRevision < previousFence.taskRevision) { + throw new Error('MANCODE_TRANSPORT_TASK_REVISION_REGRESSION'); + } + if (fence.ownerActorId === previousFence.ownerActorId) { + if (fence.ownershipEpoch !== previousFence.ownershipEpoch) { + throw new Error('MANCODE_TRANSPORT_OWNER_MISMATCH'); + } + assertSameOwnerMutationAuthorized({ + actorId, + taskOwnerActorId: previousFence.ownerActorId, + previousFence, + fence, + previousTaskBundle, + taskBundle, + metadata, + changes, + previousClaims, + claims, + }); + } else { + const acceptedHandoff = handoffs.find( + (handoff) => + handoff.state === 'accepted' && + handoff.fromActorId === previousFence.ownerActorId && + handoff.toActorId === fence.ownerActorId && + handoff.ownershipEpochAtOffer === previousFence.ownershipEpoch && + handoff.lastOperationId === operationId && + handoff.resolution?.actorId === actorId, + ); + if ( + actorId !== fence.ownerActorId || + fence.ownershipEpoch !== previousFence.ownershipEpoch + 1 || + acceptedHandoff === undefined + ) { + throw new Error('MANCODE_TRANSPORT_OWNERSHIP_TRANSFER_INVALID'); + } + assertOwnershipTransferEntities({ + actorId, + acceptedHandoff, + changes, + previousClaims, + claims, + previousHandoffs, + }); + } + } + for (const claim of claims) { + if ( + claim.lastOperationId === operationId && + (claim.authority.mode !== 'git-ref' || + claim.authority.remoteRevision !== String(nextRevision)) + ) { + throw new Error('MANCODE_TRANSPORT_CLAIM_REVISION_INVALID'); + } + } + for (const handoff of handoffs) { + if ( + handoff.lastOperationId === operationId && + (handoff.transport.mode !== 'git-ref' || + handoff.transport.state !== 'published' || + handoff.transport.transportRevision !== nextRevision || + handoff.transport.receipt !== + `git-ref-revision:${nextRevision}:${operationId}`) + ) { + throw new Error('MANCODE_TRANSPORT_HANDOFF_REVISION_INVALID'); + } + } + if (taskBundle !== null) { + if ( + !sameTaskRef(taskBundle.taskRef, taskRef) || + taskBundle.taskRevision !== fence.taskRevision || + taskBundle.ownershipEpoch !== fence.ownershipEpoch || + taskBundle.aggregateDigest !== fence.aggregateDigest + ) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_FENCE_MISMATCH'); + } + } + const offered = handoffs.find((handoff) => handoff.state === 'offered'); + if ( + offered !== undefined && + (taskBundle === null || + offered.transport.taskBundleDigest !== taskBundle.bundleDigest) + ) { + throw new Error('MANCODE_TRANSPORT_HANDOFF_BUNDLE_MISMATCH'); + } +} + +interface MutationEntityChanges { + claims: Array<{ previous: ClaimV1 | null; next: ClaimV1 }>; + handoffs: Array<{ previous: HandoffV1 | null; next: HandoffV1 }>; +} + +function assertEntityReplacementTransitions(input: { + operationId: Ulid; + actorId: Ulid; + nextRevision: number; + previousClaims: ClaimV1[]; + claims: ClaimV1[]; + previousHandoffs: HandoffV1[]; + handoffs: HandoffV1[]; +}): MutationEntityChanges { + const previousClaims = new Map( + input.previousClaims.map((claim) => [claim.claimId, claim]), + ); + const claims = new Map(input.claims.map((claim) => [claim.claimId, claim])); + const previousHandoffs = new Map( + input.previousHandoffs.map((handoff) => [handoff.handoffId, handoff]), + ); + const handoffs = new Map( + input.handoffs.map((handoff) => [handoff.handoffId, handoff]), + ); + for (const claimId of previousClaims.keys()) { + if (!claims.has(claimId)) { + throw new Error('MANCODE_TRANSPORT_CLAIM_REMOVAL_FORBIDDEN'); + } + } + for (const handoffId of previousHandoffs.keys()) { + if (!handoffs.has(handoffId)) { + throw new Error('MANCODE_TRANSPORT_HANDOFF_REMOVAL_FORBIDDEN'); + } + } + const claimChanges: MutationEntityChanges['claims'] = []; + for (const claim of input.claims) { + const previous = previousClaims.get(claim.claimId) ?? null; + if ( + previous !== null && + digestCanonicalJson(previous) === digestCanonicalJson(claim) + ) { + continue; + } + if ( + claim.lastOperationId !== input.operationId || + claim.authority.mode !== 'git-ref' || + claim.authority.remoteRevision !== String(input.nextRevision) + ) { + throw new Error('MANCODE_TRANSPORT_CLAIM_REVISION_INVALID'); + } + if (previous === null) { + if (claim.revision !== 1) { + throw new Error('MANCODE_TRANSPORT_CLAIM_TRANSITION_INVALID'); + } + } else { + assertClaimTransition(previous, claim); + } + claimChanges.push({ previous, next: claim }); + } + const handoffChanges: MutationEntityChanges['handoffs'] = []; + for (const handoff of input.handoffs) { + const previous = previousHandoffs.get(handoff.handoffId) ?? null; + if ( + previous !== null && + digestCanonicalJson(previous) === digestCanonicalJson(handoff) + ) { + continue; + } + if ( + handoff.lastOperationId !== input.operationId || + handoff.transport.mode !== 'git-ref' || + handoff.transport.state !== 'published' || + handoff.transport.transportRevision !== input.nextRevision || + handoff.transport.receipt !== + `git-ref-revision:${input.nextRevision}:${input.operationId}` + ) { + throw new Error('MANCODE_TRANSPORT_HANDOFF_REVISION_INVALID'); + } + if (previous === null) { + if (handoff.revision !== 1) { + throw new Error('MANCODE_TRANSPORT_HANDOFF_TRANSITION_INVALID'); + } + } else { + assertHandoffTransition(previous, handoff, input.actorId); + } + handoffChanges.push({ previous, next: handoff }); + } + return { claims: claimChanges, handoffs: handoffChanges }; +} + +function assertSameOwnerMutationAuthorized(input: { + actorId: Ulid; + taskOwnerActorId: Ulid; + previousFence: GitRefOwnershipFenceV1; + fence: GitRefOwnershipFenceV1; + previousTaskBundle: GitRefTaskBundleV1 | undefined; + taskBundle: GitRefTaskBundleV1 | null; + metadata: WorkflowMetadataV3 | null; + changes: MutationEntityChanges; + previousClaims: ClaimV1[]; + claims: ClaimV1[]; +}): void { + if (input.actorId === input.taskOwnerActorId) return; + if ( + input.metadata === null || + input.previousTaskBundle === undefined || + input.taskBundle === null || + input.previousTaskBundle.bundleDigest !== input.taskBundle.bundleDigest || + input.previousFence.taskRevision !== input.fence.taskRevision || + input.previousFence.aggregateDigest !== input.fence.aggregateDigest || + !input.metadata.participants.includes(input.actorId) || + (input.changes.claims.length === 0 && input.changes.handoffs.length === 0) + ) { + throw new Error('MANCODE_TRANSPORT_ACTOR_NOT_AUTHORIZED'); + } + const previousClaims = new Map( + input.previousClaims.map((claim) => [claim.claimId, claim]), + ); + const claims = new Map(input.claims.map((claim) => [claim.claimId, claim])); + for (const change of input.changes.claims) { + if (change.previous !== null) { + if (change.previous.ownerActorId !== input.actorId) { + throw new Error('MANCODE_TRANSPORT_ACTOR_NOT_AUTHORIZED'); + } + continue; + } + if (change.next.ownerActorId === input.actorId) continue; + const predecessorId = change.next.predecessorClaimId; + const previous = + predecessorId === null ? undefined : previousClaims.get(predecessorId); + const transferred = + predecessorId === null ? undefined : claims.get(predecessorId); + if ( + previous === undefined || + previous.ownerActorId !== input.actorId || + transferred?.state !== 'transferred' || + transferred.successorClaimId !== change.next.claimId || + !input.metadata.participants.includes(change.next.ownerActorId) + ) { + throw new Error('MANCODE_TRANSPORT_ACTOR_NOT_AUTHORIZED'); + } + } + for (const change of input.changes.handoffs) { + if ( + change.previous === null || + change.previous.toActorId !== input.actorId || + change.next.state !== 'rejected' + ) { + throw new Error('MANCODE_TRANSPORT_ACTOR_NOT_AUTHORIZED'); + } + } +} + +function assertOwnershipTransferEntities(input: { + actorId: Ulid; + acceptedHandoff: HandoffV1; + changes: MutationEntityChanges; + previousClaims: ClaimV1[]; + claims: ClaimV1[]; + previousHandoffs: HandoffV1[]; +}): void { + if ( + input.changes.handoffs.length !== 1 || + input.changes.handoffs[0]?.previous?.handoffId !== + input.acceptedHandoff.handoffId || + input.changes.handoffs[0]?.next.state !== 'accepted' + ) { + throw new Error('MANCODE_TRANSPORT_OWNERSHIP_TRANSFER_INVALID'); + } + const acceptedClaimIds = new Set(input.acceptedHandoff.claimIds); + const nextClaims = new Map( + input.claims.map((claim) => [claim.claimId, claim]), + ); + for (const change of input.changes.claims) { + if (change.previous !== null) { + if ( + !acceptedClaimIds.has(change.previous.claimId) || + change.next.state !== 'transferred' || + change.next.successorClaimId === null + ) { + throw new Error('MANCODE_TRANSPORT_OWNERSHIP_TRANSFER_INVALID'); + } + continue; + } + const predecessorId = change.next.predecessorClaimId; + const predecessor = + predecessorId === null + ? undefined + : input.previousClaims.find((claim) => claim.claimId === predecessorId); + const transferred = + predecessorId === null ? undefined : nextClaims.get(predecessorId); + if ( + predecessor === undefined || + !acceptedClaimIds.has(predecessor.claimId) || + transferred?.successorClaimId !== change.next.claimId || + change.next.ownerActorId !== input.actorId + ) { + throw new Error('MANCODE_TRANSPORT_OWNERSHIP_TRANSFER_INVALID'); + } + } + if ( + input.previousHandoffs.every( + (handoff) => handoff.handoffId !== input.acceptedHandoff.handoffId, + ) + ) { + throw new Error('MANCODE_TRANSPORT_OWNERSHIP_TRANSFER_INVALID'); + } +} + +function metadataFromTaskBundle( + bundle: GitRefTaskBundleV1, +): WorkflowMetadataV3 { + const artifact = bundle.artifacts.find( + (candidate) => candidate.kind === 'metadata', + ); + if (artifact === undefined) { + throw new Error('git-ref task bundle requires metadata'); + } + return parseWorkflowMetadata(artifact.content); +} + +function parseReplacementClaims( + value: unknown, + taskRef: TaskRef, + workspaceId: Ulid, + coordinationDomainId: string, +): ClaimV1[] { + const claims = parseBoundedArray( + value, + MAX_CLAIMS, + 'git-ref coordination claims', + parseClaim, + ); + assertUnique(claims, (claim) => claim.claimId, 'git-ref coordination claims'); + for (const claim of claims) { + if ( + !sameTaskRef(claim.taskRef, taskRef) || + claim.workspaceId !== workspaceId || + claim.authority.mode !== 'git-ref' || + claim.coordinationDomainId !== coordinationDomainId + ) { + throw new Error('MANCODE_TRANSPORT_CLAIM_AUTHORITY_MISMATCH'); + } + } + return claims.sort(compareClaims); +} + +function assertManifestCoordinationDomain( + manifest: GitRefTeamManifestV1, + coordinationDomainId: string, +): void { + if ( + manifest.claims.some( + (claim) => claim.coordinationDomainId !== coordinationDomainId, + ) + ) { + throw new Error('MANCODE_COORDINATION_DOMAIN_MISMATCH'); + } +} + +function parseReplacementHandoffs( + value: unknown, + taskRef: TaskRef, +): HandoffV1[] { + const handoffs = parseBoundedArray( + value, + MAX_HANDOFFS, + 'git-ref coordination handoffs', + parseHandoff, + ); + assertUnique( + handoffs, + (handoff) => handoff.handoffId, + 'git-ref coordination handoffs', + ); + for (const handoff of handoffs) { + if (!sameTaskRef(handoff.taskRef, taskRef)) { + throw new Error('MANCODE_TRANSPORT_HANDOFF_TASK_MISMATCH'); + } + } + return handoffs.sort(compareHandoffs); +} + +function assertManifestUniqueness(manifest: GitRefTeamManifestV1): void { + assertUnique( + manifest.actorProfiles, + (profile) => profile.actorId, + 'git-ref team manifest actorProfiles', + ); + assertUnique( + manifest.ownershipFences, + (fence) => taskKey(fence.taskRef), + 'git-ref team manifest ownershipFences', + ); + assertUnique( + manifest.claims, + (claim) => claim.claimId, + 'git-ref team manifest claims', + ); + assertUnique( + manifest.handoffs, + (handoff) => handoff.handoffId, + 'git-ref team manifest handoffs', + ); + assertUnique( + manifest.taskBundles, + (bundle) => taskKey(bundle.taskRef), + 'git-ref team manifest taskBundles', + ); + assertUnique( + manifest.receipts, + (receipt) => String(receipt.remoteRevision), + 'git-ref team manifest receipts', + ); +} + +function assertManifestCrossEntityConsistency( + manifest: GitRefTeamManifestV1, + allowLegacyNoReceipt: boolean, +): void { + if ( + (manifest.authorityState === 'active' && + (manifest.authorityFreeze !== null || + manifest.authorityTombstone !== null)) || + (manifest.authorityState === 'frozen' && + (manifest.authorityFreeze === null || + manifest.authorityTombstone !== null || + manifest.authorityFreeze.successorEpoch <= manifest.transportEpoch || + manifest.authorityFreeze.operationId !== manifest.lastOperationId)) || + (manifest.authorityState === 'tombstoned' && + (manifest.authorityFreeze === null || + manifest.authorityTombstone === null || + manifest.authorityFreeze.successorMode !== + manifest.authorityTombstone.successorMode || + manifest.authorityFreeze.successorEpoch !== + manifest.authorityTombstone.successorEpoch || + manifest.authorityFreeze.operationId !== + manifest.authorityTombstone.operationId || + manifest.authorityTombstone.successorEpoch <= manifest.transportEpoch || + manifest.authorityTombstone.operationId !== manifest.lastOperationId)) + ) { + throw new Error('git-ref manifest authority state is inconsistent'); + } + for (const fence of manifest.ownershipFences) { + if (fence.remoteRevision > manifest.revision) { + throw new Error( + 'git-ref ownership fence remoteRevision is in the future', + ); + } + } + for (const claim of manifest.claims) { + if ( + claim.workspaceId !== manifest.workspaceId || + claim.authority.mode !== 'git-ref' + ) { + throw new Error('git-ref manifest claim authority is invalid'); + } + const revision = parseRemoteEntityRevision(claim.authority.remoteRevision); + if (revision !== null && revision > manifest.revision) { + throw new Error('git-ref manifest claim remoteRevision is in the future'); + } + } + for (const handoff of manifest.handoffs) { + if (handoff.transport.mode !== 'git-ref') { + throw new Error('git-ref manifest handoff transport is invalid'); + } + if ( + handoff.transport.transportRevision !== null && + handoff.transport.transportRevision > manifest.revision + ) { + throw new Error( + 'git-ref manifest handoff transportRevision is in the future', + ); + } + if (handoff.state === 'offered') { + const bundle = manifest.taskBundles.find((candidate) => + sameTaskRef(candidate.taskRef, handoff.taskRef), + ); + if ( + bundle === undefined || + handoff.transport.taskBundleDigest !== bundle.bundleDigest + ) { + throw new Error('MANCODE_TRANSPORT_HANDOFF_BUNDLE_MISMATCH'); + } + } + } + for (const bundle of manifest.taskBundles) { + const fence = manifest.ownershipFences.find((candidate) => + sameTaskRef(candidate.taskRef, bundle.taskRef), + ); + if ( + fence === undefined || + fence.taskRevision !== bundle.taskRevision || + fence.ownershipEpoch !== bundle.ownershipEpoch || + fence.aggregateDigest !== bundle.aggregateDigest + ) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_FENCE_MISMATCH'); + } + } + for (const receipt of manifest.receipts) { + if (receipt.remoteRevision > manifest.revision) { + throw new Error('git-ref mutation receipt revision is in the future'); + } + } + if (manifest.lastMutation === null) { + if (manifest.receipts.length > 0 || !allowLegacyNoReceipt) { + throw new Error('git-ref manifest receipts require lastMutation'); + } + return; + } + const latest = manifest.receipts.at(-1); + if ( + latest === undefined || + digestCanonicalJson(latest) !== + digestCanonicalJson(manifest.lastMutation) || + manifest.lastMutation.operationId !== manifest.lastOperationId || + manifest.lastMutation.remoteRevision !== manifest.revision + ) { + throw new Error('git-ref manifest lastMutation is inconsistent'); + } + assertLastMutationEntityDigests(manifest, manifest.lastMutation); +} + +function assertLastMutationEntityDigests( + manifest: GitRefTeamManifestV1, + receipt: GitRefRemoteMutationReceiptV1, +): void { + let ownershipFence: GitRefOwnershipFenceV1 | GitRefOwnershipFenceV1[] | null; + let claims: ClaimV1[]; + let handoffs: HandoffV1[]; + let taskBundle: GitRefTaskBundleV1 | GitRefTaskBundleV1[] | null; + if (receipt.kind === 'coordination') { + const taskRef = receipt.taskRef; + if (taskRef === null) { + throw new Error('git-ref coordination receipt requires taskRef'); + } + ownershipFence = + manifest.ownershipFences.find((value) => + sameTaskRef(value.taskRef, taskRef), + ) ?? null; + claims = manifest.claims.filter((value) => + sameTaskRef(value.taskRef, taskRef), + ); + handoffs = manifest.handoffs.filter((value) => + sameTaskRef(value.taskRef, taskRef), + ); + taskBundle = + manifest.taskBundles.find((value) => + sameTaskRef(value.taskRef, taskRef), + ) ?? null; + } else if ( + receipt.kind === 'authority_establish' || + receipt.kind === 'authority_freeze' || + receipt.kind === 'authority_unfreeze' || + receipt.kind === 'authority_tombstone' + ) { + ownershipFence = manifest.ownershipFences; + claims = manifest.claims; + handoffs = manifest.handoffs; + taskBundle = manifest.taskBundles; + } else { + ownershipFence = null; + claims = []; + handoffs = []; + taskBundle = null; + } + const expected = { + actorProfiles: digestCanonicalJson(manifest.actorProfiles), + ownershipFence: + ownershipFence === null ? null : digestCanonicalJson(ownershipFence), + claims: digestCanonicalJson(claims), + handoffs: digestCanonicalJson(handoffs), + taskBundle: taskBundle === null ? null : digestCanonicalJson(taskBundle), + }; + if ( + digestCanonicalJson(expected) !== digestCanonicalJson(receipt.entityDigests) + ) { + throw new Error('MANCODE_TRANSPORT_RECEIPT_DIGEST_MISMATCH'); + } +} + +function assertTaskBundleConsistency(bundle: GitRefTaskBundleV1): void { + if ( + !sameTaskRef(bundle.aggregate.taskRef, bundle.taskRef) || + bundle.aggregate.taskRevision !== bundle.taskRevision || + bundle.aggregate.ownershipEpoch !== bundle.ownershipEpoch || + bundle.aggregateDigest !== taskAggregateDigest(bundle.aggregate) + ) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_AGGREGATE_MISMATCH'); + } + assertUnique( + bundle.artifacts, + (artifact) => artifact.kind, + 'git-ref task bundle artifact kinds', + ); + assertUnique( + bundle.artifacts, + (artifact) => artifact.relativePath, + 'git-ref task bundle artifact paths', + ); + const byKind = new Map( + bundle.artifacts.map((artifact) => [artifact.kind, artifact]), + ); + for (const artifact of bundle.artifacts) { + assertBundleArtifactPath(artifact); + } + for (const required of [ + 'metadata', + 'requirements', + 'review', + 'verification', + ] as const) { + if (!byKind.has(required)) { + throw new Error(`git-ref task bundle requires ${required}`); + } + } + if (!byKind.has('plan') && !byKind.has('summary')) { + throw new Error('git-ref task bundle requires plan or summary'); + } + const metadata = parseWorkflowMetadata(byKind.get('metadata')?.content); + const requirements = parseRequirementsLedger( + byKind.get('requirements')?.content, + ); + const review = parseReviewLedger(byKind.get('review')?.content); + const verification = parseVerificationLedger( + byKind.get('verification')?.content, + ); + assertBundleEntityTask( + bundle, + metadata.taskRef, + metadata.revision, + 'metadata', + ); + assertBundleEntityTask(bundle, requirements.taskRef, null, 'requirements'); + assertBundleEntityTask(bundle, review.taskRef, null, 'review'); + assertBundleEntityTask(bundle, verification.taskRef, null, 'verification'); + if ( + metadata.ownershipEpoch !== bundle.ownershipEpoch || + workflowMetadataDigest(metadata) !== bundle.aggregate.metadataDigest || + requirementsLedgerDigest(requirements) !== + bundle.aggregate.requirementsDigest || + reviewLedgerDigest(review) !== bundle.aggregate.reviewDigest || + verificationLedgerDigest(verification) !== + bundle.aggregate.verificationDigest + ) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_ENTITY_DIGEST_MISMATCH'); + } + const checkpointArtifact = byKind.get('checkpoint'); + if (bundle.aggregate.latestCheckpointId === null) { + if (checkpointArtifact !== undefined) { + throw new Error( + 'git-ref task bundle must not include an unreferenced checkpoint', + ); + } + } else { + if (checkpointArtifact === undefined) { + throw new Error('git-ref task bundle requires checkpoint'); + } + const checkpoint = parseCheckpoint(checkpointArtifact.content); + if ( + checkpointArtifact.relativePath !== + `checkpoints/${checkpoint.checkpointId}.json` + ) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_ARTIFACT_PATH_MISMATCH'); + } + assertBundleEntityTask(bundle, checkpoint.taskRef, null, 'checkpoint'); + if ( + checkpoint.taskRevision > bundle.taskRevision || + checkpoint.ownershipEpochAtOffer > bundle.ownershipEpoch || + checkpointDigest(checkpoint) !== + bundle.aggregate.latestCheckpointDigest || + checkpoint.checkpointId !== bundle.aggregate.latestCheckpointId + ) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_ENTITY_DIGEST_MISMATCH'); + } + } + const plan = byKind.get('plan'); + if ( + bundle.aggregate.planDigest !== null && + (plan === undefined || + digestCanonicalJson({ + artifactRef: { taskRef: bundle.taskRef, kind: 'plan' }, + content: plan.content, + }) !== bundle.aggregate.planDigest) + ) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_PLAN_DIGEST_MISMATCH'); + } +} + +function assertBundleArtifactPath(artifact: GitRefTaskBundleArtifactV1): void { + const fixedPaths: Partial> = { + metadata: 'metadata.json', + requirements: 'requirements.json', + review: 'review-ledger.json', + verification: 'verification-ledger.json', + plan: 'plan.md', + summary: 'summary.md', + }; + const fixedPath = fixedPaths[artifact.kind]; + if ( + (fixedPath !== undefined && artifact.relativePath !== fixedPath) || + (artifact.kind === 'checkpoint' && + !/^checkpoints\/[0-7][0-9A-HJKMNPQRSTVWXYZ]{25}\.json$/.test( + artifact.relativePath, + )) + ) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_ARTIFACT_PATH_MISMATCH'); + } +} + +function assertBundleEntityTask( + bundle: GitRefTaskBundleV1, + taskRef: TaskRef, + taskRevision: number | null, + label: string, +): void { + if ( + !sameTaskRef(bundle.taskRef, taskRef) || + (taskRevision !== null && taskRevision !== bundle.taskRevision) + ) { + throw new Error( + `git-ref task bundle ${label} does not match the bundle task`, + ); + } +} + +function parseGitRefTaskBundleArtifact( + value: unknown, +): GitRefTaskBundleArtifactV1 { + assertRecord(value, 'git-ref task bundle artifact'); + assertKnownKeys( + value, + ['kind', 'relativePath', 'content', 'contentDigest'], + 'git-ref task bundle artifact', + ); + if (!isBundleArtifactKind(value.kind)) { + throw new Error('git-ref task bundle artifact kind is invalid'); + } + const relativePath = assertSafeSharedRelativePath( + value.relativePath as string, + ); + const content = parseJsonValue( + value.content, + 'git-ref task bundle artifact content', + ); + const serialized = JSON.stringify(content); + if (Buffer.byteLength(serialized, 'utf8') > MAX_BUNDLE_ARTIFACT_BYTES) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_ARTIFACT_TOO_LARGE'); + } + const contentDigest = parseDigest( + value.contentDigest, + 'git-ref task bundle artifact contentDigest', + ); + if (contentDigest !== digestCanonicalJson(content)) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_ARTIFACT_DIGEST_MISMATCH'); + } + return { kind: value.kind, relativePath, content, contentDigest }; +} + +function parseJsonValue( + value: unknown, + label: string, + depth = 0, +): GitRefJsonValue { + if (depth > MAX_JSON_DEPTH) { + throw new Error(`${label} exceeds the maximum nesting depth`); + } + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'string') { + assertSharedTextSafe(value, label); + return value; + } + if (typeof value === 'number') { + if (!Number.isSafeInteger(value) || Object.is(value, -0)) { + throw new Error(`${label} numbers must be safe integers`); + } + return value; + } + if (Array.isArray(value)) { + return value.map((item) => parseJsonValue(item, label, depth + 1)); + } + assertRecord(value, label); + const result: { [key: string]: unknown } = {}; + for (const [key, item] of Object.entries(value)) { + assertSharedTextSafe(key, `${label} key`); + result[key] = parseJsonValue(item, label, depth + 1); + } + return result; +} + +function createMutationReceipt(input: { + kind: GitRefRemoteMutationReceiptV1['kind']; + operationId: Ulid; + actorId: Ulid; + taskRef: TaskRef | null; + remoteRevision: number; + ownershipEpoch: number | null; + actorProfiles: SharedActorProfileV1[]; + ownershipFence: GitRefOwnershipFenceV1 | GitRefOwnershipFenceV1[] | null; + claims: ClaimV1[]; + handoffs: HandoffV1[]; + taskBundle: GitRefTaskBundleV1 | GitRefTaskBundleV1[] | null; + committedAt: string; +}): GitRefRemoteMutationReceiptV1 { + return parseGitRefRemoteMutationReceipt({ + schemaVersion: 1, + kind: input.kind, + operationId: input.operationId, + actorId: input.actorId, + taskRef: input.taskRef, + remoteRevision: input.remoteRevision, + ownershipEpoch: input.ownershipEpoch, + entityDigests: { + actorProfiles: digestCanonicalJson(input.actorProfiles), + ownershipFence: + input.ownershipFence === null + ? null + : digestCanonicalJson(input.ownershipFence), + claims: digestCanonicalJson(input.claims), + handoffs: digestCanonicalJson(input.handoffs), + taskBundle: + input.taskBundle === null + ? null + : digestCanonicalJson(input.taskBundle), + }, + committedAt: input.committedAt, + }); +} + +function parseReceiptEntityDigests( + value: unknown, +): GitRefRemoteMutationReceiptV1['entityDigests'] { + assertRecord(value, 'git-ref mutation receipt entityDigests'); + assertKnownKeys( + value, + ['actorProfiles', 'ownershipFence', 'claims', 'handoffs', 'taskBundle'], + 'git-ref mutation receipt entityDigests', + ); + return { + actorProfiles: parseDigest( + value.actorProfiles, + 'receipt actorProfiles digest', + ), + ownershipFence: + value.ownershipFence === null + ? null + : parseDigest(value.ownershipFence, 'receipt ownershipFence digest'), + claims: parseDigest(value.claims, 'receipt claims digest'), + handoffs: parseDigest(value.handoffs, 'receipt handoffs digest'), + taskBundle: + value.taskBundle === null + ? null + : parseDigest(value.taskBundle, 'receipt taskBundle digest'), + }; +} + +function emptyManifest(header: ManifestHeader): GitRefTeamManifestV1 { + return { + schemaVersion: 1, + ...header, + authorityState: 'active', + authorityFreeze: null, + authorityTombstone: null, + revision: 0, + lastOperationId: header.workspaceId, + actorProfiles: [], + ownershipFences: [], + claims: [], + handoffs: [], + taskBundles: [], + receipts: [], + lastMutation: null, + updatedAt: new Date(0).toISOString(), + }; +} + +function compatibilityHeader( + options: GitRefTeamManifestStoreOptions, +): ManifestHeader { + const schemaEpoch = options.schemaEpoch ?? options.workspaceId; + const minReaderVersion = options.minReaderVersion ?? '0.0.0'; + const minWriterVersion = options.minWriterVersion ?? '0.0.0'; + const transportEpoch = options.transportEpoch ?? 1; + const configRevision = options.configRevision ?? 1; + const configDigest = + options.configDigest ?? + compatibilityConfigDigest({ + workspaceId: options.workspaceId, + schemaEpoch, + transportEpoch, + configRevision, + }); + return { + workspaceId: options.workspaceId, + schemaEpoch, + minReaderVersion, + minWriterVersion, + transportEpoch, + configRevision, + configDigest, + }; +} + +function compatibilityConfigDigest(input: { + workspaceId: Ulid; + schemaEpoch: Ulid; + transportEpoch: number; + configRevision: number; +}): string { + return digestCanonicalJson({ compatibility: true, ...input }); +} + +function isLegacyProfileManifest(value: Record): boolean { + const legacyKeys = new Set([ + 'schemaVersion', + 'workspaceId', + 'revision', + 'lastOperationId', + 'actorProfiles', + 'updatedAt', + ]); + return ( + !Object.hasOwn(value, 'schemaEpoch') && + Object.keys(value).every((key) => legacyKeys.has(key)) + ); +} + +function normalizeLegacyProfileManifest( + value: Record, +): Record { + assertKnownKeys( + value, + [ + 'schemaVersion', + 'workspaceId', + 'revision', + 'lastOperationId', + 'actorProfiles', + 'updatedAt', + ], + 'legacy git-ref profile manifest', + ); + assertUlid(value.workspaceId, 'legacy git-ref manifest workspaceId'); + return { + ...value, + schemaEpoch: value.workspaceId, + minReaderVersion: '0.0.0', + minWriterVersion: '0.0.0', + transportEpoch: 1, + configRevision: 1, + configDigest: compatibilityConfigDigest({ + workspaceId: value.workspaceId, + schemaEpoch: value.workspaceId, + transportEpoch: 1, + configRevision: 1, + }), + authorityState: 'active', + authorityFreeze: null, + authorityTombstone: null, + ownershipFences: [], + claims: [], + handoffs: [], + taskBundles: [], + receipts: [], + lastMutation: null, + }; +} + +function assertExpectedHeader( + manifest: GitRefTeamManifestV1, + expected: Partial>, +): void { + for (const key of [ + 'schemaEpoch', + 'minReaderVersion', + 'minWriterVersion', + 'transportEpoch', + 'configRevision', + 'configDigest', + ] as const) { + if (expected[key] !== undefined && manifest[key] !== expected[key]) { + throw new Error(`MANCODE_TRANSPORT_${headerErrorName(key)}_MISMATCH`); + } + } +} + +function assertActiveAuthority(manifest: GitRefTeamManifestV1 | null): void { + if (manifest !== null && manifest.authorityState !== 'active') { + throw new Error('MANCODE_TRANSPORT_AUTHORITY_NOT_ACTIVE'); + } +} + +function headerErrorName( + key: keyof Omit, +): string { + return key.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase(); +} + +function appendReceipt( + receipts: GitRefRemoteMutationReceiptV1[], + receipt: GitRefRemoteMutationReceiptV1, +): GitRefRemoteMutationReceiptV1[] { + return [...receipts.slice(-(MAX_RECEIPTS - 1)), receipt].sort( + compareReceipts, + ); +} + +function upsertActorProfile( + profiles: SharedActorProfileV1[], + profile: SharedActorProfileV1, +): SharedActorProfileV1[] { + const existing = profiles.find( + (candidate) => candidate.actorId === profile.actorId, + ); + if ( + existing !== undefined && + (existing.displayName !== profile.displayName || + existing.joinedAt !== profile.joinedAt) + ) { + throw new Error('MANCODE_ACTOR_PROFILE_CONFLICT'); + } + return [ + ...profiles.filter((candidate) => candidate.actorId !== profile.actorId), + profile, + ] + .map(parseSharedActorProfile) + .sort(compareActorProfiles); +} + +async function writeManifestCommit( + projectRoot: string, + parent: string | null, + manifest: GitRefTeamManifestV1, + now: Date, +): Promise { + const temporaryDirectory = await mkdtemp( + path.join(tmpdir(), 'mancode-git-ref-'), + ); + const manifestPath = path.join(temporaryDirectory, 'manifest.json'); + const indexPath = path.join(temporaryDirectory, 'index'); + try { + const serialized = `${JSON.stringify(manifest, null, 2)}\n`; + if (Buffer.byteLength(serialized, 'utf8') > MAX_MANIFEST_BYTES) { + throw new Error('MANCODE_TRANSPORT_MANIFEST_TOO_LARGE'); + } + await writeFile(manifestPath, serialized); + const blob = ( + await runGit(projectRoot, ['hash-object', '-w', manifestPath]) + ).trim(); + const environment = { ...process.env, GIT_INDEX_FILE: indexPath }; + await runGit(projectRoot, ['read-tree', '--empty'], environment); + await runGit( + projectRoot, + ['update-index', '--add', '--cacheinfo', `100644,${blob},manifest.json`], + environment, + ); + const tree = ( + await runGit(projectRoot, ['write-tree'], environment) + ).trim(); + const timestamp = Math.floor(now.getTime() / 1_000); + const commitEnvironment = { + ...process.env, + GIT_AUTHOR_NAME: 'mancode transport', + GIT_AUTHOR_EMAIL: 'transport@mancode.invalid', + GIT_AUTHOR_DATE: `${timestamp} +0000`, + GIT_COMMITTER_NAME: 'mancode transport', + GIT_COMMITTER_EMAIL: 'transport@mancode.invalid', + GIT_COMMITTER_DATE: `${timestamp} +0000`, + }; + return ( + await runGit( + projectRoot, + [ + 'commit-tree', + tree, + ...(parent === null ? [] : ['-p', parent]), + '-m', + 'mancode team transport', + ], + commitEnvironment, + ) + ).trim(); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} + +async function pushWithLease( + projectRoot: string, + remote: string, + expectedCommit: string | null, + commit: string, +): Promise { + try { + await runGit(projectRoot, [ + 'push', + `--force-with-lease=${TEAM_REF}:${expectedCommit ?? ''}`, + remote, + `${commit}:${TEAM_REF}`, + ]); + } catch (error) { + if (isGitFailure(error)) { + const stderr = + 'stderr' in error && typeof error.stderr === 'string' + ? error.stderr + : error.message; + if (/\b(?:rejected|stale info|fetch first)\b/i.test(stderr)) { + throw new Error('MANCODE_TRANSPORT_CAS_CONFLICT'); + } + throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); + } + throw error; + } +} + +async function gitShow(projectRoot: string, revision: string): Promise { + try { + return await runGit(projectRoot, ['show', revision]); + } catch (error) { + if (isGitFailure(error)) + throw new Error('MANCODE_TRANSPORT_MANIFEST_INVALID'); + throw error; + } +} + +async function runGit( + projectRoot: string, + arguments_: string[], + env: NodeJS.ProcessEnv = process.env, +): Promise { + const result = await execFile('git', arguments_, { + cwd: projectRoot, + env, + windowsHide: true, + maxBuffer: MAX_MANIFEST_BYTES * 2, + }); + return result.stdout; +} + +async function readRemoteCommit( + projectRoot: string, + remote: string, +): Promise { + try { + const output = await runGit(projectRoot, ['ls-remote', remote, TEAM_REF]); + const value = output.trim(); + if (!value) return null; + const commit = value.split(/\s+/)[0]; + if (commit === undefined || !GIT_OBJECT_PATTERN.test(commit)) { + throw new Error('MANCODE_TRANSPORT_MANIFEST_INVALID'); + } + return commit; + } catch (error) { + if (error instanceof Error && error.message.startsWith('MANCODE_')) + throw error; + throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); + } +} + +function receiptFor(commit: string, manifest: GitRefTeamManifestV1): string { + return `git-ref:${commit}:${digestCanonicalJson(manifest)}`; +} + +function normalizeRemoteIdentity(projectRoot: string, value: string): string { + if (!value || value.includes('\0')) { + throw new Error('MANCODE_TRANSPORT_REMOTE_INVALID'); + } + const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/.exec(value); + if ( + scp !== null && + !/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value) && + !/^[A-Za-z]:[\\/]/.test(value) + ) { + const host = scp[1]; + const remotePath = scp[2]; + if (host === undefined || remotePath === undefined || !remotePath.trim()) { + throw new Error('MANCODE_TRANSPORT_REMOTE_INVALID'); + } + return `ssh://${host.toLowerCase()}/${remotePath.replace(/^\/+|\/+$/g, '')}`; + } + try { + const url = new URL(value); + url.username = ''; + url.password = ''; + url.hash = ''; + url.search = ''; + url.hostname = url.hostname.toLowerCase(); + url.pathname = url.pathname.replace(/\/+$/g, ''); + return url.toString(); + } catch { + return path.resolve(projectRoot, value).replaceAll('\\', '/'); + } +} + +function parseBundleCodeRef(value: unknown): GitRefTaskBundleV1['codeRef'] { + assertRecord(value, 'git-ref task bundle codeRef'); + assertKnownKeys(value, ['branch', 'head'], 'git-ref task bundle codeRef'); + if ( + typeof value.branch !== 'string' || + !value.branch.trim() || + value.branch.includes('\0') || + typeof value.head !== 'string' || + !GIT_OBJECT_PATTERN.test(value.head) + ) { + throw new Error('git-ref task bundle codeRef is invalid'); + } + assertSharedTextSafe(value.branch, 'git-ref task bundle codeRef branch'); + return { branch: value.branch, head: value.head }; +} + +function parseSharedTaskRef(value: unknown, label: string): TaskRef { + const taskRef = parseTaskRefValue(value); + if (taskRef.namespace !== 'shared') { + throw new Error(`${label} must use the shared namespace`); + } + return taskRef; +} + +function parseBoundedArray( + value: unknown, + maximum: number, + label: string, + parse: (item: unknown) => T, +): T[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + if (value.length > maximum) + throw new Error(`${label} exceeds the entity limit`); + return value.map(parse); +} + +function assertUnique( + values: readonly T[], + key: (value: T) => string, + label: string, +): void { + const seen = new Set(); + for (const value of values) { + const itemKey = key(value); + if (seen.has(itemKey)) throw new Error(`${label} has duplicates`); + seen.add(itemKey); + } +} + +function replaceTaskEntity( + existing: readonly T[], + taskRef: TaskRef, + replacement: readonly T[], + getTaskRef: (value: T) => TaskRef, +): T[] { + return [ + ...existing.filter((value) => !sameTaskRef(getTaskRef(value), taskRef)), + ...replacement, + ]; +} + +function parseExpectedRevision(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error('MANCODE_TRANSPORT_REVISION_INVALID'); + } + return value; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parsePositiveIntegerOrNull( + value: unknown, + label: string, +): number | null { + return value === null ? null : parsePositiveInteger(value, label); +} + +function parseNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseVersion(value: unknown, label: string): string { + if (typeof value !== 'string' || !VERSION_PATTERN.test(value)) { + throw new Error(`${label} must be a semantic version`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function parseAuthorityState( + value: unknown, +): 'active' | 'frozen' | 'tombstoned' { + if (value !== 'active' && value !== 'frozen' && value !== 'tombstoned') { + throw new Error('git-ref team manifest authorityState is invalid'); + } + return value; +} + +function parseOptionalPositiveInteger(value: unknown, label: string): void { + if (value !== undefined) parsePositiveInteger(value, label); +} + +function parseOptionalVersion(value: unknown, label: string): void { + if (value !== undefined) parseVersion(value, label); +} + +function parseOptionalDigest(value: unknown, label: string): void { + if (value !== undefined) parseDigest(value, label); +} + +function parseRemoteEntityRevision(value: string | null): number | null { + if (value === null) return null; + if (!/^[1-9]\d*$/.test(value)) { + throw new Error('git-ref entity remoteRevision must be a decimal revision'); + } + return parsePositiveInteger(Number(value), 'git-ref entity remoteRevision'); +} + +function assertManifestSize(value: unknown): void { + let serialized: string | undefined; + try { + serialized = JSON.stringify(value); + } catch { + throw new Error('MANCODE_TRANSPORT_MANIFEST_INVALID'); + } + if (serialized === undefined) { + throw new Error('MANCODE_TRANSPORT_MANIFEST_INVALID'); + } + if (Buffer.byteLength(serialized, 'utf8') > MAX_MANIFEST_BYTES) { + throw new Error('MANCODE_TRANSPORT_MANIFEST_TOO_LARGE'); + } +} + +function isBundleArtifactKind( + value: unknown, +): value is GitRefTaskBundleArtifactKind { + return ( + value === 'metadata' || + value === 'checkpoint' || + value === 'requirements' || + value === 'review' || + value === 'verification' || + value === 'plan' || + value === 'summary' + ); +} + +function taskKey(taskRef: TaskRef): string { + return `${taskRef.namespace}:${taskRef.taskId}`; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function compareActorProfiles( + left: SharedActorProfileV1, + right: SharedActorProfileV1, +): number { + return compareUtf8(left.actorId, right.actorId); +} + +function compareByTaskRef( + left: T, + right: T, +): number { + return compareUtf8(taskKey(left.taskRef), taskKey(right.taskRef)); +} + +function compareClaims(left: ClaimV1, right: ClaimV1): number { + return compareUtf8(left.claimId, right.claimId); +} + +function compareHandoffs(left: HandoffV1, right: HandoffV1): number { + return compareUtf8(left.handoffId, right.handoffId); +} + +function compareReceipts( + left: GitRefRemoteMutationReceiptV1, + right: GitRefRemoteMutationReceiptV1, +): number { + return left.remoteRevision - right.remoteRevision; +} + +function compareBundleArtifacts( + left: GitRefTaskBundleArtifactV1, + right: GitRefTaskBundleArtifactV1, +): number { + return compareUtf8(left.kind, right.kind); +} + +function isGitFailure( + error: unknown, +): error is Error & { code: unknown; stderr?: unknown } { + return error instanceof Error && 'code' in error; +} diff --git a/src/team/handoff-operation.ts b/src/team/handoff-operation.ts new file mode 100644 index 0000000..770e4f1 --- /dev/null +++ b/src/team/handoff-operation.ts @@ -0,0 +1,1255 @@ +import { createHash } from 'node:crypto'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, +} from '../context/aggregate.js'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { createV3Checkpoint } from '../context/checkpoint-create.js'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { + assertTaskCodeHeadUnchanged, + nextTaskHeadFence, +} from '../context/task-mutation.js'; +import { type TaskRef, parseTaskRefValue } from '../context/task-ref.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, +} from '../context/workflow-metadata.js'; +import { createClaim, updateClaim } from '../runtime/claim-store.js'; +import { resolveCoordinationEntityHomeStore } from '../runtime/entity-home-store.js'; +import { + createHandoff, + readHandoff, + updateHandoff, +} from '../runtime/handoff-store.js'; +import type { OperationJournalV1 } from '../runtime/operation-journal.js'; +import { + createClaimRecoveryAction, + createHandoffRecoveryAction, + createTaskAuthorityFileRecoveryAction, + createTaskHeadFenceRecoveryAction, +} from '../runtime/operation-recovery-payload.js'; +import { + readCheckoutBranch, + readProjectRuntimeContext, +} from '../runtime/project-runtime.js'; +import type { TaskHeadFenceV1 } from '../runtime/task-head-fence.js'; +import { replaceTaskHeadFence } from '../runtime/task-head-store.js'; +import { + type OpenedV3TaskOperation, + advanceTaskOperation, + commitTaskOperation, + createTaskOperationJournal, + handleTaskOperationFailure, + openV3TaskOperation, + serializeTaskAuthority, + taskEntityKey, + taskHeadEntityKey, + writeTaskAuthorityFile, +} from '../runtime/task-operation.js'; +import { readSharedActorProfile } from './actor.js'; +import { type CheckpointV1, checkpointDigest } from './checkpoints.js'; +import { type ClaimV1, parseClaim } from './claims.js'; +import { + type HandoffSummary, + type HandoffV1, + parseHandoff, +} from './handoff.js'; + +export interface CreateV3HandoffDraftInput { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + toActorId: Ulid; + /** Defaults to all currently active claims owned by the offering actor. */ + claimIds?: Ulid[]; + summary?: HandoffSummary; + checkpointSummary?: string; + checkpointNextAction?: string; + handoffId?: Ulid; + checkpointId?: Ulid; + checkpointOperationId?: Ulid; + operationId?: Ulid; + now?: Date; +} + +export interface CreatedV3HandoffDraft { + checkpoint: CheckpointV1; + checkpointOperation: OperationJournalV1; + handoff: HandoffV1; + operation: OperationJournalV1; +} + +export interface TransitionV3HandoffInput { + projectRoot: string; + handoffId: Ulid; + sessionId: Ulid; + expectedHandoffRevision: number; + reason?: string; + operationId?: Ulid; + now?: Date; +} + +export interface TransitionedV3Handoff { + handoff: HandoffV1; + operation: OperationJournalV1; +} + +export interface AcceptV3HandoffInput { + projectRoot: string; + handoffId: Ulid; + sessionId: Ulid; + expectedHandoffRevision: number; + /** Optional explicit IDs; otherwise they are deterministic from operationId. */ + successorClaimIds?: Ulid[]; + operationId?: Ulid; + now?: Date; +} + +export interface AcceptedV3Handoff { + metadata: WorkflowMetadataV3; + handoff: HandoffV1; + predecessorClaims: ClaimV1[]; + successorClaims: ClaimV1[]; + aggregate: TaskAggregateManifestV1; + taskHeadFence: TaskHeadFenceV1; + operation: OperationJournalV1; +} + +/** + * Creates the required immutable handoff checkpoint first, then records a + * named draft under the canonical shared task lock. The checkpoint remains a + * valid recovery artifact if the later draft write is interrupted. + */ +export async function createV3HandoffDraft( + input: CreateV3HandoffDraftInput, +): Promise { + const taskRef = parseTaskRefValue(input.taskRef); + if (taskRef.namespace !== 'shared') { + throw new Error('MANCODE_HANDOFF_REQUIRES_SHARED_TASK'); + } + assertUlid(input.toActorId, 'handoff receiving actorId'); + const now = input.now ?? new Date(); + const checkpointOperationId = + input.checkpointOperationId ?? createUlid(now.getTime()); + const handoffOperationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(checkpointOperationId, 'handoff checkpoint operationId'); + assertUlid(handoffOperationId, 'handoff draft operationId'); + + const checkpointResult = await createV3Checkpoint({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + kind: 'handoff_offered', + summary: + input.checkpointSummary ?? + 'Created an immutable checkpoint before offering task ownership.', + nextAction: + input.checkpointNextAction ?? + input.summary?.nextAction ?? + 'Review the checkpoint and continue the assigned task.', + checkpointId: input.checkpointId, + operationId: checkpointOperationId, + now, + }); + const handoffId = input.handoffId ?? createUlid(now.getTime()); + assertUlid(handoffId, 'handoffId'); + const { context, handoff } = await openHandoffDraftContext({ + projectRoot: input.projectRoot, + taskRef, + sessionId: input.sessionId, + expectedTaskRevision: checkpointResult.metadata.revision, + toActorId: input.toActorId, + handoffId, + claimIds: input.claimIds, + summary: input.summary, + checkpoint: checkpointResult.checkpoint, + operationId: handoffOperationId, + now, + }); + let journal: OperationJournalV1 | null = null; + try { + journal = await createTaskOperationJournal(context, { + type: 'handoff_transition', + action: 'handoff_offer_cancel', + expectedRevisions: { + [taskEntityKey(taskRef)]: context.task.metadata.revision, + [`handoff:${handoff.handoffId}`]: 0, + }, + handoff: { + fromActorId: handoff.fromActorId, + toActorId: handoff.toActorId, + intent: 'offer', + }, + recovery: { + actions: [ + createHandoffRecoveryAction({ + stepId: 'write-handoff', + before: null, + handoff, + }), + ], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + journal = await advanceTaskOperation( + context, + journal, + 'write-handoff', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await createHandoff(context.homeStore, handoff); + const operation = await commitTaskOperation(context, journal); + return { + checkpoint: checkpointResult.checkpoint, + checkpointOperation: checkpointResult.operation, + handoff, + operation, + }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A durable intent keeps the coordination snapshot repair-only. + } + } + throw error; + } finally { + await context.release(); + } +} + +/** Offers a named draft after rechecking its task bundle and recipient. */ +export async function offerV3Handoff( + input: TransitionV3HandoffInput, +): Promise { + return transitionV3Handoff(input, 'offer'); +} + +/** Rejects an offered handoff; only the receiving actor may do so. */ +export async function rejectV3Handoff( + input: TransitionV3HandoffInput, +): Promise { + if (input.reason === undefined || !input.reason.trim()) { + throw new Error('MANCODE_HANDOFF_REJECTION_REASON_REQUIRED'); + } + return transitionV3Handoff(input, 'reject'); +} + +/** Cancels a named draft or offer without changing task ownership. */ +export async function cancelV3Handoff( + input: TransitionV3HandoffInput, +): Promise { + return transitionV3Handoff(input, 'cancel'); +} + +/** + * Accepts an offered local-coordination handoff as one task operation. The + * task remains operation_pending until old claims have been terminally + * transferred, successors are active for the new owner, and the handoff + * state itself has become accepted. + */ +export async function acceptV3Handoff( + input: AcceptV3HandoffInput, +): Promise { + const now = input.now ?? new Date(); + const operationId = input.operationId ?? createUlid(now.getTime()); + assertUlid(operationId, 'handoff accept operationId'); + const opened = await openAcceptHandoffContext({ + projectRoot: input.projectRoot, + handoffId: input.handoffId, + sessionId: input.sessionId, + expectedHandoffRevision: input.expectedHandoffRevision, + successorClaimIds: input.successorClaimIds, + operationId, + now, + }); + const { context, handoff, predecessorClaims, successorClaimIds } = opened; + let journal: OperationJournalV1 | null = null; + try { + assertHandoffBundleCurrent(context, handoff); + assertHandoffAcceptable(context, handoff); + assertTransferablePredecessorClaims( + predecessorClaims, + handoff, + context.now, + ); + const timestamp = context.now.toISOString(); + const checkpointId = handoffCheckpointId(handoff); + const pendingMetadata = markHandoffOperationPending( + context.task.metadata, + context.operationId, + timestamp, + ); + const metadata = transferHandoffOwnership( + pendingMetadata, + handoff.toActorId, + context.operationId, + timestamp, + ); + const [branch, successorClaims] = await Promise.all([ + readCheckoutBranch(context.projectRoot), + Promise.resolve( + buildPendingSuccessorClaims( + predecessorClaims, + successorClaimIds, + metadata, + handoff.toActorId, + requireCodeHead(context), + context.operationId, + timestamp, + ), + ), + ]); + const pendingSuccessorClaims = successorClaims.map((claim) => + withSuccessorBranch(claim, branch ?? 'HEAD'), + ); + const transferredClaims = predecessorClaims.map((claim, index) => + transferPredecessorClaim( + claim, + successorClaimIds[index] as Ulid, + context.operationId, + timestamp, + ), + ); + const activeSuccessorClaims = pendingSuccessorClaims.map((claim) => + activateSuccessorClaim(claim, context.operationId, timestamp), + ); + const acceptedHandoff = acceptHandoff( + handoff, + context.session.actorId, + context.operationId, + timestamp, + ); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements: context.task.requirements, + review: context.task.review, + verification: context.task.verification, + planDigest: context.task.plan?.digest ?? null, + latestCheckpoint: context.task.latestCheckpoint, + }); + const taskHeadFence = nextTaskHeadFence(context, aggregate, timestamp); + if (taskHeadFence === null) { + throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + } + + journal = await createTaskOperationJournal(context, { + type: 'handoff_accept', + action: 'handoff_accept_reject', + expectedRevisions: acceptExpectedRevisions( + context, + handoff, + checkpointId, + predecessorClaims, + successorClaimIds, + ), + handoff: { + fromActorId: handoff.fromActorId, + toActorId: handoff.toActorId, + intent: 'accept', + }, + conditions: { taskContextAvailable: true, transportFresh: true }, + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-task-operation-pending', + taskRef: context.taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(pendingMetadata), + }), + ...pendingSuccessorClaims.map((claim) => + createClaimRecoveryAction({ + stepId: 'create-pending-successor-claims', + before: null, + claim, + }), + ), + ...transferredClaims.map((claim, index) => { + const before = predecessorClaims[index]; + if (before === undefined) { + throw new Error('MANCODE_CLAIM_SET_CHANGED'); + } + return createClaimRecoveryAction({ + stepId: 'transfer-old-claims', + before, + claim, + }); + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'update-owner-and-checkpoint', + taskRef: context.taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(pendingMetadata), + targetContent: serializeTaskAuthority(metadata), + }), + ...activeSuccessorClaims.map((claim, index) => { + const before = pendingSuccessorClaims[index]; + if (before === undefined) { + throw new Error('MANCODE_CLAIM_SET_CHANGED'); + } + return createClaimRecoveryAction({ + stepId: 'activate-successor-claims', + before, + claim, + }); + }), + createHandoffRecoveryAction({ + stepId: 'accept-handoff', + before: handoff, + handoff: acceptedHandoff, + }), + createTaskHeadFenceRecoveryAction({ + stepId: 'update-task-head-fence', + before: context.coordination.taskHeadFence, + fence: taskHeadFence, + }), + ], + noOpStepIds: [ + ...(pendingSuccessorClaims.length === 0 + ? ['create-pending-successor-claims'] + : []), + ...(transferredClaims.length === 0 ? ['transfer-old-claims'] : []), + ...(activeSuccessorClaims.length === 0 + ? ['activate-successor-claims'] + : []), + ], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + + journal = await advanceTaskOperation( + context, + journal, + 'mark-task-operation-pending', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await writeTaskAuthorityFile( + context, + 'metadata.json', + `${JSON.stringify(pendingMetadata, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'create-pending-successor-claims', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + for (const claim of pendingSuccessorClaims) { + await createClaim(context.homeStore, claim); + } + + journal = await advanceTaskOperation( + context, + journal, + 'transfer-old-claims', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + for (const [index, claim] of transferredClaims.entries()) { + await updateClaim( + context.homeStore, + claim, + predecessorClaims[index]?.revision ?? 0, + ); + } + + journal = await advanceTaskOperation( + context, + journal, + 'update-owner-and-checkpoint', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await writeTaskAuthorityFile( + context, + 'metadata.json', + `${JSON.stringify(metadata, null, 2)}\n`, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'activate-successor-claims', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + for (const claim of activeSuccessorClaims) { + await updateClaim(context.homeStore, claim, 1); + } + + journal = await advanceTaskOperation( + context, + journal, + 'accept-handoff', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await updateHandoff( + context.homeStore, + acceptedHandoff, + handoff.revision, + context.session.actorId, + ); + + journal = await advanceTaskOperation( + context, + journal, + 'update-task-head-fence', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await replaceTaskHeadFence(context.homeStore, taskHeadFence); + const operation = await commitTaskOperation(context, journal); + return { + metadata, + handoff: acceptedHandoff, + predecessorClaims: transferredClaims, + successorClaims: activeSuccessorClaims, + aggregate, + taskHeadFence, + operation, + }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // Existing write intent forces forward repair by the original actor. + } + } + throw error; + } finally { + await context.release(); + } +} + +async function openAcceptHandoffContext(input: { + projectRoot: string; + handoffId: Ulid; + sessionId: Ulid; + expectedHandoffRevision: number; + successorClaimIds: Ulid[] | undefined; + operationId: Ulid; + now: Date; +}): Promise<{ + context: OpenedV3TaskOperation; + handoff: HandoffV1; + predecessorClaims: ClaimV1[]; + successorClaimIds: Ulid[]; +}> { + assertUlid(input.handoffId, 'handoffId'); + assertPositiveRevision(input.expectedHandoffRevision, 'handoff revision'); + const runtime = await readProjectRuntimeContext(input.projectRoot); + const homeStore = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const preflight = await readHandoff(homeStore, input.handoffId); + if (preflight === null) throw new Error('MANCODE_HANDOFF_NOT_FOUND'); + if (preflight.revision !== input.expectedHandoffRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + const preflightSuccessorIds = resolveSuccessorClaimIds( + preflight, + input.successorClaimIds, + input.operationId, + ); + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef: preflight.taskRef, + sessionId: input.sessionId, + expectedTaskRevision: preflight.taskRevision, + operationId: input.operationId, + extraEntityLocks: [ + `handoff:${input.handoffId}`, + `checkpoint:${handoffCheckpointId(preflight)}`, + taskHeadEntityKey(preflight.taskRef), + ...preflight.claimIds.map((claimId) => `claim:${claimId}`), + ...preflightSuccessorIds.map((claimId) => `claim:${claimId}`), + ], + now: input.now, + }); + try { + const handoff = context.coordination.handoffs.find( + (candidate) => candidate.handoffId === input.handoffId, + ); + if (handoff === undefined) throw new Error('MANCODE_HANDOFF_NOT_FOUND'); + if (handoff.revision !== input.expectedHandoffRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + const successorClaimIds = resolveSuccessorClaimIds( + handoff, + input.successorClaimIds, + input.operationId, + ); + if (!sameUlidSet(preflightSuccessorIds, successorClaimIds)) { + throw new Error('MANCODE_HANDOFF_CLAIM_SET_CHANGED'); + } + const predecessorClaims = handoff.claimIds.map((claimId) => { + const claim = context.coordination.claims.find( + (candidate) => candidate.claimId === claimId, + ); + if (claim === undefined) + throw new Error('MANCODE_HANDOFF_CLAIM_UNAVAILABLE'); + return claim; + }); + return { context, handoff, predecessorClaims, successorClaimIds }; + } catch (error) { + await context.release(); + throw error; + } +} + +function assertHandoffAcceptable( + context: OpenedV3TaskOperation, + handoff: HandoffV1, +): void { + if (handoff.state !== 'offered') { + throw new Error('MANCODE_HANDOFF_NOT_OFFERED'); + } + if (context.session.actorId !== handoff.toActorId) { + throw new Error('MANCODE_HANDOFF_ACTOR_MISMATCH'); + } + if ( + context.task.metadata.ownerActorId !== handoff.fromActorId || + context.task.metadata.ownershipEpoch !== handoff.ownershipEpochAtOffer + ) { + throw new Error('MANCODE_OWNERSHIP_EPOCH_STALE'); + } +} + +function assertTransferablePredecessorClaims( + claims: readonly ClaimV1[], + handoff: HandoffV1, + now: Date, +): void { + if (claims.length !== handoff.claimIds.length) { + throw new Error('MANCODE_HANDOFF_CLAIM_SET_CHANGED'); + } + for (const claim of claims) { + if ( + claim.state !== 'active' || + claim.ownerActorId !== handoff.fromActorId || + Date.parse(claim.expiresAt) <= now.getTime() + ) { + throw new Error('MANCODE_HANDOFF_CLAIM_UNAVAILABLE'); + } + } +} + +function resolveSuccessorClaimIds( + handoff: HandoffV1, + requested: Ulid[] | undefined, + operationId: Ulid, +): Ulid[] { + const ids = + requested ?? + handoff.claimIds.map((claimId) => + handoffSuccessorClaimId(operationId, claimId, handoff.createdAt), + ); + if (ids.length !== handoff.claimIds.length) { + throw new Error('MANCODE_HANDOFF_SUCCESSOR_CLAIM_COUNT_INVALID'); + } + const seen = new Set(); + for (const claimId of ids) { + assertUlid(claimId, 'handoff successor claimId'); + if (seen.has(claimId) || handoff.claimIds.includes(claimId)) { + throw new Error('MANCODE_HANDOFF_SUCCESSOR_CLAIM_INVALID'); + } + seen.add(claimId); + } + return ids; +} + +function handoffCheckpointId(handoff: HandoffV1): Ulid { + const checkpointId = handoff.checkpointRef.artifactId; + if (checkpointId === undefined) { + throw new Error('MANCODE_HANDOFF_CHECKPOINT_INVALID'); + } + assertUlid(checkpointId, 'handoff checkpointId'); + return checkpointId; +} + +function markHandoffOperationPending( + previous: WorkflowMetadataV3, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + transitionState: 'operation_pending', + lastOperationId: operationId, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function transferHandoffOwnership( + previous: WorkflowMetadataV3, + toActorId: Ulid, + operationId: Ulid, + updatedAt: string, +): WorkflowMetadataV3 { + const next = parseWorkflowMetadata({ + ...previous, + revision: previous.revision + 1, + ownerActorId: toActorId, + ownershipEpoch: previous.ownershipEpoch + 1, + transitionState: 'stable', + lastOperationId: operationId, + updatedAt, + }); + assertWorkflowMetadataTransition(previous, next, 'ordinary'); + return next; +} + +function buildPendingSuccessorClaims( + predecessors: ClaimV1[], + successorIds: Ulid[], + metadata: WorkflowMetadataV3, + toActorId: Ulid, + codeHead: string, + operationId: Ulid, + updatedAt: string, +): ClaimV1[] { + return predecessors.map((previous, index) => { + const successorClaimId = successorIds[index]; + if (successorClaimId === undefined) { + throw new Error('MANCODE_HANDOFF_SUCCESSOR_CLAIM_COUNT_INVALID'); + } + return parseClaim({ + ...previous, + claimId: successorClaimId, + taskRevisionAtAcquire: metadata.revision, + lastValidatedTaskRevision: metadata.revision, + implementationScopeDigest: metadata.implementationScope.digest, + ownershipEpochAtAcquire: metadata.ownershipEpoch, + ownerActorId: toActorId, + state: 'pending', + revision: 1, + predecessorClaimId: previous.claimId, + successorClaimId: null, + codeRefAtAcquire: { branch: 'HEAD', head: codeHead }, + lastValidatedCodeRef: { branch: 'HEAD', head: codeHead }, + lastOperationId: operationId, + createdAt: updatedAt, + updatedAt, + }); + }); +} + +function withSuccessorBranch(claim: ClaimV1, branch: string): ClaimV1 { + return parseClaim({ + ...claim, + codeRefAtAcquire: { ...claim.codeRefAtAcquire, branch }, + lastValidatedCodeRef: { ...claim.lastValidatedCodeRef, branch }, + }); +} + +function transferPredecessorClaim( + previous: ClaimV1, + successorClaimId: Ulid, + operationId: Ulid, + updatedAt: string, +): ClaimV1 { + return parseClaim({ + ...previous, + state: 'transferred', + revision: previous.revision + 1, + successorClaimId, + lastOperationId: operationId, + updatedAt, + }); +} + +function activateSuccessorClaim( + previous: ClaimV1, + operationId: Ulid, + updatedAt: string, +): ClaimV1 { + return parseClaim({ + ...previous, + state: 'active', + revision: previous.revision + 1, + lastOperationId: operationId, + updatedAt, + }); +} + +function acceptHandoff( + previous: HandoffV1, + actorId: Ulid, + operationId: Ulid, + updatedAt: string, +): HandoffV1 { + return parseHandoff({ + ...previous, + state: 'accepted', + revision: previous.revision + 1, + lastOperationId: operationId, + resolution: { state: 'accepted', actorId, at: updatedAt, reason: null }, + updatedAt, + }); +} + +function acceptExpectedRevisions( + context: OpenedV3TaskOperation, + handoff: HandoffV1, + checkpointId: Ulid, + predecessors: ClaimV1[], + successorClaimIds: Ulid[], +): Record { + const expected: Record = { + [taskEntityKey(handoff.taskRef)]: context.task.metadata.revision, + [`handoff:${handoff.handoffId}`]: handoff.revision, + [`checkpoint:${checkpointId}`]: + context.task.latestCheckpoint?.taskRevision ?? 0, + }; + for (const claim of predecessors) { + expected[`claim:${claim.claimId}`] = claim.revision; + } + for (const claimId of successorClaimIds) { + expected[`claim:${claimId}`] = 0; + } + const fence = context.coordination.taskHeadFence; + if (fence === null) throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING'); + expected[taskHeadEntityKey(handoff.taskRef)] = fence.fenceRevision; + return expected; +} + +async function transitionV3Handoff( + input: TransitionV3HandoffInput, + intent: 'offer' | 'reject' | 'cancel', +): Promise { + const operationId = + input.operationId ?? createUlid((input.now ?? new Date()).getTime()); + assertUlid(operationId, 'handoff transition operationId'); + const { context, handoff } = await openExistingHandoffContext({ + projectRoot: input.projectRoot, + handoffId: input.handoffId, + sessionId: input.sessionId, + expectedHandoffRevision: input.expectedHandoffRevision, + operationId, + now: input.now ?? new Date(), + }); + let journal: OperationJournalV1 | null = null; + try { + assertHandoffBundleCurrent(context, handoff); + await assertHandoffRecipient(context, handoff.toActorId); + assertTransitionState(handoff, intent); + const timestamp = context.now.toISOString(); + const next = nextHandoffTransition( + handoff, + intent, + context.session.actorId, + input.reason, + context.operationId, + timestamp, + ); + journal = await createTaskOperationJournal(context, { + type: 'handoff_transition', + action: + intent === 'offer' || intent === 'cancel' + ? 'handoff_offer_cancel' + : 'handoff_accept_reject', + expectedRevisions: { + [taskEntityKey(handoff.taskRef)]: context.task.metadata.revision, + [`handoff:${handoff.handoffId}`]: handoff.revision, + }, + handoff: { + fromActorId: handoff.fromActorId, + toActorId: handoff.toActorId, + intent, + }, + conditions: + intent === 'reject' + ? { taskContextAvailable: true, transportFresh: true } + : undefined, + recovery: { + actions: [ + createHandoffRecoveryAction({ + stepId: 'write-handoff', + before: handoff, + handoff: next, + }), + ], + }, + }); + journal = await advanceTaskOperation(context, journal, 'validate', true); + journal = await advanceTaskOperation( + context, + journal, + 'write-handoff', + false, + ); + await assertTaskCodeHeadUnchanged(context.projectRoot, context.codeHead); + await updateHandoff( + context.homeStore, + next, + handoff.revision, + context.session.actorId, + ); + const operation = await commitTaskOperation(context, journal); + return { handoff: next, operation }; + } catch (error) { + if (journal !== null) { + try { + await handleTaskOperationFailure(context, journal); + } catch { + // A durable intent keeps the coordination snapshot repair-only. + } + } + throw error; + } finally { + await context.release(); + } +} + +async function openHandoffDraftContext(input: { + projectRoot: string; + taskRef: TaskRef; + sessionId: Ulid; + expectedTaskRevision: number; + toActorId: Ulid; + handoffId: Ulid; + claimIds: Ulid[] | undefined; + summary: HandoffSummary | undefined; + checkpoint: CheckpointV1; + operationId: Ulid; + now: Date; +}): Promise<{ context: OpenedV3TaskOperation; handoff: HandoffV1 }> { + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef: input.taskRef, + sessionId: input.sessionId, + expectedTaskRevision: input.expectedTaskRevision, + operationId: input.operationId, + extraEntityLocks: [`handoff:${input.handoffId}`], + now: input.now, + }); + try { + assertLocalHandoffTransport(context); + assertHandoffTaskEligible(context.task.metadata); + await assertHandoffRecipient(context, input.toActorId); + if ( + context.task.latestCheckpoint === null || + context.task.latestCheckpoint.checkpointId !== + input.checkpoint.checkpointId + ) { + throw new Error('MANCODE_HANDOFF_CHECKPOINT_STALE'); + } + const claims = selectedOfferClaims(context, input.claimIds); + const codeHead = requireCodeHead(context); + const branch = (await readCheckoutBranch(context.projectRoot)) ?? 'HEAD'; + const timestamp = context.now.toISOString(); + const handoff = parseHandoff({ + schemaVersion: 1, + handoffId: input.handoffId, + taskRef: input.taskRef, + taskRevision: context.task.metadata.revision, + ownershipEpochAtOffer: context.task.metadata.ownershipEpoch, + state: 'draft', + revision: 1, + fromActorId: context.session.actorId, + toActorId: input.toActorId, + claimIds: claims.map((claim) => claim.claimId), + checkpointRef: { + taskRef: input.taskRef, + kind: 'checkpoint', + artifactId: input.checkpoint.checkpointId, + }, + summary: input.summary ?? defaultHandoffSummary(input.checkpoint), + transport: { + mode: 'local', + state: 'local_only', + transportRevision: null, + publishedAt: null, + fetchedAt: null, + taskBundleDigest: handoffBundleDigest( + context, + input.checkpoint, + codeHead, + ), + codeRef: { branch, head: codeHead }, + codeReachable: true, + receipt: null, + }, + lastOperationId: context.operationId, + offeredAt: null, + resolution: null, + createdAt: timestamp, + updatedAt: timestamp, + }); + return { context, handoff }; + } catch (error) { + await context.release(); + throw error; + } +} + +async function openExistingHandoffContext(input: { + projectRoot: string; + handoffId: Ulid; + sessionId: Ulid; + expectedHandoffRevision: number; + operationId: Ulid; + now: Date; +}): Promise<{ context: OpenedV3TaskOperation; handoff: HandoffV1 }> { + assertUlid(input.handoffId, 'handoffId'); + assertPositiveRevision(input.expectedHandoffRevision, 'handoff revision'); + const runtime = await readProjectRuntimeContext(input.projectRoot); + const homeStore = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const preflight = await readHandoff(homeStore, input.handoffId); + if (preflight === null) throw new Error('MANCODE_HANDOFF_NOT_FOUND'); + if (preflight.revision !== input.expectedHandoffRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + const context = await openV3TaskOperation({ + projectRoot: input.projectRoot, + taskRef: preflight.taskRef, + sessionId: input.sessionId, + expectedTaskRevision: preflight.taskRevision, + operationId: input.operationId, + extraEntityLocks: [`handoff:${input.handoffId}`], + now: input.now, + }); + try { + const handoff = context.coordination.handoffs.find( + (candidate) => candidate.handoffId === input.handoffId, + ); + if (handoff === undefined) throw new Error('MANCODE_HANDOFF_NOT_FOUND'); + if (handoff.revision !== input.expectedHandoffRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + return { context, handoff }; + } catch (error) { + await context.release(); + throw error; + } +} + +function assertHandoffTaskEligible(metadata: WorkflowMetadataV3): void { + if (metadata.workflowMode !== 'manteam') { + throw new Error('MANCODE_HANDOFF_WORKFLOW_MODE_INVALID'); + } + if (metadata.status !== 'in_progress') { + throw new Error('MANCODE_HANDOFF_WORKFLOW_NOT_ACTIVE'); + } +} + +function assertLocalHandoffTransport(context: OpenedV3TaskOperation): void { + if (context.project.config.transport.mode !== 'local') { + throw new Error('MANCODE_GIT_REF_TRANSPORT_NOT_IMPLEMENTED'); + } +} + +async function assertHandoffRecipient( + context: OpenedV3TaskOperation, + toActorId: Ulid, +): Promise { + if (!context.task.metadata.participants.includes(toActorId)) { + throw new Error('MANCODE_HANDOFF_RECIPIENT_NOT_PARTICIPANT'); + } + if ((await readSharedActorProfile(context.projectRoot, toActorId)) === null) { + throw new Error('MANCODE_HANDOFF_RECIPIENT_NOT_JOINED'); + } +} + +function selectedOfferClaims( + context: OpenedV3TaskOperation, + requested: Ulid[] | undefined, +): ClaimV1[] { + const owned = context.coordination.claims.filter( + (claim) => + claim.state === 'active' && + claim.ownerActorId === context.session.actorId, + ); + const ids = requested ?? owned.map((claim) => claim.claimId); + for (const claimId of ids) assertUlid(claimId, 'handoff claimId'); + if ( + !sameUlidSet( + ids, + owned.map((claim) => claim.claimId), + ) + ) { + throw new Error('MANCODE_HANDOFF_CLAIM_SET_INVALID'); + } + return owned; +} + +function defaultHandoffSummary(checkpoint: CheckpointV1): HandoffSummary { + return { + completed: [], + inProgress: [], + notStarted: [], + changedFiles: [], + verification: [], + blockers: [], + risks: [], + nextAction: checkpoint.nextAction, + }; +} + +function handoffBundleDigest( + context: OpenedV3TaskOperation, + checkpoint: CheckpointV1, + codeHead: string, +): string { + const aggregate = context.task.aggregate; + if (aggregate === null) throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + return digestCanonicalJson({ + aggregate, + checkpointDigest: checkpointDigest(checkpoint), + codeRef: { head: codeHead }, + }); +} + +function requireCodeHead(context: OpenedV3TaskOperation): string { + if (context.codeHead === null) { + throw new Error('MANCODE_TASK_HEAD_CODE_REF_UNAVAILABLE'); + } + return context.codeHead; +} + +function assertHandoffBundleCurrent( + context: OpenedV3TaskOperation, + handoff: HandoffV1, +): void { + assertLocalHandoffTransport(context); + assertHandoffTaskEligible(context.task.metadata); + const checkpoint = context.task.latestCheckpoint; + const codeHead = requireCodeHead(context); + if ( + context.task.metadata.revision !== handoff.taskRevision || + context.task.metadata.ownershipEpoch !== handoff.ownershipEpochAtOffer || + checkpoint === null || + checkpoint.checkpointId !== handoff.checkpointRef.artifactId || + handoff.transport.mode !== 'local' || + handoff.transport.state !== 'local_only' || + handoff.transport.codeReachable !== true || + handoff.transport.codeRef.head !== codeHead || + handoff.transport.taskBundleDigest !== + handoffBundleDigest(context, checkpoint, codeHead) + ) { + throw new Error('MANCODE_HANDOFF_TASK_UNAVAILABLE'); + } +} + +function assertTransitionState( + handoff: HandoffV1, + intent: 'offer' | 'reject' | 'cancel', +): void { + if (intent === 'offer' && handoff.state !== 'draft') { + throw new Error('MANCODE_HANDOFF_NOT_DRAFT'); + } + if (intent === 'reject' && handoff.state !== 'offered') { + throw new Error('MANCODE_HANDOFF_NOT_OFFERED'); + } + if ( + intent === 'cancel' && + handoff.state !== 'draft' && + handoff.state !== 'offered' + ) { + throw new Error('MANCODE_HANDOFF_NOT_CANCELLABLE'); + } +} + +function nextHandoffTransition( + previous: HandoffV1, + intent: 'offer' | 'reject' | 'cancel', + actorId: Ulid, + reason: string | undefined, + operationId: Ulid, + updatedAt: string, +): HandoffV1 { + if (intent === 'offer') { + return parseHandoff({ + ...previous, + state: 'offered', + revision: previous.revision + 1, + lastOperationId: operationId, + offeredAt: updatedAt, + updatedAt, + }); + } + if (intent === 'reject') { + return parseHandoff({ + ...previous, + state: 'rejected', + revision: previous.revision + 1, + lastOperationId: operationId, + resolution: { + state: 'rejected', + actorId, + at: updatedAt, + reason: reason?.trim() ?? null, + }, + updatedAt, + }); + } + return parseHandoff({ + ...previous, + state: 'cancelled', + revision: previous.revision + 1, + lastOperationId: operationId, + resolution: { + state: 'cancelled', + actorId, + at: updatedAt, + reason: reason?.trim() || null, + }, + updatedAt, + }); +} + +function sameUlidSet(left: readonly Ulid[], right: readonly Ulid[]): boolean { + const normalizedLeft = [...left].sort(compareUtf8); + const normalizedRight = [...right].sort(compareUtf8); + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((value, index) => value === normalizedRight[index]) + ); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function assertPositiveRevision(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error( + `MANCODE_${label.toUpperCase().replaceAll(' ', '_')}_INVALID`, + ); + } +} + +/** Stable future successor IDs for the same accept operation and claim. */ +export function handoffSuccessorClaimId( + operationId: Ulid, + predecessorClaimId: Ulid, + createdAt: string, +): Ulid { + assertUlid(operationId, 'handoff accept operationId'); + assertUlid(predecessorClaimId, 'handoff predecessor claimId'); + const timestamp = Date.parse(createdAt); + if (Number.isNaN(timestamp)) + throw new Error('MANCODE_HANDOFF_TIMESTAMP_INVALID'); + const entropy = createHash('sha256') + .update(`${operationId}:${predecessorClaimId}`, 'utf8') + .digest() + .subarray(0, 10); + return createUlid(timestamp, entropy); +} diff --git a/src/team/handoff.ts b/src/team/handoff.ts new file mode 100644 index 0000000..3802553 --- /dev/null +++ b/src/team/handoff.ts @@ -0,0 +1,534 @@ +import { + type ArtifactRef, + assertReferenceNamespace, + parseArtifactRef, +} from '../context/artifact-ref.js'; +import { sortUtf8StringSet } from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { assertSharedTextSafe } from '../context/privacy.js'; +import { + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import type { CodeRef, CoordinationMode } from './claims.js'; + +export type HandoffState = + | 'draft' + | 'offered' + | 'accepted' + | 'rejected' + | 'cancelled'; +export type HandoffTransportState = + | 'local_only' + | 'published' + | 'fetched' + | 'stale'; + +export interface HandoffSummary { + completed: string[]; + inProgress: string[]; + notStarted: string[]; + changedFiles: string[]; + verification: string[]; + blockers: string[]; + risks: string[]; + nextAction: string; +} + +export interface HandoffTransport { + mode: CoordinationMode; + state: HandoffTransportState; + transportRevision: number | null; + publishedAt: string | null; + fetchedAt: string | null; + taskBundleDigest: string; + codeRef: CodeRef; + codeReachable: boolean; + receipt: string | null; +} + +export interface HandoffResolution { + state: Extract; + actorId: Ulid; + at: string; + reason: string | null; +} + +export interface HandoffV1 { + schemaVersion: 1; + handoffId: Ulid; + taskRef: TaskRef; + taskRevision: number; + /** Durable fence snapshot used to reject a stale owner transfer. */ + ownershipEpochAtOffer: number; + state: HandoffState; + revision: number; + fromActorId: Ulid; + toActorId: Ulid; + claimIds: Ulid[]; + checkpointRef: ArtifactRef; + summary: HandoffSummary; + transport: HandoffTransport; + lastOperationId: Ulid | null; + offeredAt: string | null; + resolution: HandoffResolution | null; + createdAt: string; + updatedAt: string; +} + +const HANDOFF_STATES = new Set([ + 'draft', + 'offered', + 'accepted', + 'rejected', + 'cancelled', +]); +const TRANSPORT_STATES = new Set([ + 'local_only', + 'published', + 'fetched', + 'stale', +]); +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export function parseHandoff(value: unknown): HandoffV1 { + assertRecord(value, 'handoff'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'handoffId', + 'taskRef', + 'taskRevision', + 'ownershipEpochAtOffer', + 'state', + 'revision', + 'fromActorId', + 'toActorId', + 'claimIds', + 'checkpointRef', + 'summary', + 'transport', + 'lastOperationId', + 'offeredAt', + 'resolution', + 'createdAt', + 'updatedAt', + ], + 'handoff', + ); + if (value.schemaVersion !== 1) + throw new Error('handoff schemaVersion must be 1'); + assertUlid(value.handoffId, 'handoffId'); + assertUlid(value.fromActorId, 'handoff fromActorId'); + assertUlid(value.toActorId, 'handoff toActorId'); + if (value.fromActorId === value.toActorId) { + throw new Error('handoff requires a distinct receiving actor'); + } + const taskRef = parseTaskRefValue(value.taskRef); + if (taskRef.namespace !== 'shared') { + throw new Error('handoffs may only target shared TaskRefs'); + } + const checkpointRef = parseArtifactRef(value.checkpointRef); + assertReferenceNamespace('shared', checkpointRef); + if ( + checkpointRef.kind !== 'checkpoint' || + !sameTaskRef(checkpointRef.taskRef, taskRef) + ) { + throw new Error( + 'handoff checkpointRef must be a checkpoint for the same task', + ); + } + const state = parseHandoffState(value.state); + const handoff: HandoffV1 = { + schemaVersion: 1, + handoffId: value.handoffId, + taskRef, + taskRevision: parsePositiveInteger( + value.taskRevision, + 'handoff taskRevision', + ), + ownershipEpochAtOffer: parseNonNegativeInteger( + value.ownershipEpochAtOffer, + 'handoff ownershipEpochAtOffer', + ), + state, + revision: parsePositiveInteger(value.revision, 'handoff revision'), + fromActorId: value.fromActorId, + toActorId: value.toActorId, + claimIds: parseUlidSet(value.claimIds, 'handoff claimIds'), + checkpointRef, + summary: parseSummary(value.summary), + transport: parseTransport(value.transport), + lastOperationId: parseUlidOrNull( + value.lastOperationId, + 'handoff lastOperationId', + ), + offeredAt: parseTimestampOrNull(value.offeredAt, 'handoff offeredAt'), + resolution: parseResolution(value.resolution), + createdAt: parseTimestamp(value.createdAt, 'handoff createdAt'), + updatedAt: parseTimestamp(value.updatedAt, 'handoff updatedAt'), + }; + assertHandoffStateShape(handoff); + if (handoff.transport.receipt !== null) { + assertSharedTextSafe( + handoff.transport.receipt, + 'handoff transport receipt', + ); + } + if (handoff.resolution !== null && handoff.resolution.reason !== null) { + assertSharedTextSafe( + handoff.resolution.reason, + 'handoff resolution reason', + ); + } + return handoff; +} + +export function assertHandoffTransition( + previous: HandoffV1, + next: HandoffV1, + actorId: Ulid, +): void { + assertUlid(actorId, 'handoff transition actorId'); + assertHandoffIdentityIsStable(previous, next); + if (next.revision !== previous.revision + 1) { + throw new Error('handoff revision must increase exactly once per mutation'); + } + if (previous.state === next.state) return; + if (!allowedHandoffTransitions(previous.state).has(next.state)) { + throw new Error( + `invalid handoff state transition: ${previous.state} -> ${next.state}`, + ); + } + if ( + (next.state === 'accepted' || next.state === 'rejected') && + actorId !== next.toActorId + ) { + throw new Error('only the receiving actor can accept or reject a handoff'); + } +} + +function parseHandoffState(value: unknown): HandoffState { + if (typeof value !== 'string' || !HANDOFF_STATES.has(value as HandoffState)) { + throw new Error('handoff state is invalid'); + } + return value as HandoffState; +} + +function parseSummary(value: unknown): HandoffSummary { + assertRecord(value, 'handoff summary'); + const fields = [ + 'completed', + 'inProgress', + 'notStarted', + 'changedFiles', + 'verification', + 'blockers', + 'risks', + 'nextAction', + ] as const; + assertKnownKeys(value, fields, 'handoff summary'); + if (typeof value.nextAction !== 'string' || !value.nextAction.trim()) { + throw new Error('handoff summary nextAction is required'); + } + const summary: HandoffSummary = { + completed: parseSummaryItems(value.completed, 'handoff summary completed'), + inProgress: parseSummaryItems( + value.inProgress, + 'handoff summary inProgress', + ), + notStarted: parseSummaryItems( + value.notStarted, + 'handoff summary notStarted', + ), + changedFiles: parseSummaryItems( + value.changedFiles, + 'handoff summary changedFiles', + ), + verification: parseSummaryItems( + value.verification, + 'handoff summary verification', + ), + blockers: parseSummaryItems(value.blockers, 'handoff summary blockers'), + risks: parseSummaryItems(value.risks, 'handoff summary risks'), + nextAction: value.nextAction, + }; + for (const [label, items] of [ + ['completed', summary.completed], + ['inProgress', summary.inProgress], + ['notStarted', summary.notStarted], + ['changedFiles', summary.changedFiles], + ['verification', summary.verification], + ['blockers', summary.blockers], + ['risks', summary.risks], + ['nextAction', [summary.nextAction]], + ] as const) { + for (const item of items) { + assertSharedTextSafe(item, `handoff summary ${label}`); + } + } + return summary; +} + +function parseTransport(value: unknown): HandoffTransport { + assertRecord(value, 'handoff transport'); + assertKnownKeys( + value, + [ + 'mode', + 'state', + 'transportRevision', + 'publishedAt', + 'fetchedAt', + 'taskBundleDigest', + 'codeRef', + 'codeReachable', + 'receipt', + ], + 'handoff transport', + ); + if (value.mode !== 'local' && value.mode !== 'git-ref') { + throw new Error('handoff transport mode must be local or git-ref'); + } + if ( + typeof value.state !== 'string' || + !TRANSPORT_STATES.has(value.state as HandoffTransportState) + ) { + throw new Error('handoff transport state is invalid'); + } + if ( + typeof value.taskBundleDigest !== 'string' || + !DIGEST_PATTERN.test(value.taskBundleDigest) + ) { + throw new Error('handoff transport taskBundleDigest is invalid'); + } + if (typeof value.codeReachable !== 'boolean') { + throw new Error('handoff transport codeReachable must be boolean'); + } + const transport: HandoffTransport = { + mode: value.mode, + state: value.state as HandoffTransportState, + transportRevision: parseRevisionOrNull( + value.transportRevision, + 'handoff transportRevision', + ), + publishedAt: parseTimestampOrNull(value.publishedAt, 'handoff publishedAt'), + fetchedAt: parseTimestampOrNull(value.fetchedAt, 'handoff fetchedAt'), + taskBundleDigest: value.taskBundleDigest, + codeRef: parseCodeRef(value.codeRef, 'handoff transport codeRef'), + codeReachable: value.codeReachable, + receipt: parseNonEmptyStringOrNull( + value.receipt, + 'handoff transport receipt', + ), + }; + if (transport.mode === 'local' && transport.state !== 'local_only') { + throw new Error('local handoff transport can only be local_only'); + } + if ( + transport.state === 'published' && + (transport.publishedAt === null || transport.receipt === null) + ) { + throw new Error( + 'published handoffs require publishedAt and a transport receipt', + ); + } + if (transport.state === 'fetched' && transport.fetchedAt === null) { + throw new Error('fetched handoffs require fetchedAt'); + } + return transport; +} + +function parseResolution(value: unknown): HandoffResolution | null { + if (value === null) return null; + assertRecord(value, 'handoff resolution'); + assertKnownKeys( + value, + ['state', 'actorId', 'at', 'reason'], + 'handoff resolution', + ); + if ( + value.state !== 'accepted' && + value.state !== 'rejected' && + value.state !== 'cancelled' + ) { + throw new Error('handoff resolution state is invalid'); + } + assertUlid(value.actorId, 'handoff resolution actorId'); + return { + state: value.state, + actorId: value.actorId, + at: parseTimestamp(value.at, 'handoff resolution at'), + reason: parseNonEmptyStringOrNull( + value.reason, + 'handoff resolution reason', + ), + }; +} + +function parseCodeRef(value: unknown, label: string): CodeRef { + assertRecord(value, label); + assertKnownKeys(value, ['branch', 'head'], label); + if ( + typeof value.branch !== 'string' || + !value.branch.trim() || + typeof value.head !== 'string' || + !value.head.trim() + ) { + throw new Error(`${label} branch and head are required`); + } + return { branch: value.branch, head: value.head }; +} + +function parseSummaryItems(value: unknown, label: string): string[] { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== 'string' || !item.trim()) + ) { + throw new Error(`${label} must be an array of non-empty strings`); + } + return [...value] as string[]; +} + +function parseUlidSet(value: unknown, label: string): Ulid[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + const normalized = sortUtf8StringSet(value); + if (normalized.length !== value.length) { + throw new Error(`${label} must not contain duplicates`); + } + for (const item of normalized) { + assertUlid(item, label); + } + return normalized as Ulid[]; +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parseNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseRevisionOrNull(value: unknown, label: string): number | null { + if (value === null) return null; + return parsePositiveInteger(value, label); +} + +function parseUlidOrNull(value: unknown, label: string): Ulid | null { + if (value === null) return null; + assertUlid(value, label); + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function parseTimestampOrNull(value: unknown, label: string): string | null { + return value === null ? null : parseTimestamp(value, label); +} + +function parseNonEmptyStringOrNull( + value: unknown, + label: string, +): string | null { + if (value === null) return null; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} must be a non-empty string or null`); + } + return value; +} + +function assertHandoffStateShape(handoff: HandoffV1): void { + if (handoff.state === 'draft') { + if (handoff.offeredAt !== null || handoff.resolution !== null) { + throw new Error('draft handoffs cannot have offeredAt or a resolution'); + } + return; + } + if (handoff.state === 'offered') { + if (handoff.offeredAt === null || handoff.resolution !== null) { + throw new Error( + 'offered handoffs require offeredAt and cannot have a resolution', + ); + } + return; + } + if ( + handoff.resolution === null || + handoff.resolution.state !== handoff.state + ) { + throw new Error('terminal handoffs require a matching resolution'); + } + if (handoff.state === 'cancelled') { + // A sender may cancel a named draft before it is offered. Once offered, + // the original timestamp remains as audit evidence, but it is not a + // prerequisite for the cancellation transition itself. + return; + } + if (handoff.offeredAt === null) { + throw new Error('terminal handoffs must have been offered first'); + } + if (handoff.state === 'accepted' && handoff.resolution.reason !== null) { + throw new Error('accepted handoffs cannot have a resolution reason'); + } + if ( + (handoff.state === 'accepted' || handoff.state === 'rejected') && + handoff.resolution.actorId !== handoff.toActorId + ) { + throw new Error( + 'accepted or rejected handoffs require the receiving actor', + ); + } + if (handoff.state === 'rejected' && handoff.resolution.reason === null) { + throw new Error('rejected handoffs require a resolution reason'); + } +} + +function assertHandoffIdentityIsStable( + previous: HandoffV1, + next: HandoffV1, +): void { + if ( + previous.handoffId !== next.handoffId || + !sameTaskRef(previous.taskRef, next.taskRef) || + previous.taskRevision !== next.taskRevision || + previous.ownershipEpochAtOffer !== next.ownershipEpochAtOffer || + previous.fromActorId !== next.fromActorId || + previous.toActorId !== next.toActorId || + JSON.stringify(previous.claimIds) !== JSON.stringify(next.claimIds) || + JSON.stringify(previous.checkpointRef) !== + JSON.stringify(next.checkpointRef) || + previous.createdAt !== next.createdAt + ) { + throw new Error( + 'handoff identity, recipient, claims, and checkpoint are immutable', + ); + } +} + +function allowedHandoffTransitions(from: HandoffState): Set { + switch (from) { + case 'draft': + return new Set(['offered', 'cancelled']); + case 'offered': + return new Set(['accepted', 'rejected', 'cancelled']); + case 'accepted': + case 'rejected': + case 'cancelled': + return new Set(); + } +} diff --git a/src/team/join.ts b/src/team/join.ts new file mode 100644 index 0000000..44f0944 --- /dev/null +++ b/src/team/join.ts @@ -0,0 +1,150 @@ +import { type Ulid, assertUlid } from '../context/ids.js'; +import { + completeProjectionIntent, + enqueueAuditEventProjection, +} from '../runtime/projection-outbox.js'; +import { + type LocalActorIdentityV1, + type SharedActorProfileV1, + createSharedActorProfile, + parseLocalActorIdentity, + publishSharedActorProfile, +} from './actor.js'; +import { type TeamEventV1, parseTeamEvent, writeTeamEvent } from './events.js'; +import { + type ProjectConfigV1, + type TeamPolicyV1, + assertConfigPolicyConsistency, + parseProjectConfig, + parseTeamPolicy, +} from './policy.js'; + +export interface PrepareTeamJoinInput { + actor: LocalActorIdentityV1; + projectConfig: ProjectConfigV1; + teamPolicy: TeamPolicyV1; + operationId: Ulid; + eventId: Ulid; + confirmed: boolean; + sync: boolean; + now?: Date; +} + +export interface TeamJoinPlan { + actor: LocalActorIdentityV1; + profile: SharedActorProfileV1; + event: TeamEventV1; + syncRequested: boolean; + transport: ProjectConfigV1['transport']['mode']; + trustBoundary: 'repo-collaborators'; +} + +export interface TeamJoinSyncPublisher { + publishActorProfile(input: { + operationId: Ulid; + profile: SharedActorProfileV1; + }): Promise<{ receipt: string }>; +} + +export interface JoinTeamInput extends PrepareTeamJoinInput { + projectRoot: string; + syncPublisher?: TeamJoinSyncPublisher; +} + +export interface TeamJoinResult { + profile: SharedActorProfileV1; + event: TeamEventV1; + syncReceipt: string | null; + trustBoundary: 'repo-collaborators'; +} + +/** + * Freezes the `team join` preflight before any shared file is written. The + * caller must obtain an explicit confirmation; there is no implicit publish + * mode or email-derived identity matching. + */ +export function prepareTeamJoin(input: PrepareTeamJoinInput): TeamJoinPlan { + const actor = parseLocalActorIdentity(input.actor); + const projectConfig = parseProjectConfig(input.projectConfig); + const teamPolicy = parseTeamPolicy(input.teamPolicy); + assertConfigPolicyConsistency(projectConfig, teamPolicy); + assertUlid(input.operationId, 'team join operationId'); + assertUlid(input.eventId, 'team join eventId'); + if (input.confirmed !== true) { + throw new Error('MANCODE_JOIN_CONFIRMATION_REQUIRED'); + } + if (typeof input.sync !== 'boolean') { + throw new Error('team join sync must be boolean'); + } + if (input.sync && projectConfig.transport.mode !== 'git-ref') { + throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); + } + const profile = createSharedActorProfile(actor, input.now); + const event = parseTeamEvent({ + schemaVersion: 1, + eventId: input.eventId, + eventType: 'actor_joined', + operationId: input.operationId, + entityRef: { kind: 'actor', id: actor.actorId }, + taskRef: null, + actorId: actor.actorId, + taskRevision: null, + createdAt: (input.now ?? new Date()).toISOString(), + }); + return { + actor, + profile, + event, + syncRequested: input.sync, + transport: projectConfig.transport.mode, + trustBoundary: 'repo-collaborators', + }; +} + +/** + * Profile publication is authoritative for joining. The audit event is last, + * so a failure to emit it can be reconciled from this completed join plan and + * never rolls the profile back. A requested remote publish is explicit. + */ +export async function joinTeam(input: JoinTeamInput): Promise { + const plan = prepareTeamJoin(input); + const projection = await enqueueAuditEventProjection( + input.projectRoot, + plan.event, + input.now, + ); + const profile = await publishSharedActorProfile( + input.projectRoot, + plan.profile, + ); + let syncReceipt: string | null = null; + if (plan.syncRequested) { + if (input.syncPublisher === undefined) { + throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); + } + const result = await input.syncPublisher.publishActorProfile({ + operationId: plan.event.operationId, + profile, + }); + syncReceipt = parseReceipt(result.receipt); + } + const event = await writeTeamEvent(input.projectRoot, plan.event); + try { + await completeProjectionIntent( + input.projectRoot, + projection.operationId, + projection.projectionId, + input.now, + ); + } catch { + // The event itself is durable; doctor can close a stale pending intent. + } + return { profile, event, syncReceipt, trustBoundary: plan.trustBoundary }; +} + +function parseReceipt(value: unknown): string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error('MANCODE_TRANSPORT_RECEIPT_INVALID'); + } + return value; +} diff --git a/src/team/policy.ts b/src/team/policy.ts new file mode 100644 index 0000000..e17a229 --- /dev/null +++ b/src/team/policy.ts @@ -0,0 +1,338 @@ +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; + +export type CoordinationTransport = 'local' | 'git-ref'; +export type TeamRecommendationPolicy = 'on' | 'off' | 'auto'; + +export interface ProjectConfigV1 { + schemaVersion: 1; + revision: number; + workspaceId: Ulid; + transport: { + mode: CoordinationTransport; + remote: string | null; + /** Monotonic authority generation; every transport switch creates a new domain. */ + epoch: number; + }; + lastOperationId: Ulid | null; + updatedAt: string; +} + +export interface TeamPolicyV1 { + schemaVersion: 1; + revision: number; + workspaceId: Ulid; + policy: TeamRecommendationPolicy; + recentDays: number; + defaultVisibility: 'local' | 'shared'; + shareConfirmedDecisions: boolean; + retention: { + localRawArtifactDays: number; + localCacheDays: number; + completedSessionDays: number; + }; + lastOperationId: Ulid | null; + updatedAt: string; +} + +export type ProjectConfigTransitionKind = + | 'ordinary' + | 'transport_set' + | 'transport_migrate'; + +export const V3_LAYOUT_VERSION = 3; + +export function parseProjectConfig(value: unknown): ProjectConfigV1 { + assertRecord(value, 'project config'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'revision', + 'workspaceId', + 'transport', + 'lastOperationId', + 'updatedAt', + ], + 'project config', + ); + if (value.schemaVersion !== 1) { + throw new Error('project config schemaVersion must be 1'); + } + assertUlid(value.workspaceId, 'project config workspaceId'); + return { + schemaVersion: 1, + revision: parsePositiveInteger(value.revision, 'project config revision'), + workspaceId: value.workspaceId, + transport: parseTransport(value.transport), + lastOperationId: parseUlidOrNull( + value.lastOperationId, + 'project config lastOperationId', + ), + updatedAt: parseTimestamp(value.updatedAt, 'project config updatedAt'), + }; +} + +export function parseTeamPolicy(value: unknown): TeamPolicyV1 { + assertRecord(value, 'team policy'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'revision', + 'workspaceId', + 'policy', + 'recentDays', + 'defaultVisibility', + 'shareConfirmedDecisions', + 'retention', + 'lastOperationId', + 'updatedAt', + ], + 'team policy', + ); + if (value.schemaVersion !== 1) { + throw new Error('team policy schemaVersion must be 1'); + } + assertUlid(value.workspaceId, 'team policy workspaceId'); + if ( + value.policy !== 'on' && + value.policy !== 'off' && + value.policy !== 'auto' + ) { + throw new Error('team policy policy is invalid'); + } + if ( + value.defaultVisibility !== 'local' && + value.defaultVisibility !== 'shared' + ) { + throw new Error('team policy defaultVisibility is invalid'); + } + if (typeof value.shareConfirmedDecisions !== 'boolean') { + throw new Error('team policy shareConfirmedDecisions must be boolean'); + } + return { + schemaVersion: 1, + revision: parsePositiveInteger(value.revision, 'team policy revision'), + workspaceId: value.workspaceId, + policy: value.policy, + recentDays: parseNonNegativeInteger( + value.recentDays, + 'team policy recentDays', + ), + defaultVisibility: value.defaultVisibility, + shareConfirmedDecisions: value.shareConfirmedDecisions, + retention: parseRetention(value.retention), + lastOperationId: parseUlidOrNull( + value.lastOperationId, + 'team policy lastOperationId', + ), + updatedAt: parseTimestamp(value.updatedAt, 'team policy updatedAt'), + }; +} + +export function projectConfigIdentityDigest(config: ProjectConfigV1): string { + return digestCanonicalJson({ + workspaceId: config.workspaceId, + configSchemaVersion: config.schemaVersion, + layoutVersion: V3_LAYOUT_VERSION, + }); +} + +/** Binds a remote transport manifest to the exact active configuration CAS. */ +export function projectConfigDigest(config: ProjectConfigV1): string { + return digestCanonicalJson(parseProjectConfig(config)); +} + +export function assertConfigPolicyConsistency( + config: ProjectConfigV1, + policy: TeamPolicyV1, +): void { + if (config.workspaceId !== policy.workspaceId) { + throw new Error('project config and team policy workspaceId must match'); + } +} + +export function assertProjectConfigTransition( + previous: ProjectConfigV1, + next: ProjectConfigV1, + kind: ProjectConfigTransitionKind, +): void { + assertConfigIdentityStable(previous, next); + assertRevisionIncrease( + previous.revision, + next.revision, + 'project config revision', + ); + const transportChanged = + previous.transport.mode !== next.transport.mode || + previous.transport.remote !== next.transport.remote || + previous.transport.epoch !== next.transport.epoch; + if (transportChanged && kind === 'ordinary') { + throw new Error( + 'project config transport may only change through transport_set or transport_migrate', + ); + } + if (!transportChanged && kind !== 'ordinary') { + throw new Error( + 'transport mutation requires a changed project config transport', + ); + } + if ( + kind !== 'ordinary' && + next.transport.epoch !== previous.transport.epoch + 1 + ) { + throw new Error( + 'transport mutation must increase the authority epoch exactly once', + ); + } +} + +export function assertTeamPolicyTransition( + previous: TeamPolicyV1, + next: TeamPolicyV1, +): void { + if ( + previous.schemaVersion !== next.schemaVersion || + previous.workspaceId !== next.workspaceId + ) { + throw new Error('team policy schemaVersion and workspaceId are immutable'); + } + assertRevisionIncrease( + previous.revision, + next.revision, + 'team policy revision', + ); +} + +/** Ordinary mutations use one authority's CAS at a time. */ +export function assertIndependentConfigPolicyUpdate( + previousConfig: ProjectConfigV1, + nextConfig: ProjectConfigV1, + previousPolicy: TeamPolicyV1, + nextPolicy: TeamPolicyV1, +): void { + assertConfigPolicyConsistency(nextConfig, nextPolicy); + const configChanged = previousConfig.revision !== nextConfig.revision; + const policyChanged = previousPolicy.revision !== nextPolicy.revision; + if (configChanged && policyChanged) { + throw new Error( + 'project config and team policy cannot be updated by one ordinary patch', + ); + } +} + +function parseTransport(value: unknown): ProjectConfigV1['transport'] { + assertRecord(value, 'project config transport'); + assertKnownKeys( + value, + ['mode', 'remote', 'epoch'], + 'project config transport', + ); + if (value.mode !== 'local' && value.mode !== 'git-ref') { + throw new Error('project config transport mode is invalid'); + } + const remote = parseNonEmptyStringOrNull( + value.remote, + 'project config transport remote', + ); + if (value.mode === 'local' && remote !== null) { + throw new Error('local project config transport must not set a remote'); + } + if (value.mode === 'git-ref' && remote === null) { + throw new Error('git-ref project config transport requires a remote'); + } + // V3 configurations created before remote coordination existed did not + // persist an epoch. They belong to the initial local authority generation. + const epoch = + value.epoch === undefined + ? 1 + : parsePositiveInteger(value.epoch, 'project config transport epoch'); + return { mode: value.mode, remote, epoch }; +} + +function parseRetention(value: unknown): TeamPolicyV1['retention'] { + assertRecord(value, 'team policy retention'); + assertKnownKeys( + value, + ['localRawArtifactDays', 'localCacheDays', 'completedSessionDays'], + 'team policy retention', + ); + return { + localRawArtifactDays: parseNonNegativeInteger( + value.localRawArtifactDays, + 'team policy retention localRawArtifactDays', + ), + localCacheDays: parseNonNegativeInteger( + value.localCacheDays, + 'team policy retention localCacheDays', + ), + completedSessionDays: parseNonNegativeInteger( + value.completedSessionDays, + 'team policy retention completedSessionDays', + ), + }; +} + +function assertConfigIdentityStable( + previous: ProjectConfigV1, + next: ProjectConfigV1, +): void { + if ( + previous.schemaVersion !== next.schemaVersion || + previous.workspaceId !== next.workspaceId + ) { + throw new Error( + 'project config schemaVersion and workspaceId are immutable', + ); + } +} + +function assertRevisionIncrease( + previous: number, + next: number, + label: string, +): void { + if (next !== previous + 1) { + throw new Error(`${label} must increase exactly once per mutation`); + } +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parseNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseUlidOrNull(value: unknown, label: string): Ulid | null { + if (value === null) return null; + assertUlid(value, label); + return value; +} + +function parseNonEmptyStringOrNull( + value: unknown, + label: string, +): string | null { + if (value === null) return null; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} must be a non-empty string or null`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} diff --git a/src/team/transport-migration-adapters.ts b/src/team/transport-migration-adapters.ts new file mode 100644 index 0000000..696f132 --- /dev/null +++ b/src/team/transport-migration-adapters.ts @@ -0,0 +1,1724 @@ +import { + lstat, + mkdir, + readFile, + readdir, + rename, + unlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { V3ContextStore } from '../context/store.js'; +import type { TaskRef } from '../context/task-ref.js'; +import { parseWorkflowMetadata } from '../context/workflow-metadata.js'; +import { replaceFileAtomically } from '../runtime/atomic-file.js'; +import { createClaim, listClaims } from '../runtime/claim-store.js'; +import { + type EntityHomeStore, + claimDirectory, + handoffDirectory, + resolveCoordinationEntityHomeStore, + taskHeadDirectory, +} from '../runtime/entity-home-store.js'; +import { createHandoff, listHandoffs } from '../runtime/handoff-store.js'; +import { acquireEntityLocks } from '../runtime/local-lock.js'; +import { listUnfinishedOperationJournals } from '../runtime/operation-store.js'; +import { + readCheckoutBranch, + readProjectRuntimeContext, +} from '../runtime/project-runtime.js'; +import { parseTaskHeadFence } from '../runtime/task-head-fence.js'; +import { + readTaskHeadFence, + replaceTaskHeadFence, +} from '../runtime/task-head-store.js'; +import { + gitRefCoordinationDomainId, + localCoordinationDomainId, +} from '../runtime/workspace-binding.js'; +import { + publishSharedActorProfile, + readSharedActorProfile, + sharedActorProfileDirectory, +} from './actor.js'; +import { checkpointDigest, parseCheckpoint } from './checkpoints.js'; +import { type ClaimV1, parseClaim } from './claims.js'; +import { + assertGitRefBundleCodeReachable, + createGitRefTaskBundle, +} from './git-ref-bundle.js'; +import { materializeGitRefTaskBundle } from './git-ref-materialization.js'; +import { + type GitRefOwnershipFenceV1, + type GitRefTaskBundleV1, + GitRefTeamManifestStore, + type GitRefTeamManifestV1, + resolveGitRefRemoteIdentityHash, +} from './git-ref-transport.js'; +import { handoffSuccessorClaimId } from './handoff-operation.js'; +import { type HandoffV1, parseHandoff } from './handoff.js'; +import { + type CoordinationTransport, + type ProjectConfigV1, + assertProjectConfigTransition, + parseProjectConfig, + projectConfigDigest, +} from './policy.js'; +import { + type LocalTransportAuthorityStateV1, + parseTransportAuthorityTombstone, + readLocalTransportAuthorityState, + writeLocalTransportAuthorityState, +} from './transport-migration-freeze.js'; +import { + type EstablishedTransportAuthorityV1, + type StagedTransportAuthorityV1, + type TransportAuthorityTombstoneV1, + type TransportMigrationAuthoritySnapshotV1, + type TransportMigrationConfigAdapter, + type TransportMigrationManifestV1, + type TransportMigrationSourceAdapter, + type TransportMigrationTargetAdapter, + parseEstablishedTransportAuthority, + parseStagedTransportAuthority, + parseTransportMigrationManifest, +} from './transport-migration.js'; + +const STAGE_DIRECTORY = 'transport-migrations'; +const COLLECTIONS = ['claims', 'handoffs', 'task-heads'] as const; + +export { + type LocalTransportAuthorityStateV1, + assertLocalCoordinationWriteAllowed, + localTransportAuthorityStatePath, + parseLocalTransportAuthorityState, + readLocalTransportAuthorityState, +} from './transport-migration-freeze.js'; + +export interface CreateTransportMigrationFileAdaptersInput { + projectRoot: string; + actorId: Ulid; + targetMode: CoordinationTransport; + targetRemote?: string | null; + /** Allows recovery to bind adapters from the durable stage after config CAS. */ + operationId?: Ulid; + now?: () => Date; +} + +export interface TransportMigrationFileAdapters { + operationStore: EntityHomeStore; + checkoutId: Ulid; + config: FileSystemTransportMigrationConfigAdapter; + source: TransportMigrationSourceAdapter; + target: TransportMigrationTargetAdapter; +} + +interface AdapterContext { + projectRoot: string; + actorId: Ulid; + operationStore: EntityHomeStore; + checkoutId: Ulid; + repositoryBindingId: Ulid | null; + schemaEpoch: Ulid; + minReaderVersion: string; + minWriterVersion: string; + sourceConfig: ProjectConfigV1; + targetMode: CoordinationTransport; + targetRemote: string | null; + now: () => Date; +} + +/** + * Builds the real filesystem/git-ref adapter pair used by execute and recover. + * Recovery reads the staged manifest first, so a visible config switch cannot + * accidentally reverse source and target. + */ +export async function createTransportMigrationFileAdapters( + input: CreateTransportMigrationFileAdaptersInput, +): Promise { + const projectRoot = path.resolve(input.projectRoot); + assertUlid(input.actorId, 'transport migration adapter actorId'); + if (input.operationId !== undefined) { + assertUlid(input.operationId, 'transport migration adapter operationId'); + } + const runtime = await readProjectRuntimeContext(projectRoot); + const operationStore = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const project = await new V3ContextStore(projectRoot).readProjectSnapshot(); + const staged = + input.operationId === undefined + ? null + : await readStagedRecord(operationStore, input.operationId); + const sourceConfig = staged?.manifest.source.config ?? project.config; + const targetMode = staged?.manifest.target.mode ?? input.targetMode; + const targetRemote = + staged?.manifest.target.remote ?? + normalizeTargetRemote(targetMode, input.targetRemote); + if ( + targetMode !== input.targetMode || + (input.targetRemote !== undefined && + normalizeTargetRemote(input.targetMode, input.targetRemote) !== + targetRemote) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_STAGE_CONFLICT'); + } + if (sourceConfig.transport.mode === targetMode) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_MODE_UNCHANGED'); + } + const context: AdapterContext = { + projectRoot, + actorId: input.actorId, + operationStore, + checkoutId: runtime.checkoutId, + repositoryBindingId: runtime.repositoryBindingId, + schemaEpoch: project.manifest.epoch, + minReaderVersion: project.manifest.minReaderVersion, + minWriterVersion: project.manifest.minWriterVersion, + sourceConfig, + targetMode, + targetRemote, + now: input.now ?? (() => new Date()), + }; + const [source, target] = await Promise.all([ + createSourceAdapter(context), + createTargetAdapter(context), + ]); + return { + operationStore, + checkoutId: runtime.checkoutId, + config: new FileSystemTransportMigrationConfigAdapter( + projectRoot, + operationStore, + ), + source, + target, + }; +} + +export class FileSystemTransportMigrationConfigAdapter + implements TransportMigrationConfigAdapter +{ + readonly projectRoot: string; + + constructor( + projectRoot: string, + private readonly coordinationStore: EntityHomeStore, + ) { + this.projectRoot = path.resolve(projectRoot); + } + + async read(): Promise { + return readProjectConfigFile(this.projectRoot); + } + + async compareAndSwap(input: { + expectedRevision: number; + expectedTransportEpoch: number; + next: ProjectConfigV1; + }): Promise { + const next = parseProjectConfig(input.next); + if (next.workspaceId !== this.coordinationStore.workspaceId) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + const lockId = createUlid(); + const locks = await acquireEntityLocks(this.coordinationStore, lockId, [ + `config:${next.workspaceId}`, + ]); + try { + const current = await readProjectConfigFile(this.projectRoot); + if (current.workspaceId !== this.coordinationStore.workspaceId) { + throw new Error('MANCODE_WORKSPACE_BINDING_MISMATCH'); + } + if (digestCanonicalJson(current) === digestCanonicalJson(next)) { + return current; + } + if ( + current.revision !== input.expectedRevision || + current.transport.epoch !== input.expectedTransportEpoch + ) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + assertProjectConfigTransition(current, next, 'transport_migrate'); + await writeJsonAtomic(projectConfigPath(this.projectRoot), next); + return readProjectConfigFile(this.projectRoot); + } finally { + await Promise.allSettled( + [...locks].reverse().map((lock) => lock.release()), + ); + } + } +} + +class LocalTransportMigrationSourceAdapter + implements TransportMigrationSourceAdapter +{ + readonly mode = 'local' as const; + readonly remote = null; + + constructor( + private readonly context: AdapterContext, + readonly authorityId: string, + private readonly coordinationDomainId: string, + ) {} + + async freeze(input: { + operationId: Ulid; + expectedTransportEpoch: number; + }): Promise { + assertUlid(input.operationId, 'local authority freeze operationId'); + await this.withAuthorityLock(input.operationId, async () => { + const current = await readLocalTransportAuthorityState( + this.context.operationStore, + ); + if (isTombstoneRetry(current, input.operationId)) return; + if (current?.state === 'frozen') { + assertLocalStateIdentity( + current, + this.context.sourceConfig.workspaceId, + this.authorityId, + this.coordinationDomainId, + input.expectedTransportEpoch, + ); + if ( + current.operationId === input.operationId && + current.successorMode === this.context.targetMode && + current.successorEpoch === input.expectedTransportEpoch + 1 + ) { + return; + } + throw new Error('MANCODE_TRANSPORT_MIGRATION_FREEZE_CONFLICT'); + } + if (current !== null) { + assertLocalStateIdentity( + current, + this.context.sourceConfig.workspaceId, + this.authorityId, + this.coordinationDomainId, + input.expectedTransportEpoch, + ); + if (current.state !== 'active') { + throw new Error('MANCODE_TRANSPORT_MIGRATION_FREEZE_CONFLICT'); + } + } else if ( + input.expectedTransportEpoch !== + this.context.sourceConfig.transport.epoch + ) { + throw new Error('MANCODE_TRANSPORT_EPOCH_CONFLICT'); + } + await writeLocalTransportAuthorityState(this.context.operationStore, { + schemaVersion: 1, + workspaceId: this.context.sourceConfig.workspaceId, + authorityId: this.authorityId, + coordinationDomainId: this.coordinationDomainId, + transportEpoch: input.expectedTransportEpoch, + state: 'frozen', + operationId: input.operationId, + successorMode: this.context.targetMode, + successorEpoch: input.expectedTransportEpoch + 1, + tombstone: null, + updatedAt: this.context.now().toISOString(), + }); + }); + } + + async assertFrozen(input: { + operationId: Ulid; + expectedTransportEpoch: number; + }): Promise { + const current = await readLocalTransportAuthorityState( + this.context.operationStore, + ); + if (current === null) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_NOT_FROZEN'); + } + assertLocalStateIdentity( + current, + this.context.sourceConfig.workspaceId, + this.authorityId, + this.coordinationDomainId, + input.expectedTransportEpoch, + ); + if ( + current.operationId !== input.operationId || + (current.state !== 'frozen' && current.state !== 'tombstoned') + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_NOT_FROZEN'); + } + } + + async inspect(): Promise { + const current = await readLocalTransportAuthorityState( + this.context.operationStore, + ); + if (current !== null) { + assertLocalStateIdentity( + current, + this.context.sourceConfig.workspaceId, + this.authorityId, + this.coordinationDomainId, + this.context.sourceConfig.transport.epoch, + ); + if (current.state === 'tombstoned') { + throw new Error('MANCODE_TRANSPORT_AUTHORITY_TOMBSTONED'); + } + } + const [actorProfiles, taskRefs, claims, handoffs, pending, branch] = + await Promise.all([ + listActorProfiles(this.context.projectRoot), + listSharedTaskRefs(this.context.projectRoot), + listClaims(this.context.operationStore), + listHandoffs(this.context.operationStore), + listUnfinishedOperationJournals(this.context.operationStore), + readCheckoutBranch(this.context.projectRoot), + ]); + const tasks = await Promise.all( + taskRefs.map((taskRef) => this.localTaskSnapshot(taskRef, branch)), + ); + return { + schemaVersion: 1, + workspaceId: this.context.sourceConfig.workspaceId, + authorityId: this.authorityId, + transportMode: 'local', + transportEpoch: this.context.sourceConfig.transport.epoch, + coordinationDomainId: this.coordinationDomainId, + pendingOperationIds: pending.map((journal) => journal.operationId), + actorProfiles, + tasks: tasks.map((item) => item.task), + taskBundles: tasks.map((item) => item.bundle), + claims, + handoffs, + } satisfies TransportMigrationAuthoritySnapshotV1; + } + + async unfreeze(input: { operationId: Ulid }): Promise { + await this.withAuthorityLock(input.operationId, async () => { + const current = await readLocalTransportAuthorityState( + this.context.operationStore, + ); + if (current === null) return; + if ( + current.state === 'active' && + current.operationId === input.operationId + ) { + return; + } + if ( + current.state !== 'frozen' || + current.operationId !== input.operationId + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_FREEZE_CONFLICT'); + } + await writeLocalTransportAuthorityState(this.context.operationStore, { + ...current, + state: 'active', + successorMode: null, + successorEpoch: null, + updatedAt: this.context.now().toISOString(), + }); + }); + } + + async writeTombstone( + tombstone: TransportAuthorityTombstoneV1, + ): Promise { + const parsed = parseTransportAuthorityTombstone(tombstone); + await this.withAuthorityLock(parsed.operationId, async () => { + const current = await readLocalTransportAuthorityState( + this.context.operationStore, + ); + if ( + current?.state === 'tombstoned' && + digestCanonicalJson(current.tombstone) === digestCanonicalJson(parsed) + ) { + return; + } + if ( + current === null || + current.state !== 'frozen' || + current.operationId !== parsed.operationId || + current.transportEpoch !== parsed.sourceTransportEpoch || + current.authorityId !== parsed.sourceAuthorityId + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_TOMBSTONE_CONFLICT'); + } + await writeLocalTransportAuthorityState(this.context.operationStore, { + ...current, + state: 'tombstoned', + successorMode: this.context.targetMode, + successorEpoch: parsed.targetTransportEpoch, + tombstone: parsed, + updatedAt: parsed.createdAt, + }); + }); + } + + private async localTaskSnapshot(taskRef: TaskRef, branch: string | null) { + const store = new V3ContextStore(this.context.projectRoot); + const [snapshot, fence] = await Promise.all([ + store.readTaskSnapshot(taskRef), + readTaskHeadFence(this.context.operationStore, taskRef), + ]); + if ( + snapshot.metadata.transitionState !== 'stable' || + snapshot.metadata.ownerActorId === null || + snapshot.aggregate === null || + fence === null || + fence.remoteRevision !== null || + fence.taskRevision !== snapshot.metadata.revision || + fence.ownershipEpoch !== snapshot.metadata.ownershipEpoch || + fence.aggregateDigest !== digestCanonicalJson(snapshot.aggregate) || + fence.codeRef.head.length === 0 + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_TASK_SNAPSHOT_INVALID'); + } + const bundle = createGitRefTaskBundle({ + task: snapshot, + codeRef: { + branch: branch ?? snapshot.metadata.base?.branch ?? 'HEAD', + head: fence.codeRef.head, + }, + now: new Date(snapshot.metadata.updatedAt), + }); + return { + task: { + taskRef, + transitionState: 'stable' as const, + taskRevision: snapshot.metadata.revision, + ownerActorId: snapshot.metadata.ownerActorId, + ownershipEpoch: snapshot.metadata.ownershipEpoch, + aggregateDigest: bundle.aggregateDigest, + taskHeadFence: fence, + }, + bundle, + }; + } + + private async withAuthorityLock( + operationId: Ulid, + action: () => Promise, + ): Promise { + const locks = await acquireEntityLocks( + this.context.operationStore, + operationId, + [`transport_authority:${this.context.sourceConfig.workspaceId}`], + ); + try { + return await action(); + } finally { + await Promise.allSettled( + [...locks].reverse().map((lock) => lock.release()), + ); + } + } +} + +class GitRefTransportMigrationSourceAdapter + implements TransportMigrationSourceAdapter +{ + readonly mode = 'git-ref' as const; + readonly remote: string; + + constructor( + private readonly context: AdapterContext, + readonly authorityId: string, + private readonly store: GitRefTeamManifestStore, + ) { + this.remote = requireRemote(context.sourceConfig.transport.remote); + } + + async freeze(input: { + operationId: Ulid; + expectedTransportEpoch: number; + }): Promise { + const snapshot = await this.store.pull(); + const manifest = requireRemoteManifest(snapshot.manifest); + if ( + (manifest.authorityState === 'frozen' || + manifest.authorityState === 'tombstoned') && + (manifest.authorityFreeze?.operationId === input.operationId || + manifest.authorityTombstone?.operationId === input.operationId) + ) { + assertRemoteFreezeTarget( + manifest, + this.context.targetMode, + input.expectedTransportEpoch + 1, + ); + return; + } + await this.store.freezeCoordinationAuthority({ + operationId: input.operationId, + actorId: this.context.actorId, + expectedRemoteRevision: manifest.revision, + expectedPriorTransportEpoch: input.expectedTransportEpoch, + successorMode: this.context.targetMode, + successorEpoch: input.expectedTransportEpoch + 1, + }); + } + + async assertFrozen(input: { + operationId: Ulid; + expectedTransportEpoch: number; + }): Promise { + const manifest = requireRemoteManifest((await this.store.pull()).manifest); + if ( + manifest.transportEpoch !== input.expectedTransportEpoch || + (manifest.authorityState !== 'frozen' && + manifest.authorityState !== 'tombstoned') || + (manifest.authorityFreeze?.operationId !== input.operationId && + manifest.authorityTombstone?.operationId !== input.operationId) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_NOT_FROZEN'); + } + } + + async inspect(): Promise { + const manifest = requireRemoteManifest((await this.store.pull()).manifest); + if (manifest.authorityState === 'tombstoned') { + throw new Error('MANCODE_TRANSPORT_AUTHORITY_TOMBSTONED'); + } + const tasks = manifest.ownershipFences.map((fence) => { + const bundle = manifest.taskBundles.find( + (candidate) => candidate.taskRef.taskId === fence.taskRef.taskId, + ); + if (bundle === undefined) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_TASK_SNAPSHOT_INVALID'); + } + const metadata = bundleMetadata(bundle); + if ( + metadata.transitionState !== 'stable' || + metadata.ownerActorId !== fence.ownerActorId || + metadata.ownershipEpoch !== fence.ownershipEpoch || + metadata.revision !== fence.taskRevision + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_TASK_SNAPSHOT_INVALID'); + } + return { + taskRef: fence.taskRef, + transitionState: 'stable' as const, + taskRevision: fence.taskRevision, + ownerActorId: fence.ownerActorId, + ownershipEpoch: fence.ownershipEpoch, + aggregateDigest: fence.aggregateDigest, + taskHeadFence: parseTaskHeadFence({ + schemaVersion: 1, + workspaceId: manifest.workspaceId, + taskRef: fence.taskRef, + fenceRevision: fence.remoteRevision, + taskRevision: fence.taskRevision, + aggregateDigest: fence.aggregateDigest, + ownershipEpoch: fence.ownershipEpoch, + codeRef: { head: bundle.codeRef.head }, + checkoutId: this.context.checkoutId, + remoteRevision: fence.remoteRevision, + lastOperationId: fence.lastOperationId, + updatedAt: fence.updatedAt, + }), + }; + }); + return { + schemaVersion: 1, + workspaceId: manifest.workspaceId, + authorityId: this.authorityId, + transportMode: 'git-ref', + transportEpoch: manifest.transportEpoch, + coordinationDomainId: gitRefCoordinationDomainId( + await resolveGitRefRemoteIdentityHash( + this.context.projectRoot, + this.remote, + ), + manifest.workspaceId, + manifest.transportEpoch, + ), + pendingOperationIds: [], + actorProfiles: manifest.actorProfiles, + tasks, + taskBundles: manifest.taskBundles, + claims: manifest.claims, + handoffs: manifest.handoffs, + } satisfies TransportMigrationAuthoritySnapshotV1; + } + + async unfreeze(input: { operationId: Ulid }): Promise { + const manifest = requireRemoteManifest((await this.store.pull()).manifest); + if ( + manifest.authorityState === 'active' && + manifest.lastOperationId === input.operationId && + manifest.lastMutation?.kind === 'authority_unfreeze' + ) { + return; + } + if ( + manifest.authorityState !== 'frozen' || + manifest.authorityFreeze?.operationId !== input.operationId + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_FREEZE_CONFLICT'); + } + await this.store.unfreezeCoordinationAuthority({ + operationId: input.operationId, + actorId: this.context.actorId, + expectedRemoteRevision: manifest.revision, + expectedPriorTransportEpoch: manifest.transportEpoch, + freezeOperationId: input.operationId, + }); + } + + async writeTombstone( + tombstone: TransportAuthorityTombstoneV1, + ): Promise { + const parsed = parseTransportAuthorityTombstone(tombstone); + const manifest = requireRemoteManifest((await this.store.pull()).manifest); + if ( + manifest.authorityState === 'tombstoned' && + manifest.authorityTombstone?.operationId === parsed.operationId && + manifest.authorityTombstone.successorMode === this.context.targetMode && + manifest.authorityTombstone.successorEpoch === parsed.targetTransportEpoch + ) { + return; + } + await this.store.tombstoneCoordinationAuthority({ + operationId: parsed.operationId, + actorId: this.context.actorId, + expectedRemoteRevision: manifest.revision, + expectedPriorTransportEpoch: parsed.sourceTransportEpoch, + successorMode: this.context.targetMode, + successorEpoch: parsed.targetTransportEpoch, + }); + } +} + +abstract class FileStagedTransportTarget + implements TransportMigrationTargetAdapter +{ + abstract readonly mode: CoordinationTransport; + abstract readonly remote: string | null; + abstract establish(manifest: TransportMigrationManifestV1): Promise; + + constructor( + protected readonly context: AdapterContext, + readonly authorityId: string, + readonly coordinationDomainId: string, + ) {} + + async stage(manifestValue: TransportMigrationManifestV1): Promise { + const manifest = parseTransportMigrationManifest(manifestValue); + this.assertManifestTarget(manifest); + const staged = parseStagedTransportAuthority({ + schemaVersion: 1, + operationId: manifest.operationId, + manifest, + manifestDigest: digestCanonicalJson(manifest), + transportEpoch: manifest.target.transportEpoch, + coordinationDomainId: manifest.target.coordinationDomainId, + stagedAt: manifest.createdAt, + }); + await writeJsonExclusiveOrEqual( + stagedPath(this.context.operationStore, manifest.operationId), + staged, + parseStagedTransportAuthority, + 'MANCODE_TRANSPORT_MIGRATION_STAGE_CONFLICT', + ); + return staged; + } + + async readStaged(operationId: Ulid): Promise { + return readStagedRecord(this.context.operationStore, operationId); + } + + async readEstablished(operationId: Ulid): Promise { + const stored = await readJsonOrNull( + establishedPath(this.context.operationStore, operationId), + parseEstablishedTransportAuthority, + 'MANCODE_TRANSPORT_MIGRATION_ESTABLISHED_CORRUPT', + ); + return stored ?? this.recoverEstablished(operationId); + } + + async discard(input: { + operationId: Ulid; + manifestDigest: string; + }): Promise { + const established = await this.readEstablished(input.operationId); + if (established !== null) { + throw new Error('MANCODE_OPERATION_ABORT_UNSAFE'); + } + const staged = await readStagedRecord( + this.context.operationStore, + input.operationId, + ); + if (staged === null) return; + if (staged.manifestDigest !== input.manifestDigest) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_STAGE_CONFLICT'); + } + await unlinkIfExists( + stagedPath(this.context.operationStore, input.operationId), + ); + } + + protected abstract recoverEstablished( + operationId: Ulid, + ): Promise; + + protected async writeEstablished( + value: EstablishedTransportAuthorityV1, + ): Promise { + const parsed = parseEstablishedTransportAuthority(value); + await writeJsonExclusiveOrEqual( + establishedPath(this.context.operationStore, parsed.operationId), + parsed, + parseEstablishedTransportAuthority, + 'MANCODE_TRANSPORT_MIGRATION_ESTABLISH_CONFLICT', + ); + return parsed; + } + + protected assertManifestTarget(manifest: TransportMigrationManifestV1): void { + if ( + manifest.target.mode !== this.mode || + manifest.target.remote !== this.remote || + manifest.target.authorityId !== this.authorityId || + manifest.target.coordinationDomainId !== this.coordinationDomainId + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_STAGE_CONFLICT'); + } + } +} + +class GitRefTransportMigrationTargetAdapter extends FileStagedTransportTarget { + readonly mode = 'git-ref' as const; + readonly remote: string; + + constructor( + context: AdapterContext, + authorityId: string, + coordinationDomainId: string, + ) { + super(context, authorityId, coordinationDomainId); + this.remote = requireRemote(context.targetRemote); + } + + async establish( + manifestValue: TransportMigrationManifestV1, + ): Promise { + const manifest = parseTransportMigrationManifest(manifestValue); + this.assertManifestTarget(manifest); + const existing = await this.readEstablished(manifest.operationId); + if (existing !== null) return existing; + await requireStagedManifest(this.context.operationStore, manifest); + const nextConfig = migrationTargetConfig(manifest); + const store = this.targetStore(manifest, nextConfig); + const probe = await new GitRefTeamManifestStore({ + projectRoot: this.context.projectRoot, + remote: this.remote, + workspaceId: manifest.workspaceId, + now: () => new Date(manifest.createdAt), + }).pull(); + const remoteRevision = probe.manifest?.revision ?? 0; + const nextRemoteRevision = remoteRevision + 1; + const activeClaims = reissueActiveClaims(manifest, nextRemoteRevision); + const handoffs = migrateHandoffs(manifest, nextRemoteRevision); + const ownershipFences = manifest.tasks.map((task) => ({ + schemaVersion: 1 as const, + taskRef: task.taskRef, + ownerActorId: requireOwner(task.ownerActorId), + ownershipEpoch: task.ownershipEpoch, + taskRevision: task.taskRevision, + aggregateDigest: task.aggregateDigest, + remoteRevision: nextRemoteRevision, + lastOperationId: manifest.operationId, + updatedAt: manifest.createdAt, + })); + const result = await store.establishCoordinationAuthority({ + operationId: manifest.operationId, + actorId: manifest.actorId, + expectedRemoteRevision: remoteRevision, + expectedRemoteTransportEpoch: probe.manifest?.transportEpoch ?? null, + expectedPriorTransportEpoch: manifest.source.config.transport.epoch, + targetTransportEpoch: manifest.target.transportEpoch, + actorProfiles: manifest.actorProfiles, + ownershipFences, + claims: activeClaims, + handoffs, + taskBundles: manifest.taskBundles, + }); + return this.writeEstablished({ + schemaVersion: 1, + operationId: manifest.operationId, + manifestDigest: digestCanonicalJson(manifest), + transportEpoch: manifest.target.transportEpoch, + coordinationDomainId: manifest.target.coordinationDomainId, + authorityRevision: result.remoteRevision, + activeClaims, + receipt: result.receipt, + establishedAt: manifest.createdAt, + }); + } + + protected async recoverEstablished( + operationId: Ulid, + ): Promise { + const staged = await readStagedRecord( + this.context.operationStore, + operationId, + ); + if (staged === null || staged.manifest.target.mode !== 'git-ref') + return null; + const manifest = staged.manifest; + const snapshot = await new GitRefTeamManifestStore({ + projectRoot: this.context.projectRoot, + remote: this.remote, + workspaceId: manifest.workspaceId, + now: () => new Date(manifest.createdAt), + }).pull(); + const remote = snapshot.manifest; + if ( + remote === null || + remote.authorityState !== 'active' || + remote.transportEpoch !== manifest.target.transportEpoch || + remote.lastOperationId !== operationId || + remote.lastMutation?.kind !== 'authority_establish' || + snapshot.receipt === null + ) { + return null; + } + return this.writeEstablished({ + schemaVersion: 1, + operationId, + manifestDigest: staged.manifestDigest, + transportEpoch: manifest.target.transportEpoch, + coordinationDomainId: manifest.target.coordinationDomainId, + authorityRevision: remote.revision, + activeClaims: remote.claims.filter((claim) => claim.state === 'active'), + receipt: snapshot.receipt, + establishedAt: remote.updatedAt, + }); + } + + private targetStore( + manifest: TransportMigrationManifestV1, + nextConfig: ProjectConfigV1, + ): GitRefTeamManifestStore { + return new GitRefTeamManifestStore({ + projectRoot: this.context.projectRoot, + remote: this.remote, + workspaceId: manifest.workspaceId, + schemaEpoch: this.context.schemaEpoch, + minReaderVersion: this.context.minReaderVersion, + minWriterVersion: this.context.minWriterVersion, + transportEpoch: manifest.target.transportEpoch, + configRevision: nextConfig.revision, + configDigest: projectConfigDigest(nextConfig), + now: () => new Date(manifest.createdAt), + }); + } +} + +class LocalTransportMigrationTargetAdapter extends FileStagedTransportTarget { + readonly mode = 'local' as const; + readonly remote = null; + + async establish( + manifestValue: TransportMigrationManifestV1, + ): Promise { + const manifest = parseTransportMigrationManifest(manifestValue); + this.assertManifestTarget(manifest); + const existing = await this.readEstablished(manifest.operationId); + if (existing !== null) return existing; + await requireStagedManifest(this.context.operationStore, manifest); + const locks = await acquireEntityLocks( + this.context.operationStore, + manifest.operationId, + [`transport_authority:${manifest.workspaceId}`], + ); + try { + const marker = await readLocalTransportAuthorityState( + this.context.operationStore, + ); + if ( + marker?.state === 'active' && + marker.transportEpoch === manifest.target.transportEpoch && + marker.operationId === manifest.operationId + ) { + return this.finishLocalEstablishment(manifest); + } + if (marker?.state === 'active') { + throw new Error('MANCODE_TRANSPORT_MIGRATION_SPLIT_BRAIN'); + } + await archiveLocalCoordinationCollections( + this.context.operationStore, + manifest.operationId, + ); + await publishMigrationActorProfiles( + this.context.projectRoot, + manifest.actorProfiles, + ); + for (const task of manifest.tasks) { + const bundle = requireTaskBundle(manifest, task.taskRef); + await assertGitRefBundleCodeReachable(this.context.projectRoot, bundle); + await materializeLocalMigrationTask( + this.context, + manifest, + task, + bundle, + ); + } + const activeClaims = reissueActiveClaims(manifest, 1); + for (const claim of activeClaims) { + await createClaim(this.context.operationStore, claim); + } + for (const handoff of migrateHandoffs(manifest, 1)) { + await createHandoff(this.context.operationStore, handoff); + } + await writeLocalTransportAuthorityState(this.context.operationStore, { + schemaVersion: 1, + workspaceId: manifest.workspaceId, + authorityId: manifest.target.authorityId, + coordinationDomainId: manifest.target.coordinationDomainId, + transportEpoch: manifest.target.transportEpoch, + state: 'active', + operationId: manifest.operationId, + successorMode: null, + successorEpoch: null, + tombstone: null, + updatedAt: manifest.createdAt, + }); + return this.finishLocalEstablishment(manifest, activeClaims); + } finally { + await Promise.allSettled( + [...locks].reverse().map((lock) => lock.release()), + ); + } + } + + protected async recoverEstablished( + operationId: Ulid, + ): Promise { + const staged = await readStagedRecord( + this.context.operationStore, + operationId, + ); + if (staged === null || staged.manifest.target.mode !== 'local') return null; + const marker = await readLocalTransportAuthorityState( + this.context.operationStore, + ); + if ( + marker?.state !== 'active' || + marker.operationId !== operationId || + marker.transportEpoch !== staged.manifest.target.transportEpoch || + marker.authorityId !== staged.manifest.target.authorityId + ) { + return null; + } + return this.finishLocalEstablishment(staged.manifest); + } + + private async finishLocalEstablishment( + manifest: TransportMigrationManifestV1, + claims?: ClaimV1[], + ): Promise { + const activeClaims = + claims ?? + (await listClaims(this.context.operationStore)).filter( + (claim) => + claim.state === 'active' && + claim.lastOperationId === manifest.operationId, + ); + return this.writeEstablished({ + schemaVersion: 1, + operationId: manifest.operationId, + manifestDigest: digestCanonicalJson(manifest), + transportEpoch: manifest.target.transportEpoch, + coordinationDomainId: manifest.target.coordinationDomainId, + authorityRevision: 1, + activeClaims, + receipt: `local:${digestCanonicalJson(manifest).slice(7)}`, + establishedAt: manifest.createdAt, + }); + } +} + +async function createSourceAdapter( + context: AdapterContext, +): Promise { + if (context.sourceConfig.transport.mode === 'local') { + const domain = localDomain(context); + const marker = await readLocalTransportAuthorityState( + context.operationStore, + ); + const authorityId = + marker !== null && + marker.transportEpoch === context.sourceConfig.transport.epoch && + marker.coordinationDomainId === domain + ? marker.authorityId + : localAuthorityId(domain, context.sourceConfig.transport.epoch); + return new LocalTransportMigrationSourceAdapter( + context, + authorityId, + domain, + ); + } + const remote = requireRemote(context.sourceConfig.transport.remote); + const remoteIdentity = await resolveGitRefRemoteIdentityHash( + context.projectRoot, + remote, + ); + const domain = gitRefCoordinationDomainId( + remoteIdentity, + context.sourceConfig.workspaceId, + context.sourceConfig.transport.epoch, + ); + return new GitRefTransportMigrationSourceAdapter( + context, + gitRefAuthorityId(domain), + new GitRefTeamManifestStore({ + projectRoot: context.projectRoot, + remote, + workspaceId: context.sourceConfig.workspaceId, + schemaEpoch: context.schemaEpoch, + minReaderVersion: context.minReaderVersion, + minWriterVersion: context.minWriterVersion, + transportEpoch: context.sourceConfig.transport.epoch, + configRevision: context.sourceConfig.revision, + configDigest: projectConfigDigest(context.sourceConfig), + now: context.now, + }), + ); +} + +async function createTargetAdapter( + context: AdapterContext, +): Promise { + const targetEpoch = context.sourceConfig.transport.epoch + 1; + if (context.targetMode === 'local') { + const domain = localDomain(context); + return new LocalTransportMigrationTargetAdapter( + context, + localAuthorityId(domain, targetEpoch), + domain, + ); + } + const remote = requireRemote(context.targetRemote); + const remoteIdentity = await resolveGitRefRemoteIdentityHash( + context.projectRoot, + remote, + ); + const domain = gitRefCoordinationDomainId( + remoteIdentity, + context.sourceConfig.workspaceId, + targetEpoch, + ); + return new GitRefTransportMigrationTargetAdapter( + context, + gitRefAuthorityId(domain), + domain, + ); +} + +async function materializeLocalMigrationTask( + context: AdapterContext, + manifest: TransportMigrationManifestV1, + task: TransportMigrationAuthoritySnapshotV1['tasks'][number], + bundle: GitRefTaskBundleV1, +): Promise { + const existingFence = await readTaskHeadFence( + context.operationStore, + task.taskRef, + ); + const existingTask = await readTaskSnapshotOrNull( + context.projectRoot, + task.taskRef, + ); + const alreadyLocal = + existingFence !== null && + existingFence.remoteRevision === null && + existingFence.lastOperationId === manifest.operationId && + existingFence.taskRevision === bundle.taskRevision && + existingFence.aggregateDigest === bundle.aggregateDigest && + existingTask?.aggregate !== null && + existingTask !== null && + digestCanonicalJson(existingTask.aggregate) === bundle.aggregateDigest; + if (alreadyLocal) return; + const remoteRevision = task.taskHeadFence.remoteRevision; + if (remoteRevision === null) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_TASK_SNAPSHOT_INVALID'); + } + const remoteFence: GitRefOwnershipFenceV1 = { + schemaVersion: 1, + taskRef: task.taskRef, + ownerActorId: requireOwner(task.ownerActorId), + ownershipEpoch: task.ownershipEpoch, + taskRevision: task.taskRevision, + aggregateDigest: task.aggregateDigest, + remoteRevision, + lastOperationId: task.taskHeadFence.lastOperationId, + updatedAt: task.taskHeadFence.updatedAt, + }; + const result = await materializeGitRefTaskBundle({ + projectRoot: context.projectRoot, + remoteRevision, + ownershipFence: remoteFence, + bundle, + operationId: handoffSuccessorClaimId( + manifest.operationId, + task.taskRef.taskId, + manifest.createdAt, + ), + now: new Date(manifest.createdAt), + }); + await replaceTaskHeadFence( + context.operationStore, + parseTaskHeadFence({ + ...result.taskHeadFence, + fenceRevision: result.taskHeadFence.fenceRevision + 1, + remoteRevision: null, + lastOperationId: manifest.operationId, + updatedAt: manifest.createdAt, + }), + ); +} + +function reissueActiveClaims( + manifest: TransportMigrationManifestV1, + authorityRevision: number, +): ClaimV1[] { + return manifest.sourceClaims + .filter((claim) => claim.state === 'active') + .map((claim) => + parseClaim({ + ...claim, + claimId: handoffSuccessorClaimId( + manifest.operationId, + claim.claimId, + manifest.createdAt, + ), + coordinationDomainId: manifest.target.coordinationDomainId, + authority: { + mode: manifest.target.mode, + remoteRevision: + manifest.target.mode === 'git-ref' + ? String(authorityRevision) + : null, + }, + state: 'active', + revision: 1, + acquisitionEnforcement: 'enforced', + writeGuard: 'advisory', + predecessorClaimId: claim.claimId, + successorClaimId: null, + lastOperationId: manifest.operationId, + createdAt: manifest.createdAt, + updatedAt: manifest.createdAt, + }), + ) + .sort((left, right) => compareUtf8(left.claimId, right.claimId)); +} + +function migrateHandoffs( + manifest: TransportMigrationManifestV1, + authorityRevision: number, +): HandoffV1[] { + const claimIds = new Map( + manifest.sourceClaims + .filter((claim) => claim.state === 'active') + .map((claim) => [ + claim.claimId, + handoffSuccessorClaimId( + manifest.operationId, + claim.claimId, + manifest.createdAt, + ), + ]), + ); + return manifest.handoffs.map((handoff) => { + const bundle = requireTaskBundle(manifest, handoff.taskRef); + const targetDigest = + manifest.target.mode === 'git-ref' + ? bundle.bundleDigest + : localHandoffBundleDigest(bundle, handoff); + return parseHandoff({ + ...handoff, + claimIds: handoff.claimIds.map((id) => claimIds.get(id) ?? id), + transport: + manifest.target.mode === 'git-ref' + ? { + mode: 'git-ref', + state: 'published', + transportRevision: authorityRevision, + publishedAt: manifest.createdAt, + fetchedAt: null, + taskBundleDigest: targetDigest, + codeRef: bundle.codeRef, + codeReachable: true, + receipt: `migration:${manifest.operationId}`, + } + : { + mode: 'local', + state: 'local_only', + transportRevision: null, + publishedAt: null, + fetchedAt: null, + taskBundleDigest: targetDigest, + codeRef: bundle.codeRef, + codeReachable: true, + receipt: null, + }, + lastOperationId: manifest.operationId, + updatedAt: manifest.createdAt, + }); + }); +} + +function localHandoffBundleDigest( + bundle: GitRefTaskBundleV1, + handoff: HandoffV1, +): string { + const artifact = bundle.artifacts.find( + (candidate) => + candidate.kind === 'checkpoint' && + candidate.relativePath.endsWith( + `/${handoff.checkpointRef.artifactId}.json`, + ), + ); + if (artifact === undefined) return handoff.transport.taskBundleDigest; + const checkpoint = parseCheckpoint(artifact.content); + return digestCanonicalJson({ + aggregate: bundle.aggregate, + checkpointDigest: checkpointDigest(checkpoint), + codeRef: { head: bundle.codeRef.head }, + }); +} + +async function publishMigrationActorProfiles( + projectRoot: string, + profiles: TransportMigrationManifestV1['actorProfiles'], +): Promise { + for (const profile of profiles) { + const existing = await readSharedActorProfile(projectRoot, profile.actorId); + if ( + existing !== null && + digestCanonicalJson(existing) !== digestCanonicalJson(profile) + ) { + throw new Error('MANCODE_ACTOR_PROFILE_CONFLICT'); + } + await publishSharedActorProfile(projectRoot, profile); + } +} + +async function archiveLocalCoordinationCollections( + store: EntityHomeStore, + operationId: Ulid, +): Promise { + const archiveRoot = path.join( + store.root, + STAGE_DIRECTORY, + 'archive', + operationId, + ); + await mkdir(archiveRoot, { recursive: true }); + const directories: Record<(typeof COLLECTIONS)[number], string> = { + claims: claimDirectory(store), + handoffs: handoffDirectory(store), + 'task-heads': taskHeadDirectory(store), + }; + for (const name of COLLECTIONS) { + const archived = path.join(archiveRoot, name); + const absent = path.join(archiveRoot, `${name}.absent`); + if ( + (await pathKind(archived)) === 'directory' || + (await pathKind(absent)) === 'file' + ) { + continue; + } + const sourceKind = await pathKind(directories[name]); + if (sourceKind === null) { + await writeFile(absent, '\n', { encoding: 'utf8', flag: 'wx' }); + continue; + } + if (sourceKind !== 'directory') { + throw new Error('MANCODE_TRANSPORT_MIGRATION_LOCAL_AUTHORITY_UNSAFE'); + } + await rename(directories[name], archived); + } +} + +async function listActorProfiles(projectRoot: string) { + const directory = sharedActorProfileDirectory(projectRoot); + let entries: string[]; + try { + entries = await readdir(directory); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const profiles = []; + for (const entry of entries.sort(compareUtf8)) { + if (!entry.endsWith('.json')) continue; + const actorId = entry.slice(0, -'.json'.length); + assertUlid(actorId, 'shared actor profile filename'); + const profile = await readSharedActorProfile(projectRoot, actorId); + if (profile === null) { + throw new Error('MANCODE_CONTEXT_COLLECTION_CHANGED_DURING_READ'); + } + profiles.push(profile); + } + return profiles; +} + +async function listSharedTaskRefs(projectRoot: string): Promise { + const directory = path.join(projectRoot, '.mancode', 'shared', 'workflows'); + let entries: string[]; + try { + entries = await readdir(directory); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const refs: TaskRef[] = []; + for (const taskId of entries.sort(compareUtf8)) { + assertUlid(taskId, 'shared workflow directory'); + const stat = await lstat(path.join(directory, taskId)); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('MANCODE_CONTEXT_PATH_UNSAFE'); + } + refs.push({ namespace: 'shared', taskId }); + } + return refs; +} + +async function readTaskSnapshotOrNull(projectRoot: string, taskRef: TaskRef) { + try { + return await new V3ContextStore(projectRoot).readTaskSnapshot(taskRef); + } catch (error) { + if ( + error instanceof Error && + (error.message === 'MANCODE_TASK_NOT_FOUND' || isNotFound(error)) + ) { + return null; + } + throw error; + } +} + +function bundleMetadata(bundle: GitRefTaskBundleV1) { + const artifact = bundle.artifacts.find( + (candidate) => candidate.kind === 'metadata', + ); + if (artifact === undefined) { + throw new Error('MANCODE_TRANSPORT_BUNDLE_ARTIFACT_INVALID'); + } + return parseWorkflowMetadata(artifact.content); +} + +function requireTaskBundle( + manifest: TransportMigrationManifestV1, + taskRef: TaskRef, +): GitRefTaskBundleV1 { + const bundle = manifest.taskBundles.find( + (candidate) => candidate.taskRef.taskId === taskRef.taskId, + ); + if (bundle === undefined) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_TASK_SNAPSHOT_INVALID'); + } + return bundle; +} + +function requireOwner(value: Ulid | null): Ulid { + if (value === null) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_TASK_SNAPSHOT_INVALID'); + } + return value; +} + +function migrationTargetConfig( + manifest: TransportMigrationManifestV1, +): ProjectConfigV1 { + const previous = manifest.source.config; + const next = parseProjectConfig({ + ...previous, + revision: previous.revision + 1, + transport: { + mode: manifest.target.mode, + remote: manifest.target.remote, + epoch: manifest.target.transportEpoch, + }, + lastOperationId: manifest.operationId, + updatedAt: manifest.createdAt, + }); + assertProjectConfigTransition(previous, next, 'transport_migrate'); + return next; +} + +async function requireStagedManifest( + store: EntityHomeStore, + manifest: TransportMigrationManifestV1, +): Promise { + const staged = await readStagedRecord(store, manifest.operationId); + if ( + staged === null || + staged.manifestDigest !== digestCanonicalJson(manifest) || + digestCanonicalJson(staged.manifest) !== digestCanonicalJson(manifest) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_STAGE_CONFLICT'); + } + return staged; +} + +async function readStagedRecord( + store: EntityHomeStore, + operationId: Ulid, +): Promise { + assertUlid(operationId, 'transport migration staged operationId'); + return readJsonOrNull( + stagedPath(store, operationId), + parseStagedTransportAuthority, + 'MANCODE_TRANSPORT_MIGRATION_STAGE_CORRUPT', + ); +} + +function stagedPath(store: EntityHomeStore, operationId: Ulid): string { + return path.join( + store.root, + STAGE_DIRECTORY, + 'staged', + `${operationId}.json`, + ); +} + +function establishedPath(store: EntityHomeStore, operationId: Ulid): string { + return path.join( + store.root, + STAGE_DIRECTORY, + 'established', + `${operationId}.json`, + ); +} + +function projectConfigPath(projectRoot: string): string { + return path.join(projectRoot, '.mancode', 'shared', 'config.json'); +} + +async function readProjectConfigFile( + projectRoot: string, +): Promise { + try { + return parseProjectConfig( + JSON.parse(await readFile(projectConfigPath(projectRoot), 'utf8')), + ); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('MANCODE_CONTEXT_ENTITY_CORRUPT: shared/config.json'); + } + throw error; + } +} + +async function writeJsonAtomic(target: string, value: unknown): Promise { + await mkdir(path.dirname(target), { recursive: true }); + await assertPlainDirectory(path.dirname(target)); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${createUlid()}.tmp`, + ); + try { + await writeFile(temporary, serialize(value), { + encoding: 'utf8', + flag: 'wx', + }); + await replaceFileAtomically(temporary, target); + } catch (error) { + await unlinkIfExists(temporary); + throw error; + } +} + +async function writeJsonExclusiveOrEqual( + target: string, + value: T, + parser: (raw: unknown) => T, + conflictCode: string, +): Promise { + await mkdir(path.dirname(target), { recursive: true }); + await assertPlainDirectory(path.dirname(target)); + try { + await writeFile(target, serialize(value), { encoding: 'utf8', flag: 'wx' }); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + const existing = await readJsonOrNull(target, parser, conflictCode); + if ( + existing !== null && + digestCanonicalJson(existing) === digestCanonicalJson(value) + ) { + return; + } + throw new Error(conflictCode); + } +} + +async function readJsonOrNull( + target: string, + parser: (raw: unknown) => T, + corruptCode: string, +): Promise { + try { + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_STAGE_UNSAFE'); + } + const parsed = parser(JSON.parse(await readFile(target, 'utf8'))); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_STAGE_UNSAFE'); + } + return parsed; + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) throw new Error(corruptCode); + throw error; + } +} + +function assertLocalStateIdentity( + state: LocalTransportAuthorityStateV1, + workspaceId: Ulid, + authorityId: string, + coordinationDomainId: string, + transportEpoch: number, +): void { + if ( + state.workspaceId !== workspaceId || + state.authorityId !== authorityId || + state.coordinationDomainId !== coordinationDomainId || + state.transportEpoch !== transportEpoch + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_SPLIT_BRAIN'); + } +} + +function isTombstoneRetry( + state: LocalTransportAuthorityStateV1 | null, + operationId: Ulid, +): boolean { + return state?.state === 'tombstoned' && state.operationId === operationId; +} + +function assertRemoteFreezeTarget( + manifest: GitRefTeamManifestV1, + successorMode: CoordinationTransport, + successorEpoch: number, +): void { + const freeze = manifest.authorityFreeze; + if ( + freeze === null || + freeze.successorMode !== successorMode || + freeze.successorEpoch !== successorEpoch + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_FREEZE_CONFLICT'); + } +} + +function requireRemoteManifest( + value: GitRefTeamManifestV1 | null, +): GitRefTeamManifestV1 { + if (value === null) throw new Error('MANCODE_TRANSPORT_MANIFEST_MISSING'); + return value; +} + +function localDomain(context: AdapterContext): string { + return context.repositoryBindingId === null + ? `local:non-git:${context.sourceConfig.workspaceId}` + : localCoordinationDomainId( + context.repositoryBindingId, + context.sourceConfig.workspaceId, + ); +} + +function localAuthorityId(domain: string, epoch: number): string { + return `authority:${domain}:epoch:${epoch}`; +} + +function gitRefAuthorityId(domain: string): string { + return `authority:${domain}`; +} + +function normalizeTargetRemote( + mode: CoordinationTransport, + remote: string | null | undefined, +): string | null { + if (mode === 'local') { + if (remote !== undefined && remote !== null) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_REMOTE_INVALID'); + } + return null; + } + return requireRemote(remote ?? null); +} + +function requireRemote(value: string | null): string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_REMOTE_INVALID'); + } + return value; +} + +async function pathKind( + target: string, +): Promise<'file' | 'directory' | 'other' | null> { + try { + const stat = await lstat(target); + if (stat.isSymbolicLink()) return 'other'; + if (stat.isFile()) return 'file'; + if (stat.isDirectory()) return 'directory'; + return 'other'; + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + +async function assertPlainDirectory(target: string): Promise { + const stat = await lstat(target); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_STAGE_UNSAFE'); + } +} + +async function unlinkIfExists(target: string): Promise { + try { + await unlink(target); + } catch (error) { + if (!isNotFound(error)) throw error; + } +} + +function serialize(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} + +function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/team/transport-migration-freeze.ts b/src/team/transport-migration-freeze.ts new file mode 100644 index 0000000..b9f3547 --- /dev/null +++ b/src/team/transport-migration-freeze.ts @@ -0,0 +1,339 @@ +import { lstat, mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { type Ulid, assertUlid, createUlid } from '../context/ids.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import { replaceFileAtomically } from '../runtime/atomic-file.js'; +import type { EntityHomeStore } from '../runtime/entity-home-store.js'; +import { listUnfinishedOperationJournals } from '../runtime/operation-store.js'; +import type { CoordinationTransport, ProjectConfigV1 } from './policy.js'; +import type { TransportAuthorityTombstoneV1 } from './transport-migration.js'; + +export interface LocalTransportAuthorityStateV1 { + schemaVersion: 1; + workspaceId: Ulid; + authorityId: string; + coordinationDomainId: string; + transportEpoch: number; + state: 'active' | 'frozen' | 'tombstoned'; + operationId: Ulid; + successorMode: CoordinationTransport | null; + successorEpoch: number | null; + tombstone: TransportAuthorityTombstoneV1 | null; + updatedAt: string; +} + +export function localTransportAuthorityStatePath( + store: EntityHomeStore, +): string { + if (store.kind === 'checkout_local') { + throw new Error('MANCODE_TRANSPORT_MIGRATION_OPERATION_STORE_INVALID'); + } + return path.join(store.root, 'transport-authority', 'state.json'); +} + +export async function readLocalTransportAuthorityState( + store: EntityHomeStore, +): Promise { + const target = localTransportAuthorityStatePath(store); + try { + await assertSafeStateDirectory(path.dirname(target)); + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error('MANCODE_TRANSPORT_AUTHORITY_STATE_UNSAFE'); + } + const state = parseLocalTransportAuthorityState( + JSON.parse(await readFile(target, 'utf8')), + ); + const after = await lstat(target); + if ( + !after.isFile() || + after.isSymbolicLink() || + before.dev !== after.dev || + before.ino !== after.ino + ) { + throw new Error('MANCODE_TRANSPORT_AUTHORITY_STATE_UNSAFE'); + } + return state; + } catch (error) { + if (isNotFound(error)) return null; + if (error instanceof SyntaxError) { + throw new Error('MANCODE_TRANSPORT_AUTHORITY_STATE_CORRUPT'); + } + throw error; + } +} + +/** Shared mutation entry points call this before preparing a business write. */ +export async function assertLocalCoordinationWriteAllowed( + store: EntityHomeStore, + expectedTransportEpoch: number, +): Promise { + if ( + !Number.isSafeInteger(expectedTransportEpoch) || + expectedTransportEpoch < 1 + ) { + throw new Error('MANCODE_TRANSPORT_EPOCH_CONFLICT'); + } + const state = await readLocalTransportAuthorityState(store); + // Marker-less authorities remain valid for projects that never migrated. + if (state === null) return; + if (state.transportEpoch !== expectedTransportEpoch) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_SPLIT_BRAIN'); + } + if (state.state === 'frozen') { + throw new Error('MANCODE_TRANSPORT_MIGRATION_FROZEN'); + } + if (state.state === 'tombstoned') { + throw new Error('MANCODE_TRANSPORT_AUTHORITY_TOMBSTONED'); + } +} + +/** Blocks ordinary coordination writes while a transport cutover is unfinished. */ +export async function assertTransportCoordinationWriteAllowed( + store: EntityHomeStore, + config: ProjectConfigV1, +): Promise { + const migrations = (await listUnfinishedOperationJournals(store)).filter( + (journal) => journal.type === 'transport_migrate', + ); + if (migrations.length > 0) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_FROZEN'); + } + if (config.transport.mode === 'local') { + await assertLocalCoordinationWriteAllowed(store, config.transport.epoch); + } +} + +export async function writeLocalTransportAuthorityState( + store: EntityHomeStore, + value: LocalTransportAuthorityStateV1, +): Promise { + const target = localTransportAuthorityStatePath(store); + await mkdir(path.dirname(target), { recursive: true }); + await assertSafeStateDirectory(path.dirname(target)); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${createUlid()}.tmp`, + ); + try { + await writeFile( + temporary, + `${JSON.stringify(parseLocalTransportAuthorityState(value), null, 2)}\n`, + { encoding: 'utf8', flag: 'wx' }, + ); + await replaceFileAtomically(temporary, target); + } catch (error) { + await unlinkIfExists(temporary); + throw error; + } +} + +export function parseLocalTransportAuthorityState( + value: unknown, +): LocalTransportAuthorityStateV1 { + assertRecord(value, 'local transport authority state'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'workspaceId', + 'authorityId', + 'coordinationDomainId', + 'transportEpoch', + 'state', + 'operationId', + 'successorMode', + 'successorEpoch', + 'tombstone', + 'updatedAt', + ], + 'local transport authority state', + ); + if (value.schemaVersion !== 1) { + throw new Error('local transport authority state schemaVersion is invalid'); + } + assertUlid(value.workspaceId, 'local transport authority workspaceId'); + assertUlid(value.operationId, 'local transport authority operationId'); + if ( + value.state !== 'active' && + value.state !== 'frozen' && + value.state !== 'tombstoned' + ) { + throw new Error('local transport authority state is invalid'); + } + const successorMode = parseTransportModeOrNull(value.successorMode); + const successorEpoch = parsePositiveIntegerOrNull(value.successorEpoch); + const tombstone = + value.tombstone === null + ? null + : parseTransportAuthorityTombstone(value.tombstone); + if ( + (value.state === 'active' && + (successorMode !== null || + successorEpoch !== null || + tombstone !== null)) || + (value.state === 'frozen' && + (successorMode === null || + successorEpoch === null || + tombstone !== null)) || + (value.state === 'tombstoned' && + (successorMode === null || successorEpoch === null || tombstone === null)) + ) { + throw new Error('local transport authority state shape is invalid'); + } + return { + schemaVersion: 1, + workspaceId: value.workspaceId, + authorityId: nonEmptyString(value.authorityId, 'authorityId'), + coordinationDomainId: localDomainString(value.coordinationDomainId), + transportEpoch: positiveInteger(value.transportEpoch, 'transportEpoch'), + state: value.state, + operationId: value.operationId, + successorMode, + successorEpoch, + tombstone, + updatedAt: timestamp(value.updatedAt, 'updatedAt'), + }; +} + +export function parseTransportAuthorityTombstone( + value: unknown, +): TransportAuthorityTombstoneV1 { + assertRecord(value, 'transport authority tombstone'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'workspaceId', + 'sourceAuthorityId', + 'sourceTransportEpoch', + 'sourceCoordinationDomainId', + 'targetAuthorityId', + 'targetTransportEpoch', + 'targetCoordinationDomainId', + 'manifestDigest', + 'authorityReceipt', + 'activatedConfigRevision', + 'createdAt', + ], + 'transport authority tombstone', + ); + if (value.schemaVersion !== 1) { + throw new Error('transport authority tombstone schemaVersion is invalid'); + } + assertUlid(value.operationId, 'transport authority tombstone operationId'); + assertUlid(value.workspaceId, 'transport authority tombstone workspaceId'); + const manifestDigest = nonEmptyString(value.manifestDigest, 'manifestDigest'); + if (!/^sha256:[a-f0-9]{64}$/.test(manifestDigest)) { + throw new Error('transport authority tombstone manifestDigest is invalid'); + } + return { + schemaVersion: 1, + operationId: value.operationId, + workspaceId: value.workspaceId, + sourceAuthorityId: nonEmptyString( + value.sourceAuthorityId, + 'sourceAuthorityId', + ), + sourceTransportEpoch: positiveInteger( + value.sourceTransportEpoch, + 'sourceTransportEpoch', + ), + sourceCoordinationDomainId: nonEmptyString( + value.sourceCoordinationDomainId, + 'sourceCoordinationDomainId', + ), + targetAuthorityId: nonEmptyString( + value.targetAuthorityId, + 'targetAuthorityId', + ), + targetTransportEpoch: positiveInteger( + value.targetTransportEpoch, + 'targetTransportEpoch', + ), + targetCoordinationDomainId: nonEmptyString( + value.targetCoordinationDomainId, + 'targetCoordinationDomainId', + ), + manifestDigest, + authorityReceipt: nonEmptyString( + value.authorityReceipt, + 'authorityReceipt', + ), + activatedConfigRevision: positiveInteger( + value.activatedConfigRevision, + 'activatedConfigRevision', + ), + createdAt: timestamp(value.createdAt, 'createdAt'), + }; +} + +function parseTransportModeOrNull( + value: unknown, +): CoordinationTransport | null { + if (value === null) return null; + if (value !== 'local' && value !== 'git-ref') { + throw new Error('local transport authority successorMode is invalid'); + } + return value; +} + +function parsePositiveIntegerOrNull(value: unknown): number | null { + return value === null ? null : positiveInteger(value, 'successorEpoch'); +} + +function localDomainString(value: unknown): string { + const parsed = nonEmptyString(value, 'coordinationDomainId'); + if (!parsed.startsWith('local:') || parsed.includes('..')) { + throw new Error( + 'local transport authority coordinationDomainId is invalid', + ); + } + return parsed; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function nonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function timestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +async function unlinkIfExists(target: string): Promise { + try { + await unlink(target); + } catch (error) { + if (!isNotFound(error)) throw error; + } +} + +async function assertSafeStateDirectory(target: string): Promise { + const stat = await lstat(target); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('MANCODE_TRANSPORT_AUTHORITY_STATE_UNSAFE'); + } +} + +function isNotFound(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/team/transport-migration.ts b/src/team/transport-migration.ts new file mode 100644 index 0000000..e032893 --- /dev/null +++ b/src/team/transport-migration.ts @@ -0,0 +1,1665 @@ +import { digestCanonicalJson } from '../context/canonical.js'; +import { type Ulid, assertUlid } from '../context/ids.js'; +import { + type TaskRef, + parseTaskRefValue, + sameTaskRef, +} from '../context/task-ref.js'; +import { assertKnownKeys, assertRecord } from '../context/validation.js'; +import type { EntityHomeStore } from '../runtime/entity-home-store.js'; +import { throwIfOperationCrashInjected } from '../runtime/operation-crash-injection.js'; +import { + assertOperationJournalMatchesDefinition, + getOperationDefinition, +} from '../runtime/operation-definition.js'; +import type { + OperationJournalV1, + OperationStep, +} from '../runtime/operation-journal.js'; +import { + createPreparedOperationJournal, + listUnfinishedOperationJournals, + readOperationJournal, + updateOperationJournal, +} from '../runtime/operation-store.js'; +import { + type TaskHeadFenceV1, + parseTaskHeadFence, +} from '../runtime/task-head-fence.js'; +import { type SharedActorProfileV1, parseSharedActorProfile } from './actor.js'; +import { + type AuthorizationBasisV1, + createAuthorizationBasis, +} from './authorization.js'; +import { type ClaimV1, parseClaim } from './claims.js'; +import { + type GitRefTaskBundleV1, + parseGitRefTaskBundle, +} from './git-ref-transport.js'; +import { type HandoffV1, parseHandoff } from './handoff.js'; +import { + type CoordinationTransport, + type ProjectConfigV1, + assertProjectConfigTransition, + parseProjectConfig, +} from './policy.js'; + +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +export interface TransportMigrationTaskSnapshotV1 { + taskRef: TaskRef; + transitionState: 'stable'; + taskRevision: number; + ownerActorId: Ulid | null; + ownershipEpoch: number; + aggregateDigest: string; + taskHeadFence: TaskHeadFenceV1; +} + +export interface TransportMigrationAuthoritySnapshotV1 { + schemaVersion: 1; + workspaceId: Ulid; + authorityId: string; + transportMode: CoordinationTransport; + transportEpoch: number; + coordinationDomainId: string; + pendingOperationIds: Ulid[]; + actorProfiles: SharedActorProfileV1[]; + tasks: TransportMigrationTaskSnapshotV1[]; + taskBundles: GitRefTaskBundleV1[]; + claims: ClaimV1[]; + handoffs: HandoffV1[]; +} + +export interface TransportMigrationManifestV1 { + schemaVersion: 1; + operationId: Ulid; + actorId: Ulid; + workspaceId: Ulid; + source: { + config: ProjectConfigV1; + authorityId: string; + coordinationDomainId: string; + }; + target: { + mode: CoordinationTransport; + remote: string | null; + authorityId: string; + transportEpoch: number; + coordinationDomainId: string; + }; + actorProfiles: SharedActorProfileV1[]; + tasks: TransportMigrationTaskSnapshotV1[]; + taskBundles: GitRefTaskBundleV1[]; + sourceClaims: ClaimV1[]; + handoffs: HandoffV1[]; + claimPolicy: 'reissue-active'; + createdAt: string; +} + +export interface StagedTransportAuthorityV1 { + schemaVersion: 1; + operationId: Ulid; + manifest: TransportMigrationManifestV1; + manifestDigest: string; + transportEpoch: number; + coordinationDomainId: string; + stagedAt: string; +} + +export interface EstablishedTransportAuthorityV1 { + schemaVersion: 1; + operationId: Ulid; + manifestDigest: string; + transportEpoch: number; + coordinationDomainId: string; + authorityRevision: number; + activeClaims: ClaimV1[]; + receipt: string; + establishedAt: string; +} + +export interface TransportAuthorityTombstoneV1 { + schemaVersion: 1; + operationId: Ulid; + workspaceId: Ulid; + sourceAuthorityId: string; + sourceTransportEpoch: number; + sourceCoordinationDomainId: string; + targetAuthorityId: string; + targetTransportEpoch: number; + targetCoordinationDomainId: string; + manifestDigest: string; + authorityReceipt: string; + activatedConfigRevision: number; + createdAt: string; +} + +/** Source methods must be durable and idempotent by operationId. */ +export interface TransportMigrationSourceAdapter { + readonly mode: CoordinationTransport; + readonly remote: string | null; + readonly authorityId: string; + freeze(input: { + operationId: Ulid; + expectedTransportEpoch: number; + }): Promise; + assertFrozen(input: { + operationId: Ulid; + expectedTransportEpoch: number; + }): Promise; + inspect(): Promise; + unfreeze(input: { operationId: Ulid }): Promise; + writeTombstone(tombstone: TransportAuthorityTombstoneV1): Promise; +} + +/** Target methods must return the same value when the same operation retries. */ +export interface TransportMigrationTargetAdapter { + readonly mode: CoordinationTransport; + readonly remote: string | null; + readonly authorityId: string; + readonly coordinationDomainId: string; + stage(manifest: TransportMigrationManifestV1): Promise; + readStaged(operationId: Ulid): Promise; + establish(manifest: TransportMigrationManifestV1): Promise; + readEstablished(operationId: Ulid): Promise; + discard(input: { + operationId: Ulid; + manifestDigest: string; + }): Promise; +} + +export interface TransportMigrationConfigAdapter { + read(): Promise; + compareAndSwap(input: { + expectedRevision: number; + expectedTransportEpoch: number; + next: ProjectConfigV1; + }): Promise; +} + +interface TransportMigrationAdapters { + operationStore: EntityHomeStore; + config: TransportMigrationConfigAdapter; + source: TransportMigrationSourceAdapter; + target: TransportMigrationTargetAdapter; +} + +export interface TransportMigrationStartInput + extends TransportMigrationAdapters { + operationId: Ulid; + checkoutId: Ulid; + actorId: Ulid; + sessionId: Ulid; + expectedConfigRevision: number; + joined: boolean; + explicitConfirmation: boolean; + now?: Date; +} + +export interface TransportMigrationRecoveryInput + extends TransportMigrationAdapters { + operationId: Ulid; + actorId: Ulid; + sessionId: Ulid; + mode?: 'forward' | 'abort'; +} + +export interface TransportMigrationPreview { + config: ProjectConfigV1; + snapshot: TransportMigrationAuthoritySnapshotV1; + manifest: TransportMigrationManifestV1; + manifestDigest: string; +} + +export interface StagedTransportMigration extends TransportMigrationPreview { + journal: OperationJournalV1; + staged: StagedTransportAuthorityV1; +} + +export interface CompletedTransportMigration extends StagedTransportMigration { + journal: OperationJournalV1; + established: EstablishedTransportAuthorityV1; + activatedConfig: ProjectConfigV1; +} + +export type TransportMigrationRecoveryResult = + | { state: 'aborted'; journal: OperationJournalV1 } + | { state: 'already_committed'; journal: OperationJournalV1 } + | ({ state: 'repaired' } & CompletedTransportMigration); + +export async function previewTransportMigration( + input: TransportMigrationStartInput, +): Promise { + assertStartInput(input); + const now = input.now ?? new Date(); + migrationAuthorization(input, now); + const [config, unfinished] = await Promise.all([ + readConfig(input.config), + listUnfinishedOperationJournals(input.operationStore), + ]); + if ( + input.operationStore.kind === 'checkout_local' || + input.operationStore.workspaceId !== config.workspaceId + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_OPERATION_STORE_INVALID'); + } + assertSourceConfig(config, input); + if (config.revision !== input.expectedConfigRevision) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + if (unfinished.length > 0) { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } + const snapshot = await inspectSource(input.source, config, input.operationId); + assertNoPendingOperations(snapshot, input.operationId); + const manifest = createManifest(input, config, snapshot, now); + return { + config, + snapshot, + manifest, + manifestDigest: digestCanonicalJson(manifest), + }; +} + +export async function stageTransportMigration( + input: TransportMigrationStartInput, +): Promise { + const preview = await previewTransportMigration(input); + let journal = await createMigrationJournal(input, preview); + try { + throwIfOperationCrashInjected('transport_migrate', 'prepared'); + const result = await advanceToStaged(input, journal, preview.manifest); + journal = result.journal; + return { ...preview, journal, staged: result.staged }; + } catch (error) { + await settleMigrationFailure(input, journal); + throw error; + } +} + +export async function executeTransportMigration( + input: TransportMigrationStartInput, +): Promise { + const staged = await stageTransportMigration(input); + try { + const completed = await advanceToCommitted( + input, + staged.journal, + staged.manifest, + ); + return { ...staged, ...completed }; + } catch (error) { + await markRepairRequired(input.operationStore, staged.journal); + throw error; + } +} + +export async function recoverTransportMigration( + input: TransportMigrationRecoveryInput, +): Promise { + assertRecoveryInput(input); + const journal = await readOperationJournal( + input.operationStore, + input.operationId, + ); + if (journal === null || journal.type !== 'transport_migrate') { + throw new Error('MANCODE_OPERATION_JOURNAL_NOT_FOUND'); + } + assertRecoveryIdentity(input, journal); + if (journal.state === 'aborted') return { state: 'aborted', journal }; + if (journal.state === 'committed') { + return { state: 'already_committed', journal }; + } + if (input.mode === 'abort') { + return abortMigration(input, journal); + } + try { + const manifest = await loadRecoveryManifest(input, journal); + const stagedResult = await advanceToStaged(input, journal, manifest); + const completed = await advanceToCommitted( + input, + stagedResult.journal, + manifest, + ); + const snapshot = snapshotFromManifest(manifest); + return { + state: 'repaired', + config: manifest.source.config, + snapshot, + manifest, + manifestDigest: digestCanonicalJson(manifest), + staged: stagedResult.staged, + ...completed, + }; + } catch (error) { + await markRepairRequired(input.operationStore, journal); + throw error; + } +} + +async function advanceToStaged( + input: TransportMigrationAdapters & { operationId: Ulid }, + initialJournal: OperationJournalV1, + manifest: TransportMigrationManifestV1, +): Promise<{ + journal: OperationJournalV1; + staged: StagedTransportAuthorityV1; +}> { + let journal = await ensureStepIntent( + input.operationStore, + initialJournal, + 'freeze-shared-coordination-writes', + ); + await input.source.freeze({ + operationId: journal.operationId, + expectedTransportEpoch: manifest.source.config.transport.epoch, + }); + await assertSourceFrozen(input.source, manifest); + throwIfOperationCrashInjected( + 'transport_migrate', + 'freeze-shared-coordination-writes', + ); + + journal = await ensureStepIntent( + input.operationStore, + journal, + 'validate-old-authority', + ); + await assertSourceMatchesManifest(input, manifest); + throwIfOperationCrashInjected('transport_migrate', 'validate-old-authority'); + + journal = await ensureStepIntent( + input.operationStore, + journal, + 'stage-new-authority', + ); + const staged = parseStagedTransportAuthority( + await input.target.stage(manifest), + ); + assertStagedMatches(input.target, manifest, staged); + throwIfOperationCrashInjected('transport_migrate', 'stage-new-authority'); + return { journal, staged }; +} + +async function advanceToCommitted( + input: TransportMigrationAdapters & { operationId: Ulid }, + initialJournal: OperationJournalV1, + manifest: TransportMigrationManifestV1, +): Promise<{ + journal: OperationJournalV1; + established: EstablishedTransportAuthorityV1; + activatedConfig: ProjectConfigV1; +}> { + await assertSourceFrozen(input.source, manifest); + let journal = await ensureStepIntent( + input.operationStore, + initialJournal, + 'establish-new-epoch', + ); + const established = parseEstablishedTransportAuthority( + await input.target.establish(manifest), + ); + assertEstablishedMatches(input.target, manifest, established); + throwIfOperationCrashInjected('transport_migrate', 'establish-new-epoch'); + + journal = await ensureStepIntent( + input.operationStore, + journal, + 'switch-config-authority', + ); + const activatedConfig = await activateConfig(input.config, manifest); + throwIfOperationCrashInjected('transport_migrate', 'switch-config-authority'); + await input.source.writeTombstone( + createTombstone(manifest, established, activatedConfig), + ); + + journal = await commitJournal(input.operationStore, journal); + return { journal, established, activatedConfig }; +} + +async function abortMigration( + input: TransportMigrationRecoveryInput, + journal: OperationJournalV1, +): Promise { + if (journal.state === 'repair_required') { + throw new Error('MANCODE_OPERATION_ABORT_UNSAFE'); + } + const [config, stagedValue, establishedValue] = await Promise.all([ + readConfig(input.config), + input.target.readStaged(journal.operationId), + input.target.readEstablished(journal.operationId), + ]); + if (stagedValue === null && establishedValue !== null) { + throw new Error('MANCODE_OPERATION_ABORT_UNSAFE'); + } + if (stagedValue !== null) { + const staged = parseStagedTransportAuthority(stagedValue); + assertJournalManifestBinding(journal, staged.manifest); + if ( + digestCanonicalJson(config) !== + digestCanonicalJson(staged.manifest.source.config) + ) { + throw new Error('MANCODE_OPERATION_ABORT_UNSAFE'); + } + await input.target.discard({ + operationId: journal.operationId, + manifestDigest: staged.manifestDigest, + }); + if ( + (await input.target.readStaged(journal.operationId)) !== null || + (await input.target.readEstablished(journal.operationId)) !== null + ) { + throw new Error('MANCODE_OPERATION_ABORT_UNSAFE'); + } + } else if (!configMatchesUnstagedJournal(config, input, journal)) { + throw new Error('MANCODE_OPERATION_ABORT_UNSAFE'); + } + await input.source.unfreeze({ operationId: journal.operationId }); + const aborted = await updateOperationJournal( + input.operationStore, + { ...journal, state: 'aborted', updatedAt: new Date().toISOString() }, + { canAbort: true }, + ); + return { state: 'aborted', journal: aborted }; +} + +async function loadRecoveryManifest( + input: TransportMigrationRecoveryInput, + journal: OperationJournalV1, +): Promise { + const stagedValue = await input.target.readStaged(journal.operationId); + if (stagedValue !== null) { + const staged = parseStagedTransportAuthority(stagedValue); + assertStagedMatches(input.target, staged.manifest, staged); + assertJournalManifestBinding(journal, staged.manifest); + return staged.manifest; + } + const config = await readConfig(input.config); + if (config.transport.mode !== input.source.mode) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_STAGE_MISSING'); + } + const snapshot = await inspectSource( + input.source, + config, + journal.operationId, + ); + assertNoPendingOperations(snapshot, journal.operationId); + const manifest = createManifest( + input, + config, + snapshot, + new Date(journal.startedAt), + ); + assertJournalManifestBinding(journal, manifest); + return manifest; +} + +async function assertSourceMatchesManifest( + input: TransportMigrationAdapters & { operationId: Ulid }, + manifest: TransportMigrationManifestV1, +): Promise { + await assertSourceFrozen(input.source, manifest); + const [config, unfinished, snapshot] = await Promise.all([ + readConfig(input.config), + listUnfinishedOperationJournals(input.operationStore), + inspectSource(input.source, manifest.source.config, input.operationId), + ]); + const unrelated = unfinished.filter( + (candidate) => candidate.operationId !== input.operationId, + ); + if (unrelated.length > 0) { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } + assertNoPendingOperations(snapshot, input.operationId); + if ( + !sameSourceConfigOrActivated(config, manifest) || + digestCanonicalJson(snapshotFromManifest(manifest)) !== + digestCanonicalJson(snapshot) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_SOURCE_CHANGED'); + } +} + +async function createMigrationJournal( + input: TransportMigrationStartInput, + preview: TransportMigrationPreview, +): Promise { + const definition = getOperationDefinition('transport_migrate'); + const now = new Date(preview.manifest.createdAt); + const authorizationBasis = migrationAuthorization(input, now); + const { entityLocks, expectedRevisions } = migrationGuards(preview); + const journal: OperationJournalV1 = { + schemaVersion: 1, + operationId: input.operationId, + type: 'transport_migrate', + state: 'prepared', + primaryStoreId: input.operationStore.storeId, + checkoutId: input.checkoutId, + secondaryReservations: [], + actorId: input.actorId, + sessionId: input.sessionId, + authorizationBasis, + entityLocks, + expectedRevisions, + steps: definition.steps.map((step) => ({ id: step.id, state: 'pending' })), + startedAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + assertOperationJournalMatchesDefinition(journal); + return createPreparedOperationJournal(input.operationStore, journal); +} + +function migrationAuthorization( + input: TransportMigrationStartInput, + now: Date, +): AuthorizationBasisV1 { + return createAuthorizationBasis( + { + action: 'team_policy_config_transport', + actorId: input.actorId, + session: { + sessionId: input.sessionId, + actorId: input.actorId, + status: 'active', + }, + joined: input.joined, + sharedWriteGuard: 'enforced', + task: null, + claim: null, + handoff: null, + evidence: null, + profileActorId: null, + conditions: { + expectedRevisionMatches: true, + explicitConfirmation: input.explicitConfirmation, + }, + }, + now, + ); +} + +function migrationGuards(preview: TransportMigrationPreview): { + entityLocks: string[]; + expectedRevisions: Record; +} { + const expectedRevisions: Record = { + [`config:${preview.config.workspaceId}`]: preview.config.revision, + [`migration_manifest:${preview.manifestDigest}`]: 0, + }; + const entityLocks = new Set(Object.keys(expectedRevisions)); + for (const task of preview.snapshot.tasks) { + const taskKey = `task:shared:${task.taskRef.taskId}`; + const headKey = `task_head:${task.taskRef.taskId}`; + expectedRevisions[taskKey] = task.taskRevision; + expectedRevisions[headKey] = task.taskHeadFence.fenceRevision; + entityLocks.add(taskKey); + entityLocks.add(headKey); + } + for (const claim of preview.snapshot.claims) { + const key = `claim:${claim.claimId}`; + expectedRevisions[key] = claim.revision; + entityLocks.add(key); + } + for (const handoff of preview.snapshot.handoffs) { + const key = `handoff:${handoff.handoffId}`; + expectedRevisions[key] = handoff.revision; + entityLocks.add(key); + } + ensureCollectionGuard(expectedRevisions, entityLocks, 'task_head'); + ensureCollectionGuard(expectedRevisions, entityLocks, 'claim'); + ensureCollectionGuard(expectedRevisions, entityLocks, 'handoff'); + return { + entityLocks: [...entityLocks].sort(compareUtf8), + expectedRevisions: Object.fromEntries( + Object.entries(expectedRevisions).sort(([left], [right]) => + compareUtf8(left, right), + ), + ), + }; +} + +function ensureCollectionGuard( + expectedRevisions: Record, + entityLocks: Set, + prefix: 'task_head' | 'claim' | 'handoff', +): void { + if ( + Object.keys(expectedRevisions).some((key) => key.startsWith(`${prefix}:`)) + ) { + return; + } + const key = `${prefix}:collection`; + expectedRevisions[key] = 0; + entityLocks.add(key); +} + +async function ensureStepIntent( + store: EntityHomeStore, + journal: OperationJournalV1, + stepId: string, +): Promise { + const step = journal.steps.find((candidate) => candidate.id === stepId); + if (step === undefined) throw new Error('MANCODE_OPERATION_STEP_INVALID'); + if (step.state === 'completed') return journal; + return updateOperationJournal( + store, + { + ...journal, + state: + journal.state === 'repair_required' ? 'repair_required' : 'applying', + steps: completeStep(journal.steps, stepId), + updatedAt: new Date().toISOString(), + }, + { canAbort: stepId !== 'switch-config-authority' }, + ); +} + +async function commitJournal( + store: EntityHomeStore, + journal: OperationJournalV1, +): Promise { + if (journal.state === 'committed') return journal; + const committed = await updateOperationJournal( + store, + { + ...journal, + state: 'committed', + steps: completeStep(journal.steps, 'commit'), + updatedAt: new Date().toISOString(), + }, + { canAbort: false }, + ); + throwIfOperationCrashInjected('transport_migrate', 'commit'); + return committed; +} + +function completeStep(steps: OperationStep[], stepId: string): OperationStep[] { + const index = steps.findIndex((step) => step.id === stepId); + if (index < 0) throw new Error('MANCODE_OPERATION_STEP_INVALID'); + if (steps.slice(0, index).some((step) => step.state !== 'completed')) { + throw new Error('MANCODE_OPERATION_STEP_ORDER_INVALID'); + } + return steps.map((step, currentIndex) => + currentIndex === index ? { ...step, state: 'completed' as const } : step, + ); +} + +async function settleMigrationFailure( + input: TransportMigrationStartInput, + staleJournal: OperationJournalV1, +): Promise { + const journal = + (await readOperationJournal( + input.operationStore, + staleJournal.operationId, + )) ?? staleJournal; + if (hasBusinessWriteIntent(journal)) { + await markRepairRequired(input.operationStore, journal); + return; + } + try { + await input.source.unfreeze({ operationId: journal.operationId }); + await updateOperationJournal( + input.operationStore, + { ...journal, state: 'aborted', updatedAt: new Date().toISOString() }, + { canAbort: true }, + ); + } catch { + await markRepairRequired(input.operationStore, journal); + } +} + +async function markRepairRequired( + store: EntityHomeStore, + staleJournal: OperationJournalV1, +): Promise { + const journal = + (await readOperationJournal(store, staleJournal.operationId)) ?? + staleJournal; + if ( + journal.state === 'committed' || + journal.state === 'aborted' || + journal.state === 'repair_required' + ) { + return; + } + try { + await updateOperationJournal( + store, + { + ...journal, + state: 'repair_required', + updatedAt: new Date().toISOString(), + }, + { canAbort: false }, + ); + } catch { + // The durable non-terminal journal remains a write blocker. + } +} + +function hasBusinessWriteIntent(journal: OperationJournalV1): boolean { + const definition = getOperationDefinition(journal.type); + return journal.steps.some( + (step, index) => + step.state === 'completed' && + definition.steps[index]?.visibility === 'business_write', + ); +} + +async function activateConfig( + configAdapter: TransportMigrationConfigAdapter, + manifest: TransportMigrationManifestV1, +): Promise { + const current = await readConfig(configAdapter); + const next = targetConfig(manifest); + if (digestCanonicalJson(current) === digestCanonicalJson(next)) + return current; + if ( + digestCanonicalJson(current) !== digestCanonicalJson(manifest.source.config) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_SPLIT_BRAIN'); + } + const written = parseProjectConfig( + await configAdapter.compareAndSwap({ + expectedRevision: current.revision, + expectedTransportEpoch: current.transport.epoch, + next, + }), + ); + if (digestCanonicalJson(written) !== digestCanonicalJson(next)) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_CONFIG_CONFLICT'); + } + return written; +} + +function targetConfig(manifest: TransportMigrationManifestV1): ProjectConfigV1 { + const previous = manifest.source.config; + const next = parseProjectConfig({ + ...previous, + revision: previous.revision + 1, + transport: { + mode: manifest.target.mode, + remote: manifest.target.remote, + epoch: manifest.target.transportEpoch, + }, + lastOperationId: manifest.operationId, + updatedAt: manifest.createdAt, + }); + assertProjectConfigTransition(previous, next, 'transport_migrate'); + return next; +} + +function createTombstone( + manifest: TransportMigrationManifestV1, + established: EstablishedTransportAuthorityV1, + activatedConfig: ProjectConfigV1, +): TransportAuthorityTombstoneV1 { + return { + schemaVersion: 1, + operationId: manifest.operationId, + workspaceId: manifest.workspaceId, + sourceAuthorityId: manifest.source.authorityId, + sourceTransportEpoch: manifest.source.config.transport.epoch, + sourceCoordinationDomainId: manifest.source.coordinationDomainId, + targetAuthorityId: manifest.target.authorityId, + targetTransportEpoch: manifest.target.transportEpoch, + targetCoordinationDomainId: manifest.target.coordinationDomainId, + manifestDigest: digestCanonicalJson(manifest), + authorityReceipt: established.receipt, + activatedConfigRevision: activatedConfig.revision, + createdAt: manifest.createdAt, + }; +} + +function createManifest( + input: Pick & { + operationId: Ulid; + actorId: Ulid; + }, + config: ProjectConfigV1, + snapshot: TransportMigrationAuthoritySnapshotV1, + now: Date, +): TransportMigrationManifestV1 { + return parseTransportMigrationManifest({ + schemaVersion: 1, + operationId: input.operationId, + actorId: input.actorId, + workspaceId: config.workspaceId, + source: { + config, + authorityId: snapshot.authorityId, + coordinationDomainId: snapshot.coordinationDomainId, + }, + target: { + mode: input.target.mode, + remote: input.target.remote, + authorityId: input.target.authorityId, + transportEpoch: config.transport.epoch + 1, + coordinationDomainId: input.target.coordinationDomainId, + }, + actorProfiles: snapshot.actorProfiles, + tasks: snapshot.tasks, + taskBundles: snapshot.taskBundles, + sourceClaims: snapshot.claims, + handoffs: snapshot.handoffs, + claimPolicy: 'reissue-active', + createdAt: now.toISOString(), + }); +} + +function snapshotFromManifest( + manifest: TransportMigrationManifestV1, +): TransportMigrationAuthoritySnapshotV1 { + return { + schemaVersion: 1, + workspaceId: manifest.workspaceId, + authorityId: manifest.source.authorityId, + transportMode: manifest.source.config.transport.mode, + transportEpoch: manifest.source.config.transport.epoch, + coordinationDomainId: manifest.source.coordinationDomainId, + pendingOperationIds: [], + actorProfiles: manifest.actorProfiles, + tasks: manifest.tasks, + taskBundles: manifest.taskBundles, + claims: manifest.sourceClaims, + handoffs: manifest.handoffs, + }; +} + +async function inspectSource( + source: TransportMigrationSourceAdapter, + config: ProjectConfigV1, + operationId: Ulid, +): Promise { + const snapshot = parseTransportMigrationAuthoritySnapshot( + await source.inspect(), + ); + if ( + snapshot.workspaceId !== config.workspaceId || + snapshot.authorityId !== source.authorityId || + snapshot.transportMode !== source.mode || + snapshot.transportEpoch !== config.transport.epoch + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_SOURCE_MISMATCH'); + } + assertAuthoritySnapshotConsistency(snapshot, operationId); + return { + ...snapshot, + pendingOperationIds: snapshot.pendingOperationIds.filter( + (candidate) => candidate !== operationId, + ), + }; +} + +async function assertSourceFrozen( + source: TransportMigrationSourceAdapter, + manifest: TransportMigrationManifestV1, +): Promise { + await source.assertFrozen({ + operationId: manifest.operationId, + expectedTransportEpoch: manifest.source.config.transport.epoch, + }); +} + +function assertNoPendingOperations( + snapshot: TransportMigrationAuthoritySnapshotV1, + operationId: Ulid, +): void { + if ( + snapshot.pendingOperationIds.some((candidate) => candidate !== operationId) + ) { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } +} + +function assertStartInput(input: TransportMigrationStartInput): void { + assertUlid(input.operationId, 'transport migration operationId'); + assertUlid(input.checkoutId, 'transport migration checkoutId'); + assertUlid(input.actorId, 'transport migration actorId'); + assertUlid(input.sessionId, 'transport migration sessionId'); + if ( + !Number.isSafeInteger(input.expectedConfigRevision) || + input.expectedConfigRevision < 1 + ) { + throw new Error('MANCODE_EXPECTED_REVISION_INVALID'); + } + if ( + typeof input.joined !== 'boolean' || + typeof input.explicitConfirmation !== 'boolean' + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_CONFIRMATION_INVALID'); + } + assertAdapterPair(input.source, input.target); +} + +function assertRecoveryInput(input: TransportMigrationRecoveryInput): void { + assertUlid(input.operationId, 'transport migration operationId'); + assertUlid(input.actorId, 'transport migration actorId'); + assertUlid(input.sessionId, 'transport migration sessionId'); + assertAdapterPair(input.source, input.target); +} + +function assertAdapterPair( + source: TransportMigrationSourceAdapter, + target: TransportMigrationTargetAdapter, +): void { + if (source.mode === target.mode) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_MODE_UNCHANGED'); + } + assertTransportRemote(source.mode, source.remote); + assertTransportRemote(target.mode, target.remote); + assertAuthorityId(source.authorityId, 'source authorityId'); + assertAuthorityId(target.authorityId, 'target authorityId'); + assertCoordinationDomainId(target.coordinationDomainId); + if ( + source.authorityId === target.authorityId || + !target.coordinationDomainId.startsWith(`${target.mode}:`) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_TARGET_INVALID'); + } +} + +function assertSourceConfig( + config: ProjectConfigV1, + input: Pick, +): void { + if ( + config.transport.mode !== input.source.mode || + config.transport.remote !== input.source.remote + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_SOURCE_MISMATCH'); + } +} + +function assertRecoveryIdentity( + input: TransportMigrationRecoveryInput, + journal: OperationJournalV1, +): void { + if ( + journal.primaryStoreId !== input.operationStore.storeId || + journal.actorId !== input.actorId || + journal.sessionId !== input.sessionId + ) { + throw new Error('MANCODE_OPERATION_RECOVERY_AUTHORIZATION_MISMATCH'); + } +} + +function assertJournalManifestBinding( + journal: OperationJournalV1, + manifest: TransportMigrationManifestV1, +): void { + const digest = digestCanonicalJson(manifest); + const key = `migration_manifest:${digest}`; + if ( + journal.expectedRevisions[key] !== 0 || + !journal.entityLocks.includes(key) || + manifest.operationId !== journal.operationId + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_MANIFEST_CONFLICT'); + } +} + +function assertStagedMatches( + target: TransportMigrationTargetAdapter, + manifest: TransportMigrationManifestV1, + staged: StagedTransportAuthorityV1, +): void { + if ( + staged.operationId !== manifest.operationId || + staged.manifestDigest !== digestCanonicalJson(manifest) || + staged.transportEpoch !== manifest.target.transportEpoch || + staged.coordinationDomainId !== manifest.target.coordinationDomainId || + digestCanonicalJson(staged.manifest) !== digestCanonicalJson(manifest) || + target.authorityId !== manifest.target.authorityId + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_STAGE_CONFLICT'); + } +} + +function assertEstablishedMatches( + target: TransportMigrationTargetAdapter, + manifest: TransportMigrationManifestV1, + established: EstablishedTransportAuthorityV1, +): void { + if ( + established.operationId !== manifest.operationId || + established.manifestDigest !== digestCanonicalJson(manifest) || + established.transportEpoch !== manifest.target.transportEpoch || + established.coordinationDomainId !== manifest.target.coordinationDomainId || + target.authorityId !== manifest.target.authorityId + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_ESTABLISH_CONFLICT'); + } + assertReissuedClaims(manifest, established.activeClaims); +} + +function assertReissuedClaims( + manifest: TransportMigrationManifestV1, + targetClaims: ClaimV1[], +): void { + const sourceClaims = manifest.sourceClaims.filter( + (claim) => claim.state === 'active', + ); + if (sourceClaims.length !== targetClaims.length) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_CLAIM_REISSUE_INVALID'); + } + const sourceIds = new Set(sourceClaims.map((claim) => claim.claimId)); + const mapped = new Set(); + for (const target of targetClaims) { + const source = sourceClaims.find( + (candidate) => candidate.claimId === target.predecessorClaimId, + ); + if ( + source === undefined || + sourceIds.has(target.claimId) || + mapped.has(source.claimId) || + target.state !== 'active' || + target.workspaceId !== manifest.workspaceId || + target.coordinationDomainId !== manifest.target.coordinationDomainId || + target.authority.mode !== manifest.target.mode || + !sameClaimAssignment(source, target) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_CLAIM_REISSUE_INVALID'); + } + mapped.add(source.claimId); + } +} + +function sameClaimAssignment(source: ClaimV1, target: ClaimV1): boolean { + return ( + sameTaskRef(source.taskRef, target.taskRef) && + source.taskRevisionAtAcquire === target.taskRevisionAtAcquire && + source.lastValidatedTaskRevision === target.lastValidatedTaskRevision && + source.ownerActorId === target.ownerActorId && + source.implementationScopeDigest === target.implementationScopeDigest && + source.ownershipEpochAtAcquire === target.ownershipEpochAtAcquire && + source.scopeDigest === target.scopeDigest && + digestCanonicalJson(source.scope) === digestCanonicalJson(target.scope) && + digestCanonicalJson(source.codeRefAtAcquire) === + digestCanonicalJson(target.codeRefAtAcquire) && + digestCanonicalJson(source.lastValidatedCodeRef) === + digestCanonicalJson(target.lastValidatedCodeRef) + ); +} + +function assertAuthoritySnapshotConsistency( + snapshot: TransportMigrationAuthoritySnapshotV1, + operationId: Ulid, +): void { + const tasks = new Map( + snapshot.tasks.map((task) => [task.taskRef.taskId, task]), + ); + const profiles = new Set( + snapshot.actorProfiles.map((profile) => profile.actorId), + ); + const bundles = new Map( + snapshot.taskBundles.map((bundle) => [bundle.taskRef.taskId, bundle]), + ); + if ( + profiles.size !== snapshot.actorProfiles.length || + bundles.size !== snapshot.taskBundles.length || + bundles.size !== tasks.size || + snapshot.tasks.some((task) => { + const bundle = bundles.get(task.taskRef.taskId); + return ( + task.ownerActorId === null || + !profiles.has(task.ownerActorId) || + task.taskHeadFence.workspaceId !== snapshot.workspaceId || + bundle === undefined || + bundle.taskRevision !== task.taskRevision || + bundle.ownershipEpoch !== task.ownershipEpoch || + bundle.aggregateDigest !== task.aggregateDigest || + bundle.codeRef.head !== task.taskHeadFence.codeRef.head + ); + }) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_TASK_SNAPSHOT_INVALID'); + } + for (const claim of snapshot.claims) { + const task = tasks.get(claim.taskRef.taskId); + if ( + claim.workspaceId !== snapshot.workspaceId || + claim.coordinationDomainId !== snapshot.coordinationDomainId || + claim.authority.mode !== snapshot.transportMode || + !profiles.has(claim.ownerActorId) || + task === undefined + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_CLAIM_SNAPSHOT_INVALID'); + } + if (claim.state === 'pending') { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } + if ( + claim.state === 'active' && + (claim.ownershipEpochAtAcquire !== task.ownershipEpoch || + claim.lastValidatedTaskRevision !== task.taskRevision || + claim.lastValidatedCodeRef.head !== task.taskHeadFence.codeRef.head) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_CLAIM_SNAPSHOT_INVALID'); + } + } + for (const handoff of snapshot.handoffs) { + const task = tasks.get(handoff.taskRef.taskId); + if ( + task === undefined || + handoff.transport.mode !== snapshot.transportMode || + !profiles.has(handoff.fromActorId) || + !profiles.has(handoff.toActorId) || + ((handoff.state === 'draft' || handoff.state === 'offered') && + handoff.ownershipEpochAtOffer !== task.ownershipEpoch) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_HANDOFF_SNAPSHOT_INVALID'); + } + } + if (snapshot.pendingOperationIds.some((id) => id !== operationId)) { + throw new Error('MANCODE_OPERATION_REPAIR_REQUIRED'); + } +} + +function sameSourceConfigOrActivated( + config: ProjectConfigV1, + manifest: TransportMigrationManifestV1, +): boolean { + return ( + digestCanonicalJson(config) === + digestCanonicalJson(manifest.source.config) || + digestCanonicalJson(config) === digestCanonicalJson(targetConfig(manifest)) + ); +} + +function configMatchesUnstagedJournal( + config: ProjectConfigV1, + input: TransportMigrationRecoveryInput, + journal: OperationJournalV1, +): boolean { + const expectedRevision = + journal.expectedRevisions[`config:${config.workspaceId}`]; + return ( + expectedRevision === config.revision && + config.transport.mode === input.source.mode && + config.transport.remote === input.source.remote + ); +} + +async function readConfig( + adapter: TransportMigrationConfigAdapter, +): Promise { + return parseProjectConfig(await adapter.read()); +} + +export function parseTransportMigrationAuthoritySnapshot( + value: unknown, +): TransportMigrationAuthoritySnapshotV1 { + assertRecord(value, 'transport migration authority snapshot'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'workspaceId', + 'authorityId', + 'transportMode', + 'transportEpoch', + 'coordinationDomainId', + 'pendingOperationIds', + 'actorProfiles', + 'tasks', + 'taskBundles', + 'claims', + 'handoffs', + ], + 'transport migration authority snapshot', + ); + if (value.schemaVersion !== 1) { + throw new Error( + 'transport migration authority snapshot schemaVersion is invalid', + ); + } + assertUlid(value.workspaceId, 'transport migration workspaceId'); + const transportMode = parseTransportMode(value.transportMode); + const snapshot: TransportMigrationAuthoritySnapshotV1 = { + schemaVersion: 1, + workspaceId: value.workspaceId, + authorityId: parseAuthorityId(value.authorityId), + transportMode, + transportEpoch: parsePositiveInteger( + value.transportEpoch, + 'transportEpoch', + ), + coordinationDomainId: parseCoordinationDomainId(value.coordinationDomainId), + pendingOperationIds: parseUlidSet( + value.pendingOperationIds, + 'pendingOperationIds', + ), + actorProfiles: parseCollection( + value.actorProfiles, + 'actorProfiles', + parseSharedActorProfile, + ).sort((left, right) => compareUtf8(left.actorId, right.actorId)), + tasks: parseTaskSnapshots(value.tasks), + taskBundles: parseCollection( + value.taskBundles, + 'taskBundles', + parseGitRefTaskBundle, + ).sort((left, right) => + compareUtf8(left.taskRef.taskId, right.taskRef.taskId), + ), + claims: parseCollection(value.claims, 'claims', parseClaim).sort( + (left, right) => compareUtf8(left.claimId, right.claimId), + ), + handoffs: parseCollection(value.handoffs, 'handoffs', parseHandoff).sort( + (left, right) => compareUtf8(left.handoffId, right.handoffId), + ), + }; + if (!snapshot.coordinationDomainId.startsWith(`${transportMode}:`)) { + throw new Error('transport migration coordinationDomainId mode is invalid'); + } + assertUniqueCoordinationEntities(snapshot.claims, snapshot.handoffs); + return snapshot; +} + +export function parseTransportMigrationManifest( + value: unknown, +): TransportMigrationManifestV1 { + assertRecord(value, 'transport migration manifest'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'actorId', + 'workspaceId', + 'source', + 'target', + 'actorProfiles', + 'tasks', + 'taskBundles', + 'sourceClaims', + 'handoffs', + 'claimPolicy', + 'createdAt', + ], + 'transport migration manifest', + ); + if (value.schemaVersion !== 1 || value.claimPolicy !== 'reissue-active') { + throw new Error('transport migration manifest schema is invalid'); + } + assertUlid(value.operationId, 'transport migration manifest operationId'); + assertUlid(value.actorId, 'transport migration manifest actorId'); + assertUlid(value.workspaceId, 'transport migration manifest workspaceId'); + assertRecord(value.source, 'transport migration manifest source'); + assertKnownKeys( + value.source, + ['config', 'authorityId', 'coordinationDomainId'], + 'transport migration manifest source', + ); + assertRecord(value.target, 'transport migration manifest target'); + assertKnownKeys( + value.target, + ['mode', 'remote', 'authorityId', 'transportEpoch', 'coordinationDomainId'], + 'transport migration manifest target', + ); + const config = parseProjectConfig(value.source.config); + const targetMode = parseTransportMode(value.target.mode); + const targetRemote = parseTransportRemote(targetMode, value.target.remote); + const manifest: TransportMigrationManifestV1 = { + schemaVersion: 1, + operationId: value.operationId, + actorId: value.actorId, + workspaceId: value.workspaceId, + source: { + config, + authorityId: parseAuthorityId(value.source.authorityId), + coordinationDomainId: parseCoordinationDomainId( + value.source.coordinationDomainId, + ), + }, + target: { + mode: targetMode, + remote: targetRemote, + authorityId: parseAuthorityId(value.target.authorityId), + transportEpoch: parsePositiveInteger( + value.target.transportEpoch, + 'target transportEpoch', + ), + coordinationDomainId: parseCoordinationDomainId( + value.target.coordinationDomainId, + ), + }, + actorProfiles: parseCollection( + value.actorProfiles, + 'actorProfiles', + parseSharedActorProfile, + ).sort((left, right) => compareUtf8(left.actorId, right.actorId)), + tasks: parseTaskSnapshots(value.tasks), + taskBundles: parseCollection( + value.taskBundles, + 'taskBundles', + parseGitRefTaskBundle, + ).sort((left, right) => + compareUtf8(left.taskRef.taskId, right.taskRef.taskId), + ), + sourceClaims: parseCollection( + value.sourceClaims, + 'sourceClaims', + parseClaim, + ).sort((left, right) => compareUtf8(left.claimId, right.claimId)), + handoffs: parseCollection(value.handoffs, 'handoffs', parseHandoff).sort( + (left, right) => compareUtf8(left.handoffId, right.handoffId), + ), + claimPolicy: 'reissue-active', + createdAt: parseTimestamp(value.createdAt, 'createdAt'), + }; + if ( + manifest.workspaceId !== config.workspaceId || + !manifest.actorProfiles.some( + (profile) => profile.actorId === manifest.actorId, + ) || + manifest.target.mode === config.transport.mode || + manifest.target.transportEpoch !== config.transport.epoch + 1 || + !manifest.target.coordinationDomainId.endsWith( + targetMode === 'git-ref' + ? `:${manifest.workspaceId}:${manifest.target.transportEpoch}` + : `:${manifest.workspaceId}`, + ) || + manifest.target.coordinationDomainId === + manifest.source.coordinationDomainId || + !manifest.source.coordinationDomainId.startsWith( + `${config.transport.mode}:`, + ) || + !manifest.target.coordinationDomainId.startsWith(`${targetMode}:`) + ) { + throw new Error( + 'transport migration manifest authority transition is invalid', + ); + } + assertUniqueCoordinationEntities(manifest.sourceClaims, manifest.handoffs); + assertAuthoritySnapshotConsistency( + snapshotFromManifest(manifest), + manifest.operationId, + ); + return manifest; +} + +export function parseStagedTransportAuthority( + value: unknown, +): StagedTransportAuthorityV1 { + assertRecord(value, 'staged transport authority'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'manifest', + 'manifestDigest', + 'transportEpoch', + 'coordinationDomainId', + 'stagedAt', + ], + 'staged transport authority', + ); + if (value.schemaVersion !== 1) { + throw new Error('staged transport authority schemaVersion is invalid'); + } + assertUlid(value.operationId, 'staged transport authority operationId'); + return { + schemaVersion: 1, + operationId: value.operationId, + manifest: parseTransportMigrationManifest(value.manifest), + manifestDigest: parseDigest(value.manifestDigest, 'manifestDigest'), + transportEpoch: parsePositiveInteger( + value.transportEpoch, + 'transportEpoch', + ), + coordinationDomainId: parseCoordinationDomainId(value.coordinationDomainId), + stagedAt: parseTimestamp(value.stagedAt, 'stagedAt'), + }; +} + +export function parseEstablishedTransportAuthority( + value: unknown, +): EstablishedTransportAuthorityV1 { + assertRecord(value, 'established transport authority'); + assertKnownKeys( + value, + [ + 'schemaVersion', + 'operationId', + 'manifestDigest', + 'transportEpoch', + 'coordinationDomainId', + 'authorityRevision', + 'activeClaims', + 'receipt', + 'establishedAt', + ], + 'established transport authority', + ); + if (value.schemaVersion !== 1) { + throw new Error('established transport authority schemaVersion is invalid'); + } + assertUlid(value.operationId, 'established transport authority operationId'); + const established: EstablishedTransportAuthorityV1 = { + schemaVersion: 1, + operationId: value.operationId, + manifestDigest: parseDigest(value.manifestDigest, 'manifestDigest'), + transportEpoch: parsePositiveInteger( + value.transportEpoch, + 'transportEpoch', + ), + coordinationDomainId: parseCoordinationDomainId(value.coordinationDomainId), + authorityRevision: parsePositiveInteger( + value.authorityRevision, + 'authorityRevision', + ), + activeClaims: parseCollection( + value.activeClaims, + 'activeClaims', + parseClaim, + ).sort((left, right) => compareUtf8(left.claimId, right.claimId)), + receipt: parseNonEmptyString(value.receipt, 'receipt'), + establishedAt: parseTimestamp(value.establishedAt, 'establishedAt'), + }; + assertUniqueCoordinationEntities(established.activeClaims, []); + return established; +} + +function assertUniqueCoordinationEntities( + claims: ClaimV1[], + handoffs: HandoffV1[], +): void { + if ( + new Set(claims.map((claim) => claim.claimId)).size !== claims.length || + new Set(handoffs.map((handoff) => handoff.handoffId)).size !== + handoffs.length + ) { + throw new Error('transport migration authority has duplicate entities'); + } +} + +function parseTaskSnapshots( + value: unknown, +): TransportMigrationTaskSnapshotV1[] { + const tasks = parseCollection(value, 'tasks', parseTaskSnapshot).sort( + (left, right) => compareUtf8(left.taskRef.taskId, right.taskRef.taskId), + ); + const ids = new Set(); + for (const task of tasks) { + if (ids.has(task.taskRef.taskId)) { + throw new Error('transport migration task snapshots have duplicates'); + } + ids.add(task.taskRef.taskId); + } + return tasks; +} + +function parseTaskSnapshot(value: unknown): TransportMigrationTaskSnapshotV1 { + assertRecord(value, 'transport migration task snapshot'); + assertKnownKeys( + value, + [ + 'taskRef', + 'transitionState', + 'taskRevision', + 'ownerActorId', + 'ownershipEpoch', + 'aggregateDigest', + 'taskHeadFence', + ], + 'transport migration task snapshot', + ); + const taskRef = parseTaskRefValue(value.taskRef); + if (taskRef.namespace !== 'shared' || value.transitionState !== 'stable') { + throw new Error('transport migration requires stable shared tasks'); + } + const ownerActorId = value.ownerActorId; + if (ownerActorId !== null) { + assertUlid(ownerActorId, 'transport migration task ownerActorId'); + } + const taskRevision = parsePositiveInteger(value.taskRevision, 'taskRevision'); + const ownershipEpoch = parseNonNegativeInteger( + value.ownershipEpoch, + 'ownershipEpoch', + ); + const aggregateDigest = parseDigest(value.aggregateDigest, 'aggregateDigest'); + const taskHeadFence = parseTaskHeadFence(value.taskHeadFence); + if ( + !sameTaskRef(taskHeadFence.taskRef, taskRef) || + taskHeadFence.taskRevision !== taskRevision || + taskHeadFence.ownershipEpoch !== ownershipEpoch || + taskHeadFence.aggregateDigest !== aggregateDigest + ) { + throw new Error( + 'transport migration task head does not match its stable task', + ); + } + return { + taskRef, + transitionState: 'stable', + taskRevision, + ownerActorId, + ownershipEpoch, + aggregateDigest, + taskHeadFence, + }; +} + +function parseCollection( + value: unknown, + label: string, + parser: (item: unknown) => T, +): T[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + return value.map(parser); +} + +function parseUlidSet(value: unknown, label: string): Ulid[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + const parsed = value.map((item) => { + assertUlid(item, label); + return item; + }); + const unique = new Set(parsed); + if (unique.size !== parsed.length) throw new Error(`${label} has duplicates`); + return [...unique].sort(compareUtf8); +} + +function parseTransportMode(value: unknown): CoordinationTransport { + if (value !== 'local' && value !== 'git-ref') { + throw new Error('transport migration mode is invalid'); + } + return value; +} + +function parseTransportRemote( + mode: CoordinationTransport, + value: unknown, +): string | null { + if (value !== null && (typeof value !== 'string' || !value.trim())) { + throw new Error('transport migration remote is invalid'); + } + assertTransportRemote(mode, value); + return value; +} + +function assertTransportRemote( + mode: CoordinationTransport, + remote: unknown, +): asserts remote is string | null { + if ( + (mode === 'local' && remote !== null) || + (mode === 'git-ref' && + (typeof remote !== 'string' || !remote.trim() || remote.includes('\0'))) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_REMOTE_INVALID'); + } +} + +function parseAuthorityId(value: unknown): string { + assertAuthorityId(value, 'authorityId'); + return value; +} + +function assertAuthorityId( + value: unknown, + label: string, +): asserts value is string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error(`transport migration ${label} is invalid`); + } +} + +function parseCoordinationDomainId(value: unknown): string { + assertCoordinationDomainId(value); + return value; +} + +function assertCoordinationDomainId(value: unknown): asserts value is string { + if ( + typeof value !== 'string' || + !/^(local|git-ref):[^\0]+$/.test(value) || + value.includes('..') + ) { + throw new Error('transport migration coordinationDomainId is invalid'); + } +} + +function parsePositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function parseNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function parseDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be a sha256 digest`); + } + return value; +} + +function parseTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp`); + } + return value; +} + +function parseNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.from(left, 'utf8').compare(Buffer.from(right, 'utf8')); +} diff --git a/src/team/transport.ts b/src/team/transport.ts new file mode 100644 index 0000000..645da4c --- /dev/null +++ b/src/team/transport.ts @@ -0,0 +1,341 @@ +import { type Ulid, assertUlid } from '../context/ids.js'; +import { + type CapabilityLevel, + type Freshness, + parseCapabilityLevel, + parseFreshness, +} from './capabilities.js'; +import type { ProjectConfigV1 } from './policy.js'; + +export type TransportMode = 'local' | 'git-ref' | 'external' | 'unavailable'; + +export interface CoordinationCapabilitiesV1 { + claimAcquisition: CapabilityLevel; + writeGuard: CapabilityLevel; + transport: TransportMode; + transportFreshness: Freshness; + lastSuccessfulSyncAt: string | null; + remoteRevision: number | null; +} + +export interface TransportMutationRequest { + operationId: Ulid; + expectedRemoteRevision: number; + expectedOwnershipEpoch: number; +} + +export interface CoordinationTransport { + readonly mode: Exclude; + inspect(): Promise; + pull(): Promise; + push(request: TransportMutationRequest): Promise; +} + +/** + * The backend owns the real remote read/CAS. Its inspect method must be a + * cache-only read; only pull and push are explicit network boundaries. + */ +export interface GitRefCoordinationTransportBackend { + inspect(): Promise; + pull(): Promise; + push(request: TransportMutationRequest): Promise; +} + +export class LocalCoordinationTransportAdapter + implements CoordinationTransport +{ + readonly mode = 'local' as const; + private readonly capabilities: CoordinationCapabilitiesV1; + + constructor( + claimAcquisition: CapabilityLevel = 'enforced', + writeGuard: CapabilityLevel = 'advisory', + ) { + this.capabilities = localCoordinationCapabilities( + claimAcquisition, + writeGuard, + ); + } + + async inspect(): Promise { + return { ...this.capabilities }; + } + + async pull(): Promise { + return this.inspect(); + } + + async push(request: TransportMutationRequest): Promise { + assertTransportMutationRequest(request); + throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); + } +} + +export class GitRefCoordinationTransportAdapter + implements CoordinationTransport +{ + readonly mode = 'git-ref' as const; + + constructor(private readonly backend: GitRefCoordinationTransportBackend) { + if ( + typeof backend?.inspect !== 'function' || + typeof backend.pull !== 'function' || + typeof backend.push !== 'function' + ) { + throw new Error('MANCODE_TRANSPORT_BACKEND_INVALID'); + } + } + + /** Cache-only by contract; callers use pull for an explicit remote read. */ + async inspect(): Promise { + return gitRefCapabilities(await this.backend.inspect(), false); + } + + async pull(): Promise { + return gitRefCapabilities(await this.backend.pull(), true); + } + + async push( + request: TransportMutationRequest, + ): Promise { + assertTransportMutationRequest(request); + const before = await this.inspect(); + assertRemoteMutationAvailable(before, request); + const after = gitRefCapabilities(await this.backend.push(request), true); + if ( + after.remoteRevision === null || + after.remoteRevision < request.expectedRemoteRevision + ) { + throw new Error('MANCODE_TRANSPORT_BACKEND_CONTRACT'); + } + return after; + } +} + +export function parseCoordinationCapabilities( + value: unknown, +): CoordinationCapabilitiesV1 { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('coordination capabilities must be an object'); + } + const record = value as Record; + const allowed = new Set([ + 'claimAcquisition', + 'writeGuard', + 'transport', + 'transportFreshness', + 'lastSuccessfulSyncAt', + 'remoteRevision', + ]); + for (const key of Object.keys(record)) { + if (!allowed.has(key)) { + throw new Error(`coordination capabilities has unknown field: ${key}`); + } + } + const capabilities: CoordinationCapabilitiesV1 = { + claimAcquisition: parseCapabilityLevel( + record.claimAcquisition, + 'claimAcquisition', + ), + writeGuard: parseCapabilityLevel(record.writeGuard, 'writeGuard'), + transport: parseTransportMode(record.transport), + transportFreshness: parseFreshness( + record.transportFreshness, + 'transportFreshness', + ), + lastSuccessfulSyncAt: parseTimestampOrNull( + record.lastSuccessfulSyncAt, + 'lastSuccessfulSyncAt', + ), + remoteRevision: parseNonNegativeIntegerOrNull( + record.remoteRevision, + 'remoteRevision', + ), + }; + assertCoordinationCapabilitiesShape(capabilities); + return capabilities; +} + +export function localCoordinationCapabilities( + claimAcquisition: CapabilityLevel = 'enforced', + writeGuard: CapabilityLevel = 'advisory', +): CoordinationCapabilitiesV1 { + const capabilities: CoordinationCapabilitiesV1 = { + claimAcquisition, + writeGuard, + transport: 'local', + transportFreshness: 'unavailable', + lastSuccessfulSyncAt: null, + remoteRevision: null, + }; + assertCoordinationCapabilitiesShape(capabilities); + return capabilities; +} + +export function capabilitiesFromProjectConfig( + config: ProjectConfigV1, + localClaimAcquisition: CapabilityLevel = 'enforced', + writeGuard: CapabilityLevel = 'advisory', +): CoordinationCapabilitiesV1 { + if (config.transport.mode === 'local') { + return localCoordinationCapabilities(localClaimAcquisition, writeGuard); + } + return { + claimAcquisition: 'unavailable', + writeGuard, + transport: 'git-ref', + transportFreshness: 'unknown', + lastSuccessfulSyncAt: null, + remoteRevision: null, + }; +} + +export function assertRemoteMutationAvailable( + capabilities: CoordinationCapabilitiesV1, + request: TransportMutationRequest, +): void { + assertCoordinationCapabilitiesShape(capabilities); + assertTransportMutationRequest(request); + if ( + capabilities.transport !== 'git-ref' || + capabilities.transportFreshness !== 'fresh' || + capabilities.claimAcquisition === 'unavailable' + ) { + throw new Error('MANCODE_TRANSPORT_UNAVAILABLE'); + } + if ( + capabilities.remoteRevision === null || + capabilities.remoteRevision !== request.expectedRemoteRevision + ) { + throw new Error('MANCODE_TRANSPORT_REVISION_CONFLICT'); + } +} + +export function assertTransportMutationRequest( + request: TransportMutationRequest, +): void { + assertUlid(request.operationId, 'transport mutation operationId'); + if ( + !Number.isSafeInteger(request.expectedRemoteRevision) || + request.expectedRemoteRevision < 0 || + !Number.isSafeInteger(request.expectedOwnershipEpoch) || + request.expectedOwnershipEpoch < 0 + ) { + throw new Error( + 'transport mutation expected revisions must be non-negative integers', + ); + } +} + +export function assertCoordinationCapabilitiesShape( + capabilities: CoordinationCapabilitiesV1, +): void { + if (capabilities.transport === 'local') { + if ( + capabilities.transportFreshness !== 'unavailable' || + capabilities.lastSuccessfulSyncAt !== null || + capabilities.remoteRevision !== null + ) { + throw new Error( + 'local transport must report remote freshness, sync time, and remote revision as unavailable', + ); + } + } + if (capabilities.transport === 'unavailable') { + if ( + capabilities.transportFreshness !== 'unavailable' || + capabilities.remoteRevision !== null + ) { + throw new Error( + 'unavailable transport cannot report remote freshness or revision', + ); + } + } + if ( + capabilities.transport === 'git-ref' && + capabilities.transportFreshness === 'fresh' + ) { + if ( + capabilities.lastSuccessfulSyncAt === null || + capabilities.remoteRevision === null + ) { + throw new Error( + 'fresh git-ref transport requires sync time and remote revision', + ); + } + } + if ( + capabilities.transport === 'git-ref' && + capabilities.transportFreshness === 'stale' && + (capabilities.lastSuccessfulSyncAt === null || + capabilities.remoteRevision === null) + ) { + throw new Error( + 'stale git-ref transport requires its last sync time and remote revision', + ); + } + if ( + capabilities.transport === 'git-ref' && + capabilities.transportFreshness === 'unknown' && + (capabilities.lastSuccessfulSyncAt !== null || + capabilities.remoteRevision !== null) + ) { + throw new Error( + 'unknown git-ref transport cannot report a sync time or remote revision', + ); + } + if ( + capabilities.transport === 'git-ref' && + capabilities.transportFreshness !== 'fresh' && + capabilities.claimAcquisition === 'enforced' + ) { + throw new Error( + 'git-ref claim acquisition may only be enforced from a fresh snapshot', + ); + } +} + +function gitRefCapabilities( + value: unknown, + requireFresh: boolean, +): CoordinationCapabilitiesV1 { + const capabilities = parseCoordinationCapabilities(value); + if (capabilities.transport !== 'git-ref') { + throw new Error('MANCODE_TRANSPORT_BACKEND_MODE_MISMATCH'); + } + if (requireFresh && capabilities.transportFreshness !== 'fresh') { + throw new Error('MANCODE_TRANSPORT_SYNC_INCOMPLETE'); + } + return capabilities; +} + +function parseTransportMode(value: unknown): TransportMode { + if ( + value !== 'local' && + value !== 'git-ref' && + value !== 'external' && + value !== 'unavailable' + ) { + throw new Error('transport is invalid'); + } + return value; +} + +function parseTimestampOrNull(value: unknown, label: string): string | null { + if (value === null) return null; + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${label} must be an ISO timestamp or null`); + } + return value; +} + +function parseNonNegativeIntegerOrNull( + value: unknown, + label: string, +): number | null { + if (value === null) return null; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer or null`); + } + return value; +} diff --git a/tests/actor-contracts.test.ts b/tests/actor-contracts.test.ts new file mode 100644 index 0000000..e9b4376 --- /dev/null +++ b/tests/actor-contracts.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + createLocalActor, + createSharedActorProfile, + parseSharedActorProfile, + publishSharedActorProfile, + readLocalActor, + readSharedActorProfile, +} from '../src/team/actor.js'; + +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); +}); + +describe('actor identity and public profile contracts', () => { + it('creates one local actor identity without an implicitly shared email', async () => { + const root = await temporaryRoot(); + const actor = await createLocalActor(root, { + actorId: ACTOR_ID, + displayName: 'Alice Example', + now: new Date('2026-07-17T10:00:00.000Z'), + }); + await expect(readLocalActor(root)).resolves.toEqual(actor); + await expect( + createLocalActor(root, { displayName: 'Another Alice' }), + ).rejects.toThrow('MANCODE_LOCAL_ACTOR_EXISTS'); + expect(() => + parseSharedActorProfile({ + ...createSharedActorProfile(actor), + email: 'a@example.com', + }), + ).toThrow(/unknown field/); + }); + + it('preflights and publishes the narrow public profile idempotently', async () => { + const root = await temporaryRoot(); + const actor = await createLocalActor(root, { + actorId: ACTOR_ID, + displayName: 'Alice Example', + }); + const profile = createSharedActorProfile( + actor, + new Date('2026-07-17T10:00:00.000Z'), + ); + await expect(publishSharedActorProfile(root, profile)).resolves.toEqual( + profile, + ); + await expect(publishSharedActorProfile(root, profile)).resolves.toEqual( + profile, + ); + await expect(readSharedActorProfile(root, ACTOR_ID)).resolves.toEqual( + profile, + ); + }); + + it('rejects sensitive display data before it reaches the shared profile', async () => { + const root = await temporaryRoot(); + const actor = await createLocalActor(root, { + actorId: ACTOR_ID, + displayName: '/Users/alice/private', + }); + expect(() => createSharedActorProfile(actor)).toThrow( + 'MANCODE_PRIVACY_BLOCKED', + ); + }); +}); + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'mancode-actor-contract-')); + roots.push(root); + return root; +} diff --git a/tests/aggregate-contracts.test.ts b/tests/aggregate-contracts.test.ts new file mode 100644 index 0000000..ec134ca --- /dev/null +++ b/tests/aggregate-contracts.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from 'vitest'; +import { + type TaskAggregateInput, + assertTaskAggregateConsistency, + assertTaskCompletionGate, + buildTaskAggregateManifest, + parseTaskAggregateManifest, + taskAggregateDigest, +} from '../src/context/aggregate.js'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import { + type RequirementsLedgerV1, + requirementsLedgerDigest, +} from '../src/context/requirements-ledger.js'; +import { + type ReviewLedgerV1, + reviewLedgerDigest, +} from '../src/context/review-ledger.js'; +import { + type VerificationLedgerV1, + verificationLedgerDigest, +} from '../src/context/verification-ledger.js'; +import type { WorkflowMetadataV3 } from '../src/context/workflow-metadata.js'; + +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const DECISION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const REQUIREMENT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const CRITERION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; +const CHECK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7P'; +const EVIDENCE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7Q'; +const REPORT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7R'; +const PLAN_DIGEST = `sha256:${'b'.repeat(64)}`; + +describe('task aggregate V3 contract', () => { + it('builds a typed aggregate manifest only when metadata caches match ledgers', () => { + const input = aggregate(); + const manifest = buildTaskAggregateManifest(input); + expect(parseTaskAggregateManifest(manifest)).toEqual(manifest); + expect(taskAggregateDigest(manifest)).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(manifest.requirementsDigest).toBe(input.requirements.contentDigest); + + expect(() => + assertTaskAggregateConsistency({ + ...input, + metadata: { + ...input.metadata, + governance: { + ...input.metadata.governance, + requirementsDigest: `sha256:${'0'.repeat(64)}`, + }, + }, + }), + ).toThrow(/requirementsDigest must match/); + }); + + it('requires the entire aggregate and completion context to be ready', () => { + const input = aggregate(); + expect(() => + assertTaskCompletionGate(input, { + activeChildTaskRefs: [], + hasPendingRepairOperation: false, + activeClaimCount: 1, + claimsWillReleaseOrTransfer: true, + }), + ).not.toThrow(); + expect(() => + assertTaskCompletionGate(input, { + activeChildTaskRefs: [ + { namespace: 'shared', taskId: '01JZ4B6W5Z0A1B2C3D4E5F6G7S' }, + ], + hasPendingRepairOperation: false, + activeClaimCount: 0, + }), + ).toThrow(/no active child workflows/); + expect(() => + assertTaskCompletionGate(input, { + activeChildTaskRefs: [], + hasPendingRepairOperation: false, + activeClaimCount: 1, + }), + ).toThrow(/release or transfer/); + }); +}); + +function aggregate(): TaskAggregateInput { + const requirements = requirementsLedger(); + const review = reviewLedger(requirements.contentDigest); + const verification = verificationLedger(requirements.contentDigest); + return { + metadata: metadata(requirements, review, verification), + requirements, + review, + verification, + planDigest: PLAN_DIGEST, + latestCheckpoint: null, + }; +} + +function requirementsLedger(): RequirementsLedgerV1 { + const draft: RequirementsLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: { namespace: 'shared', taskId: TASK_ID }, + revision: 2, + status: 'confirmed', + goal: 'Rate-limit repeated login failures.', + functionalScope: { + inScope: ['Protect the login endpoint.'], + outOfScope: ['Change account recovery.'], + }, + technicalDecisions: [ + { + displayId: 'TD-1', + legacyId: null, + decisionId: DECISION_ID, + statement: 'Use the existing Redis client.', + }, + ], + defaults: [], + coverage: [ + 'platform', + 'core_scope', + 'technical_stack', + 'data_and_persistence', + 'performance', + 'compatibility', + 'security', + ].map((dimension, index) => ({ + coverageId: coverageId(index), + dimension: + dimension as RequirementsLedgerV1['coverage'][number]['dimension'], + status: 'confirmed' as const, + rationale: `${dimension} is covered.`, + })), + requirements: [ + { + displayId: 'R-1', + legacyId: 'REQ-1', + requirementId: REQUIREMENT_ID, + statement: 'Throttle repeated failed login attempts.', + priority: 'must', + }, + ], + acceptanceCriteria: [ + { + displayId: 'AC-1', + legacyId: 'AC-1', + criterionId: CRITERION_ID, + requirementIds: [REQUIREMENT_ID], + statement: + 'Repeated failed login attempts receive a rate-limit response.', + required: true, + verificationRequirement: 'automated', + }, + ], + blockingUnknowns: [], + legacySource: null, + contentDigest: '', + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; + return { ...draft, contentDigest: requirementsLedgerDigest(draft) }; +} + +function reviewLedger(requirementsDigest: string): ReviewLedgerV1 { + const draft: ReviewLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: { namespace: 'shared', taskId: TASK_ID }, + revision: 1, + status: 'passed', + depth: 'targeted', + requirementsDigest, + planVersion: 2, + requiredDomains: ['quality'], + domains: [ + { + domain: 'quality', + status: 'passed', + reportRef: { + taskRef: { namespace: 'shared', taskId: TASK_ID }, + kind: 'review_report', + artifactId: REPORT_ID, + }, + }, + ], + blockers: [], + remediationRound: 0, + skip: null, + legacySource: null, + contentDigest: '', + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; + return { ...draft, contentDigest: reviewLedgerDigest(draft) }; +} + +function verificationLedger(requirementsDigest: string): VerificationLedgerV1 { + const draft: VerificationLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: { namespace: 'shared', taskId: TASK_ID }, + revision: 1, + status: 'passed', + requirementsDigest, + planVersion: 2, + remediationRound: 0, + checks: [ + { + displayId: 'AC-1', + legacyId: 'AC-1', + checkId: CHECK_ID, + criterionId: CRITERION_ID, + required: true, + verificationRequirement: 'automated', + automated: { + evidenceId: EVIDENCE_ID, + status: 'passed', + summary: 'The automated login test passed.', + command: 'npm test', + exitCode: 0, + artifactRef: null, + confirmedByActorId: null, + confirmationSource: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }, + manual: null, + }, + ], + legacySource: null, + contentDigest: '', + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; + return { ...draft, contentDigest: verificationLedgerDigest(draft) }; +} + +function metadata( + requirements: RequirementsLedgerV1, + review: ReviewLedgerV1, + verification: VerificationLedgerV1, +): WorkflowMetadataV3 { + const scope = { + source: 'explicit' as const, + include: ['src/auth/**'], + exclude: [], + modules: ['auth-api'], + }; + return { + schemaVersion: 3, + taskRef: { namespace: 'shared', taskId: TASK_ID }, + displaySlug: 'login-rate-limit', + task: 'Add login rate limits.', + workflowMode: 'manteam', + visibility: 'shared', + coordination: 'team', + status: 'in_progress', + currentStep: 9, + skippedSteps: [], + blockingReason: null, + outcome: null, + revision: 7, + transitionState: 'stable', + lastOperationId: null, + ownerActorId: ACTOR_ID, + ownershipEpoch: 3, + participants: [ACTOR_ID], + createdBy: { actorId: ACTOR_ID, client: 'codex', source: 'actor' }, + base: { branch: 'feature/login', head: 'abc1234', upstream: null }, + implementationScope: { + ...scope, + digest: digestCanonicalJson(scope), + }, + governance: { + requirementsStatus: 'ready', + requirementsDigest: requirements.contentDigest, + planVersion: 2, + planDecision: 'governed_execution', + policyVersions: { planning: 2, review: 2, verification: 1 }, + reviewStatus: review.status, + reviewLedgerDigest: review.contentDigest, + verificationStatus: verification.status, + verificationLedgerDigest: verification.contentDigest, + }, + soloExecution: null, + latestCheckpointRef: null, + parent: null, + successorTaskRef: null, + legacyCompatibility: null, + startedAt: '2026-07-17T09:30:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} + +function coverageId(index: number): string { + const ids = [ + '01JZ4B6W5Z0A1B2C3D4E5F6G7S', + '01JZ4B6W5Z0A1B2C3D4E5F6G7T', + '01JZ4B6W5Z0A1B2C3D4E5F6G7V', + '01JZ4B6W5Z0A1B2C3D4E5F6G7W', + '01JZ4B6W5Z0A1B2C3D4E5F6G7X', + '01JZ4B6W5Z0A1B2C3D4E5F6G7Y', + '01JZ4B6W5Z0A1B2C3D4E5F6G7Z', + ]; + const id = ids[index]; + if (id === undefined) throw new Error(`missing coverage id at ${index}`); + return id; +} diff --git a/tests/atomic-file-contracts.test.ts b/tests/atomic-file-contracts.test.ts new file mode 100644 index 0000000..beaaedc --- /dev/null +++ b/tests/atomic-file-contracts.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { replaceFileAtomically } from '../src/runtime/atomic-file.js'; + +describe('atomic file replacement', () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); + }); + + it('replaces an existing sibling only after the replacement is written', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'mancode-atomic-file-')); + roots.push(root); + const target = path.join(root, 'target.json'); + const temporary = path.join(root, '.target.json.tmp'); + await writeFile(target, '{"state":"old"}\n'); + await writeFile(temporary, '{"state":"new"}\n'); + + await replaceFileAtomically(temporary, target); + + await expect(readFile(target, 'utf8')).resolves.toBe('{"state":"new"}\n'); + await expect(readFile(temporary, 'utf8')).rejects.toThrow(); + }); + + it('rejects invalid retry settings before changing files', async () => { + await expect( + replaceFileAtomically('temporary', 'target', { maxAttempts: 0 }), + ).rejects.toThrow('MANCODE_ATOMIC_REPLACE_ATTEMPTS_INVALID'); + }); +}); diff --git a/tests/authorization-contracts.test.ts b/tests/authorization-contracts.test.ts new file mode 100644 index 0000000..e370e98 --- /dev/null +++ b/tests/authorization-contracts.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { + AUTHORIZATION_MATRIX, + type AuthorizationRequest, + assertAuthorized, + assertRepairUsesOriginalAuthorization, + createAuthorizationBasis, + evaluateAuthorization, +} from '../src/team/authorization.js'; + +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const OTHER_ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; + +describe('cooperative authorization matrix', () => { + it('freezes all documented mutating actor rules and keeps the trust boundary explicit', () => { + expect(AUTHORIZATION_MATRIX).toHaveLength(15); + const decision = evaluateAuthorization(baseRequest()); + expect(decision).toEqual({ + allowed: true, + failures: [], + trustBoundary: 'repo-collaborators', + }); + }); + + it('requires the correct task/claim/handoff actor and fresh authority facts', () => { + expect(() => + assertAuthorized({ + ...baseRequest(), + action: 'handoff_accept_reject', + handoff: { + fromActorId: OTHER_ACTOR_ID, + toActorId: OTHER_ACTOR_ID, + intent: 'accept', + }, + }), + ).toThrow('MANCODE_HANDOFF_ACTOR_MISMATCH'); + expect(() => + assertAuthorized({ + ...baseRequest(), + action: 'claim_create', + conditions: { + ...baseRequest().conditions, + implementationScopeContainsClaim: false, + }, + }), + ).toThrow('MANCODE_SCOPE_OUTSIDE_IMPLEMENTATION_SCOPE'); + }); + + it('forbids P0 waivers and binds repair to the original actor/session authorization', () => { + const waiver = { + ...baseRequest(), + action: 'review_skip_or_waiver' as const, + conditions: { + ...baseRequest().conditions, + reviewAction: 'waiver' as const, + reviewSeverity: 'p0' as const, + }, + }; + expect(() => assertAuthorized(waiver)).toThrow('MANCODE_WAIVER_FORBIDDEN'); + + const basis = createAuthorizationBasis( + baseRequest(), + new Date('2026-07-17T10:00:00.000Z'), + ); + expect(() => + assertRepairUsesOriginalAuthorization(basis, ACTOR_ID, SESSION_ID), + ).not.toThrow(); + expect(() => + assertRepairUsesOriginalAuthorization(basis, OTHER_ACTOR_ID, SESSION_ID), + ).toThrow('MANCODE_REPAIR_AUTHORIZATION_MISMATCH'); + }); +}); + +function baseRequest(): AuthorizationRequest { + return { + action: 'shared_metadata_plan_mutation', + actorId: ACTOR_ID, + session: { sessionId: SESSION_ID, actorId: ACTOR_ID, status: 'active' }, + joined: true, + sharedWriteGuard: 'advisory', + task: { + ownerActorId: ACTOR_ID, + participantActorIds: [ACTOR_ID, OTHER_ACTOR_ID], + }, + claim: { ownerActorId: ACTOR_ID, transferTargetActorId: null }, + handoff: { + fromActorId: ACTOR_ID, + toActorId: OTHER_ACTOR_ID, + intent: 'offer', + }, + evidence: { assignedToActor: true, restrictsWriteToAssignedItem: true }, + profileActorId: ACTOR_ID, + conditions: { + expectedRevisionMatches: true, + ownershipEpochFresh: true, + privacyConfirmed: true, + explicitConfirmation: true, + taskContextAvailable: true, + transportFresh: true, + gitSourceConfirmed: true, + completionGateSatisfied: true, + claimHandoffConsistent: true, + implementationScopeContainsClaim: true, + coordinationStoreFresh: true, + reason: 'The previous owner explicitly requested re-claim.', + }, + }; +} diff --git a/tests/beta-gate-contracts.test.ts b/tests/beta-gate-contracts.test.ts new file mode 100644 index 0000000..3f7acb7 --- /dev/null +++ b/tests/beta-gate-contracts.test.ts @@ -0,0 +1,103 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { contextBeta } from '../src/commands/context.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { createUlid } from '../src/context/ids.js'; +import { installV3Adapter } from '../src/installers/v3-adapter.js'; +import { writePlatformSessionSpike } from '../src/runtime/platform-spike-store.js'; +import { + SESSION_SPIKE_PLATFORMS, + createPlatformSessionSpike, +} from '../src/runtime/platform-spike.js'; + +describe('V3 Beta gate', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-beta-gate-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await initializeV3Project({ + projectRoot: root, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: new Date('2026-07-18T12:00:00.000Z'), + }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('refuses Beta while real-host spike evidence and adapters are missing', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + expect(await contextBeta(root, { json: true })).toBe(3); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + ready: false, + blockers: expect.arrayContaining([ + 'MANCODE_BETA_PLATFORM_SESSION_SPIKE_REQUIRED', + 'MANCODE_BETA_ADAPTER_SHADOW_OR_INSTALL_REQUIRED', + ]), + sessionEvidence: { + missingPlatforms: expect.arrayContaining(['codex']), + }, + }); + } finally { + logs.mockRestore(); + } + }); + + it('passes only after every V3 adapter and every platform spike is present', async () => { + for (const platform of [ + 'claude-code', + 'codex', + 'cursor', + 'copilot', + 'zcode', + ] as const) { + await installV3Adapter(root, platform); + } + await Promise.all( + SESSION_SPIKE_PLATFORMS.map((platform) => + writePlatformSessionSpike( + root, + createPlatformSessionSpike({ + platform, + observedAt: '2026-07-18T12:00:00.000Z', + hostSessionSource: 'api', + firstWindowHostSessionKey: `${platform}-window-a`, + secondWindowHostSessionKey: `${platform}-window-b`, + commandPropagation: 'proven', + subagentInheritance: 'not_applicable', + hookApproval: 'not_applicable', + }), + ), + ), + ); + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + expect(await contextBeta(root, { json: true })).toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + ready: true, + blockers: [], + sessionEvidence: { ready: true }, + runtimeBinding: 'ready', + }); + } finally { + logs.mockRestore(); + } + }); +}); + +function id(offset: number) { + return createUlid( + Date.parse('2026-07-18T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/checkpoint-contracts.test.ts b/tests/checkpoint-contracts.test.ts new file mode 100644 index 0000000..32d966f --- /dev/null +++ b/tests/checkpoint-contracts.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { type CheckpointV1, parseCheckpoint } from '../src/team/checkpoints.js'; + +const ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('checkpoint contract', () => { + it('creates immutable, typed task snapshots with all aggregate governance digests', () => { + const checkpoint = parseCheckpoint(rawCheckpoint()); + expect(checkpoint.kind).toBe('handoff_offered'); + expect(checkpoint.taskRef).toEqual({ + namespace: 'shared', + taskId: TASK_ID, + }); + }); + + it('rejects unknown kinds and incomplete governance snapshots', () => { + expect(() => + parseCheckpoint({ ...rawCheckpoint(), kind: 'custom' }), + ).toThrow(/checkpoint kind/); + expect(() => + parseCheckpoint({ + ...rawCheckpoint(), + governance: { requirementsDigest: DIGEST }, + }), + ).toThrow(/unknown field|planVersion/); + }); + + it('blocks sensitive text before it becomes a shared checkpoint', () => { + expect(() => + parseCheckpoint({ + ...rawCheckpoint(), + summary: 'Authorization: Bearer super-secret', + }), + ).toThrow(/MANCODE_PRIVACY_BLOCKED/); + }); +}); + +function rawCheckpoint(): CheckpointV1 { + return { + schemaVersion: 1, + checkpointId: ID, + operationId: ID, + taskRef: { namespace: 'shared', taskId: TASK_ID }, + taskRevision: 7, + ownershipEpochAtOffer: 3, + kind: 'handoff_offered', + git: { branch: 'feature/login', head: 'abc1234', base: 'def5678' }, + summary: 'Login path is ready for review.', + governance: { + requirementsDigest: DIGEST, + planVersion: 2, + reviewLedgerDigest: DIGEST, + verificationLedgerDigest: DIGEST, + }, + nextAction: 'Accept the handoff.', + createdBy: { actorId: ID, client: 'codex' }, + createdAt: '2026-07-17T10:00:00.000Z', + }; +} diff --git a/tests/claim-conflict-contracts.test.ts b/tests/claim-conflict-contracts.test.ts new file mode 100644 index 0000000..04bfc8c --- /dev/null +++ b/tests/claim-conflict-contracts.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import { type ClaimV1, parseClaim } from '../src/team/claims.js'; +import { + assertClaimScopeSubset, + assessClaimConflicts, + deriveClaimValidity, +} from '../src/team/conflicts.js'; + +const CLAIM_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const OTHER_CLAIM_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('claim validity and conflict contract', () => { + it('requires an explicit implementation scope proof before claim acquisition', () => { + expect(() => + assertClaimScopeSubset(claim().scope, { + source: 'explicit', + include: ['src/**'], + exclude: ['src/private/**'], + modules: ['auth-api'], + apis: [], + schemas: [], + }), + ).not.toThrow(); + expect(() => + assertClaimScopeSubset(claim().scope, { + source: 'legacy_unspecified', + include: [], + exclude: [], + modules: [], + }), + ).toThrow('MANCODE_SCOPE_CONFIRMATION_REQUIRED'); + }); + + it('derives revalidation and re-claim states instead of persisting them', () => { + const active = parseClaim(claim()); + expect( + deriveClaimValidity(active, validationContext({ taskRevision: 8 })), + ).toBe('needs_revalidation'); + expect( + deriveClaimValidity( + active, + validationContext({ + implementationScopeDigest: `sha256:${'b'.repeat(64)}`, + }), + ), + ).toBe('reclaim_required'); + }); + + it('never says a stale transport has no conflict and blocks fresh shared schema conflicts', () => { + const existing = parseClaim({ + ...claim(), + claimId: OTHER_CLAIM_ID, + scope: { ...claim().scope, schemas: ['public.User'] }, + scopeDigest: digestCanonicalJson({ + ...claim().scope, + schemas: ['public.User'], + }), + }); + const candidate = { ...claim().scope, schemas: ['public.User'] }; + expect( + assessClaimConflicts(candidate, [existing], { + transportFreshness: 'fresh', + claimAcquisition: 'enforced', + }), + ).toMatchObject({ level: 'blocker', acquisition: 'reject' }); + expect( + assessClaimConflicts(candidate, [existing], { + transportFreshness: 'stale', + claimAcquisition: 'enforced', + }), + ).toMatchObject({ level: 'unknown', acquisition: 'sync_or_confirm' }); + }); + + it('treats identical globs as an overlap rather than silently downgrading them to info', () => { + const existing = parseClaim(claim()); + expect( + assessClaimConflicts(existing.scope, [existing], { + transportFreshness: 'fresh', + claimAcquisition: 'enforced', + }), + ).toMatchObject({ level: 'warning', acquisition: 'confirm_or_narrow' }); + }); +}); + +function validationContext( + overrides: Partial<{ + taskRevision: number; + implementationScopeDigest: string; + }> = {}, +) { + return { + taskRef: { namespace: 'shared' as const, taskId: TASK_ID }, + taskRevision: overrides.taskRevision ?? 7, + implementationScopeDigest: overrides.implementationScopeDigest ?? DIGEST, + ownershipEpoch: 3, + codeRefHead: 'abc1234', + now: new Date('2026-07-17T10:00:00.000Z'), + transportFreshness: 'fresh' as const, + }; +} + +function claim(): ClaimV1 { + const scope = { + paths: ['src/auth/**'], + modules: ['auth-api'], + apis: [], + schemas: [], + }; + return { + schemaVersion: 1, + claimId: CLAIM_ID, + workspaceId: CLAIM_ID, + coordinationDomainId: `local:${CLAIM_ID}:${CLAIM_ID}`, + authority: { mode: 'local', remoteRevision: null }, + taskRef: { namespace: 'shared', taskId: TASK_ID }, + taskRevisionAtAcquire: 7, + lastValidatedTaskRevision: 7, + implementationScopeDigest: DIGEST, + ownershipEpochAtAcquire: 3, + ownerActorId: ACTOR_ID, + state: 'active', + revision: 1, + scope, + scopeDigest: digestCanonicalJson(scope), + codeRefAtAcquire: { branch: 'feature/auth', head: 'abc1234' }, + lastValidatedCodeRef: { branch: 'feature/auth', head: 'abc1234' }, + acquisitionEnforcement: 'enforced', + writeGuard: 'advisory', + expiresAt: '2026-07-18T10:00:00.000Z', + predecessorClaimId: null, + successorClaimId: null, + lastOperationId: null, + createdAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} diff --git a/tests/claim-contracts.test.ts b/tests/claim-contracts.test.ts new file mode 100644 index 0000000..84ef781 --- /dev/null +++ b/tests/claim-contracts.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import { + type ClaimV1, + assertClaimTransition, + parseClaim, +} from '../src/team/claims.js'; + +const CLAIM_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('claim contract', () => { + it('binds a shared claim to a canonical scope digest and never persists runtime stale states', () => { + const parsed = parseClaim(claim()); + expect(parsed.scope.paths).toEqual(['src/auth/**', 'tests/auth/**']); + expect(() => + parseClaim({ ...claim(), state: 'needs_revalidation' }), + ).toThrow(/claim state/); + expect(() => + parseClaim({ + ...claim(), + taskRef: { namespace: 'local', taskId: TASK_ID }, + }), + ).toThrow(/shared TaskRefs/); + expect(() => parseClaim({ ...claim(), scopeDigest: DIGEST })).toThrow( + /does not match/, + ); + }); + + it('allows only the stable pending/active/terminal claim state machine', () => { + const pending = parseClaim({ ...claim(), state: 'pending', revision: 1 }); + const active = parseClaim({ ...pending, state: 'active', revision: 2 }); + const released = parseClaim({ ...active, state: 'released', revision: 3 }); + expect(() => assertClaimTransition(pending, active)).not.toThrow(); + expect(() => assertClaimTransition(active, released)).not.toThrow(); + expect(() => + assertClaimTransition(released, { + ...released, + state: 'active', + revision: 4, + }), + ).toThrow(/invalid claim state transition/); + }); + + it('requires a new claim identity for a scope change or expired re-claim', () => { + const active = parseClaim({ ...claim(), state: 'active', revision: 1 }); + const changedScope = parseClaim({ + ...active, + revision: 2, + scope: { ...active.scope, paths: ['src/billing/**'] }, + scopeDigest: digestCanonicalJson({ + ...active.scope, + paths: ['src/billing/**'], + }), + }); + expect(() => assertClaimTransition(active, changedScope)).toThrow( + /scope are immutable/, + ); + const expired = parseClaim({ ...active, state: 'expired', revision: 2 }); + expect(() => + assertClaimTransition(expired, { + ...expired, + state: 'active', + revision: 3, + }), + ).toThrow(/invalid claim state transition/); + }); + + it('blocks sensitive values before they can enter a shared claim scope', () => { + const scope = { ...claim().scope, paths: ['/Users/alice/private/**'] }; + expect(() => + parseClaim({ + ...claim(), + scope, + scopeDigest: digestCanonicalJson(scope), + }), + ).toThrow(/MANCODE_PRIVACY_BLOCKED/); + }); + + it('accepts only repository-relative claim path globs', () => { + for (const paths of [ + ['src/../auth/**'], + ['src//auth/**'], + ['src\\auth\\**'], + ]) { + const scope = { ...claim().scope, paths }; + expect(() => + parseClaim({ + ...claim(), + scope, + scopeDigest: digestCanonicalJson(scope), + }), + ).toThrow(/safe repository-relative/); + } + }); +}); + +function claim(): ClaimV1 { + const scope = { + paths: ['tests/auth/**', 'src/auth/**'], + modules: ['auth-api'], + apis: [], + schemas: [], + }; + return { + schemaVersion: 1, + claimId: CLAIM_ID, + workspaceId: CLAIM_ID, + coordinationDomainId: `local:${CLAIM_ID}:${CLAIM_ID}`, + authority: { mode: 'local', remoteRevision: null }, + taskRef: { namespace: 'shared', taskId: TASK_ID }, + taskRevisionAtAcquire: 7, + lastValidatedTaskRevision: 7, + implementationScopeDigest: DIGEST, + ownershipEpochAtAcquire: 3, + ownerActorId: ACTOR_ID, + state: 'active', + revision: 1, + scope, + scopeDigest: digestCanonicalJson({ + paths: ['src/auth/**', 'tests/auth/**'], + modules: ['auth-api'], + apis: [], + schemas: [], + }), + codeRefAtAcquire: { branch: 'feature/login', head: 'abc1234' }, + lastValidatedCodeRef: { branch: 'feature/login', head: 'abc1234' }, + acquisitionEnforcement: 'enforced', + writeGuard: 'advisory', + expiresAt: '2026-07-18T10:00:00.000Z', + predecessorClaimId: null, + successorClaimId: null, + lastOperationId: null, + createdAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} diff --git a/tests/claim-store-contracts.test.ts b/tests/claim-store-contracts.test.ts new file mode 100644 index 0000000..ba0b60b --- /dev/null +++ b/tests/claim-store-contracts.test.ts @@ -0,0 +1,114 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import type { Ulid } from '../src/context/ids.js'; +import { + claimPath, + createClaim, + listClaims, + readClaim, + updateClaim, +} from '../src/runtime/claim-store.js'; +import type { EntityHomeStore } from '../src/runtime/entity-home-store.js'; +import type { ClaimV1 } from '../src/team/claims.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H' as Ulid; +const CLAIM_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J' as Ulid; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K' as Ulid; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M' as Ulid; + +describe('claim authority store', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-claim-store-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('creates immutable claim IDs and performs revision-CAS transitions', async () => { + const store = homeStore(root); + const active = claim(); + await expect(createClaim(store, active)).resolves.toEqual(active); + await expect(createClaim(store, active)).resolves.toEqual(active); + await expect(readClaim(store, CLAIM_ID)).resolves.toEqual(active); + await expect(listClaims(store, active.taskRef)).resolves.toEqual([active]); + + const released: ClaimV1 = { + ...active, + state: 'released', + revision: 2, + lastOperationId: CLAIM_ID, + updatedAt: '2026-07-17T10:01:00.000Z', + }; + await expect(updateClaim(store, released, 1)).resolves.toEqual(released); + await expect(updateClaim(store, released, 1)).rejects.toThrow( + 'MANCODE_EXPECTED_REVISION_CONFLICT', + ); + }); + + it('refuses to reinterpret a claim ID as a different immutable snapshot', async () => { + const store = homeStore(root); + const active = claim(); + await createClaim(store, active); + await expect( + createClaim(store, { ...active, ownerActorId: WORKSPACE_ID }), + ).rejects.toThrow('MANCODE_CLAIM_ID_CONFLICT'); + expect(claimPath(store, CLAIM_ID)).toContain(`${CLAIM_ID}.json`); + }); +}); + +function homeStore(root: string): EntityHomeStore { + return { + kind: 'non_git_shared', + storeId: `non-git:${WORKSPACE_ID}`, + root, + workspaceId: WORKSPACE_ID, + checkoutId: null, + repositoryBindingId: null, + }; +} + +function claim(): ClaimV1 { + const scope = { + paths: ['src/auth/**'], + modules: ['auth-api'], + apis: [], + schemas: [], + }; + return { + schemaVersion: 1, + claimId: CLAIM_ID, + workspaceId: WORKSPACE_ID, + coordinationDomainId: `local:${WORKSPACE_ID}:${WORKSPACE_ID}`, + authority: { mode: 'local', remoteRevision: null }, + taskRef: { namespace: 'shared', taskId: TASK_ID }, + taskRevisionAtAcquire: 1, + lastValidatedTaskRevision: 1, + implementationScopeDigest: `sha256:${'a'.repeat(64)}`, + ownershipEpochAtAcquire: 1, + ownerActorId: ACTOR_ID, + state: 'active', + revision: 1, + scope, + scopeDigest: digestCanonicalJson(scope), + codeRefAtAcquire: { branch: 'main', head: 'abc123' }, + lastValidatedCodeRef: { branch: 'main', head: 'abc123' }, + acquisitionEnforcement: 'enforced', + writeGuard: 'advisory', + expiresAt: '2026-07-18T10:00:00.000Z', + predecessorClaimId: null, + successorClaimId: null, + lastOperationId: null, + createdAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} diff --git a/tests/cli-v3-surface-contracts.test.ts b/tests/cli-v3-surface-contracts.test.ts new file mode 100644 index 0000000..e8cbb73 --- /dev/null +++ b/tests/cli-v3-surface-contracts.test.ts @@ -0,0 +1,67 @@ +import type { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; + +describe('V3 CLI command surface', () => { + it('registers coordination, migration, recovery, and explicit-sync commands', async () => { + const originalArgv = process.argv; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + process.argv = ['node', 'mancode', 'version']; + try { + const { cliProgram } = await import('../src/cli.js'); + + expect(cliProgram.commands.map((command) => command.name())).toEqual( + expect.arrayContaining([ + 'init', + 'workflow', + 'context', + 'operation', + 'team', + 'migrate', + ]), + ); + expect(commandAt(cliProgram, 'context', 'session', 'new')).toBeDefined(); + expect( + commandAt(cliProgram, 'context', 'session', 'spike'), + ).toBeDefined(); + expect(commandAt(cliProgram, 'operation', 'repair')).toBeDefined(); + expect(commandAt(cliProgram, 'operation', 'abort')).toBeDefined(); + expect(commandAt(cliProgram, 'team', 'sync', 'pull')).toBeDefined(); + expect(commandAt(cliProgram, 'team', 'sync', 'push')).toBeDefined(); + expect(commandAt(cliProgram, 'team', 'handoff', 'accept')).toBeDefined(); + expect( + commandAt(cliProgram, 'migrate', 'context', 'resolve'), + ).toBeDefined(); + + expect( + requiredOptions(commandAt(cliProgram, 'team', 'transport', 'migrate')), + ).toEqual(['--to', '--expected-config-revision']); + expect( + requiredOptions(commandAt(cliProgram, 'team', 'sync', 'push')), + ).toEqual(['--expected-task-revision']); + expect(requiredOptions(commandAt(cliProgram, 'team', 'claim'))).toEqual([ + '--expected-task-revision', + ]); + } finally { + process.argv = originalArgv; + log.mockRestore(); + } + }); +}); + +function commandAt(root: Command, ...path: string[]): Command { + let current = root; + for (const name of path) { + const next = current.commands.find((command) => command.name() === name); + if (next === undefined) + throw new Error(`missing CLI command: ${path.join(' ')}`); + current = next; + } + return current; +} + +function requiredOptions(command: Command): string[] { + return command.options + .filter((option) => option.mandatory) + .map((option) => option.long) + .filter((option): option is string => option !== undefined); +} diff --git a/tests/compatibility-contracts.test.ts b/tests/compatibility-contracts.test.ts new file mode 100644 index 0000000..acecbd6 --- /dev/null +++ b/tests/compatibility-contracts.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest'; +import { + type CompatibilityFailureCode, + type CompatibilityGateInput, + assertCompatibilityGate, + compareSemver, + evaluateCompatibilityGate, +} from '../src/context/compatibility.js'; +import type { SchemaManifestV1 } from '../src/context/manifest.js'; + +const EPOCH = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const BASELINE = { + stateDigest: `sha256:${'a'.repeat(64)}`, + workflowIndexDigest: `sha256:${'b'.repeat(64)}`, +}; + +describe('schema compatibility gate', () => { + it('allows only migration staging during dual-read', () => { + const input = baseInput({ + activationState: 'dual_read', + legacyBaseline: BASELINE, + }); + expect( + evaluateCompatibilityGate({ ...input, operation: 'migration_stage' }), + ).toMatchObject({ readAllowed: true, writeAllowed: true, failures: [] }); + expect( + evaluateCompatibilityGate({ ...input, operation: 'v3_business_write' }), + ).toMatchObject({ writeAllowed: false }); + expect(() => + assertCompatibilityGate({ ...input, operation: 'v3_business_write' }), + ).toThrow('MANCODE_V3_WRITE_REQUIRES_ACTIVATION'); + }); + + it('blocks reads and writes when the schema epoch differs', () => { + expectWriteBlocked( + { + ...activeInput(), + expectedSchemaEpoch: '01JZ4B6W5Z0A1B2C3D4E5F6G7J', + operation: 'v3_business_write', + }, + ['MANCODE_SCHEMA_EPOCH_MISMATCH'], + false, + ); + }); + + it('blocks reads and writes when minReaderVersion exceeds the reader', () => { + expectWriteBlocked( + { + ...activeInput({ minReaderVersion: '0.4.1' }), + operation: 'v3_business_write', + }, + ['MANCODE_READER_VERSION_TOO_OLD'], + false, + ); + }); + + it('allows reads but blocks writes when minWriterVersion exceeds the writer', () => { + expectWriteBlocked( + { + ...activeInput({ minWriterVersion: '0.4.1' }), + operation: 'v3_business_write', + }, + ['MANCODE_WRITER_VERSION_TOO_OLD'], + true, + ); + }); + + it('blocks writes for both missing and mismatched managed adapters', () => { + const active = activeInput(); + + expectWriteBlocked( + { + ...active, + adapterVersions: withoutCodex(active.adapterVersions), + operation: 'v3_business_write', + }, + ['MANCODE_ADAPTER_VERSION_MISMATCH'], + true, + ); + expectWriteBlocked( + { + ...active, + adapterVersions: { ...active.adapterVersions, codex: '2' }, + operation: 'v3_business_write', + }, + ['MANCODE_ADAPTER_VERSION_MISMATCH'], + true, + ); + }); + + it('blocks writes when the legacy authority drifts from its baseline', () => { + expectWriteBlocked( + { + ...activeInput(), + currentLegacyBaseline: { + ...BASELINE, + stateDigest: `sha256:${'c'.repeat(64)}`, + }, + operation: 'v3_business_write', + }, + ['MANCODE_LEGACY_BASELINE_CHANGED'], + true, + ); + }); + + it('reports the complete deterministic failure set for a mixed-version writer', () => { + const active = activeInput({ + minReaderVersion: '0.4.1', + minWriterVersion: '0.4.1', + }); + + expectWriteBlocked( + { + ...active, + expectedSchemaEpoch: '01JZ4B6W5Z0A1B2C3D4E5F6G7J', + adapterVersions: withoutCodex(active.adapterVersions), + currentLegacyBaseline: { + ...BASELINE, + workflowIndexDigest: `sha256:${'c'.repeat(64)}`, + }, + operation: 'v3_business_write', + }, + [ + 'MANCODE_SCHEMA_EPOCH_MISMATCH', + 'MANCODE_READER_VERSION_TOO_OLD', + 'MANCODE_WRITER_VERSION_TOO_OLD', + 'MANCODE_ADAPTER_VERSION_MISMATCH', + 'MANCODE_LEGACY_BASELINE_CHANGED', + ], + false, + ); + }); + + it('keeps greenfield initialization mutually exclusive with legacy authority', () => { + const result = evaluateCompatibilityGate({ + ...baseInput({ activationState: 'initializing', legacyBaseline: null }), + legacyAuthorityPresent: true, + currentLegacyBaseline: null, + operation: 'greenfield_initialize', + }); + expect(result.failures).toContain('MANCODE_LEGACY_AUTHORITY_PRESENT'); + }); + + it('orders semantic versions including prereleases', () => { + expect(compareSemver('0.4.0', '0.4.0-beta.1')).toBeGreaterThan(0); + expect(compareSemver('0.4.0-beta.2', '0.4.0-beta.10')).toBeLessThan(0); + expect(compareSemver('1.0.0', '1.0.0')).toBe(0); + }); +}); + +function activeInput(overrides: Partial = {}) { + return baseInput({ + activationState: 'v3_active', + legacyBaseline: BASELINE, + activatedAt: '2026-07-17T12:00:00.000Z', + ...overrides, + }); +} + +function expectWriteBlocked( + input: CompatibilityGateInput, + failures: CompatibilityFailureCode[], + readAllowed: boolean, +): void { + expect(evaluateCompatibilityGate(input)).toEqual({ + readAllowed, + writeAllowed: false, + failures, + }); + expect(() => assertCompatibilityGate(input)).toThrowError( + new RegExp(`^${failures[0]}$`), + ); +} + +function withoutCodex( + adapters: SchemaManifestV1['managedAdapters'], +): CompatibilityGateInput['adapterVersions'] { + return { + 'claude-code': adapters['claude-code'], + cursor: adapters.cursor, + copilot: adapters.copilot, + zcode: adapters.zcode, + }; +} + +function baseInput(overrides: Partial) { + const manifest: SchemaManifestV1 = { + manifestVersion: 1, + layoutVersion: 3, + epoch: EPOCH, + activationState: 'initializing', + minReaderVersion: '0.4.0', + minWriterVersion: '0.4.0', + activatedAt: null, + legacyBaseline: null, + managedAdapters: { + 'claude-code': '3', + codex: '3', + cursor: '3', + copilot: '3', + zcode: '3', + }, + lastOperationId: null, + ...overrides, + }; + return { + manifest, + expectedSchemaEpoch: EPOCH, + readerVersion: '0.4.0', + writerVersion: '0.4.0', + adapterVersions: { ...manifest.managedAdapters }, + currentLegacyBaseline: manifest.legacyBaseline, + legacyAuthorityPresent: manifest.legacyBaseline !== null, + }; +} diff --git a/tests/confirmed-decision-contracts.test.ts b/tests/confirmed-decision-contracts.test.ts new file mode 100644 index 0000000..3ddb0ad --- /dev/null +++ b/tests/confirmed-decision-contracts.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + confirmedDecisionDigest, + createConfirmedDecision, + listConfirmedDecisions, + publishConfirmedDecision, +} from '../src/context/confirmed-decision.js'; +import { createAuthorizationBasis } from '../src/team/authorization.js'; + +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const DECISION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('confirmed shared decision contract', () => { + it('publishes one privacy-safe immutable entity and is idempotent by digest', async () => { + const root = await temporaryRoot(); + const decision = fixture(); + const first = await publishConfirmedDecision(root, decision); + const retried = await publishConfirmedDecision(root, decision); + + expect(retried).toEqual(first); + expect(confirmedDecisionDigest(first)).toMatch(/^sha256:/); + await expect(listConfirmedDecisions(root)).resolves.toEqual([first]); + }); + + it('refuses privacy-unsafe text, local task references, and conflicting IDs', async () => { + expect(() => + createConfirmedDecision({ ...fixtureInput(), statement: 'token=secret' }), + ).toThrow('MANCODE_PRIVACY_BLOCKED'); + expect(() => + createConfirmedDecision({ + ...fixtureInput(), + taskRef: { namespace: 'local', taskId: DECISION_ID }, + }), + ).toThrow('MANCODE_CONFIRMED_DECISION_LOCAL_TASK_FORBIDDEN'); + + const root = await temporaryRoot(); + await publishConfirmedDecision(root, fixture()); + await expect( + publishConfirmedDecision( + root, + createConfirmedDecision({ + ...fixtureInput(), + statement: 'Choose a different shared policy.', + }), + ), + ).rejects.toThrow('MANCODE_CONFIRMED_DECISION_ID_CONFLICT'); + }); +}); + +function fixture() { + return createConfirmedDecision(fixtureInput()); +} + +function fixtureInput() { + const authorization = createAuthorizationBasis( + { + action: 'confirmed_decision_publish', + actorId: ACTOR_ID, + session: { + sessionId: SESSION_ID, + actorId: ACTOR_ID, + status: 'active', + }, + joined: true, + sharedWriteGuard: 'advisory', + task: null, + claim: null, + handoff: null, + evidence: null, + profileActorId: null, + conditions: { + confirmedDecisionSharingEnabled: true, + privacyConfirmed: true, + explicitConfirmation: true, + }, + }, + new Date('2026-07-17T10:00:00.000Z'), + ); + return { + decisionId: DECISION_ID, + title: 'Keep the V3 context resolver authoritative', + statement: 'Shared planning reads one stable Context Pack.', + actorId: ACTOR_ID, + operationId: OPERATION_ID, + authorization, + now: new Date('2026-07-17T10:00:00.000Z'), + }; +} + +async function temporaryRoot(): Promise { + const root = await mkdtemp( + path.join(os.tmpdir(), 'mancode-decision-contract-'), + ); + roots.push(root); + return root; +} diff --git a/tests/context-contracts.test.ts b/tests/context-contracts.test.ts new file mode 100644 index 0000000..9a80b3d --- /dev/null +++ b/tests/context-contracts.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import { + assertReferenceNamespace, + parseArtifactRef, +} from '../src/context/artifact-ref.js'; +import { + canonicalizeJson, + digestCanonicalJson, + sortUtf8StringSet, +} from '../src/context/canonical.js'; +import { createUlid, isUlid } from '../src/context/ids.js'; +import { + parentSnapshotStaleReasons, + parseParentSnapshot, +} from '../src/context/parent-snapshot.js'; +import { + assertWorkflowStatusTransition, + normalizeLegacyWorkflowMode, + parseWorkflowDescriptor, +} from '../src/context/schema.js'; +import { + formatTaskRef, + parseTaskRef, + parseTaskRefValue, +} from '../src/context/task-ref.js'; + +const LOCAL_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const SHARED_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const OTHER_SHARED_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('V3 context contract spike', () => { + it('creates canonical ULIDs and rejects non-canonical identifiers', () => { + const generated = createUlid(1_720_000_000_000, new Uint8Array(10)); + expect(generated).toBe('01J1VZTC000000000000000000'); + expect(isUlid(generated)).toBe(true); + expect(isUlid(generated.toLowerCase())).toBe(false); + expect(() => createUlid(-1)).toThrow(/48 bits/); + }); + + it('requires namespace-qualified TaskRefs', () => { + const taskRef = parseTaskRef(`shared:${SHARED_ID}`); + expect(formatTaskRef(taskRef)).toBe(`shared:${SHARED_ID}`); + expect(() => parseTaskRef(SHARED_ID)).toThrow(/local:/); + expect(() => parseTaskRef(`shared:${SHARED_ID.toLowerCase()}`)).toThrow( + /local:/, + ); + expect(() => + parseTaskRefValue({ + namespace: 'shared', + taskId: SHARED_ID, + extra: true, + }), + ).toThrow(/unknown field/); + }); + + it('keeps ArtifactRefs typed and prevents shared-to-local references', () => { + const localArtifact = parseArtifactRef({ + taskRef: { namespace: 'local', taskId: LOCAL_ID }, + kind: 'review_report', + }); + expect(() => assertReferenceNamespace('shared', localArtifact)).toThrow( + /cannot reference local/, + ); + expect(() => + parseArtifactRef({ + taskRef: { namespace: 'shared', taskId: SHARED_ID }, + kind: 'review_report', + path: '../../secret', + }), + ).toThrow(/unknown field/); + }); + + it('enforces the workflow mode, visibility, coordination, and parent matrix', () => { + expect( + parseWorkflowDescriptor({ + workflowMode: 'man', + visibility: 'local', + coordination: 'single', + parent: null, + }), + ).toMatchObject({ workflowMode: 'man' }); + expect(() => + parseWorkflowDescriptor({ + workflowMode: 'manteam', + visibility: 'shared', + coordination: 'single', + parent: null, + }), + ).toThrow(/manteam requires/); + expect(() => + parseWorkflowDescriptor({ + workflowMode: 'manba', + visibility: 'shared', + coordination: 'single', + parent: null, + }), + ).toThrow(/requires a shared man parent/); + expect( + parseWorkflowDescriptor({ + workflowMode: 'manba', + visibility: 'shared', + coordination: 'team', + parent: { + taskRef: { namespace: 'shared', taskId: SHARED_ID }, + workflowMode: 'manteam', + visibility: 'shared', + coordination: 'team', + }, + }), + ).toMatchObject({ workflowMode: 'manba', coordination: 'team' }); + expect(() => + parseWorkflowDescriptor({ + workflowMode: 'solo', + visibility: 'local', + coordination: 'single', + parent: null, + }), + ).toThrow(/workflowMode/); + expect(normalizeLegacyWorkflowMode('mamba')).toBe('manba'); + }); + + it('only allows publish/promote to supersede a local task with a new shared TaskRef', () => { + const localTaskRef = { namespace: 'local' as const, taskId: LOCAL_ID }; + const sharedTaskRef = { namespace: 'shared' as const, taskId: SHARED_ID }; + expect(() => + assertWorkflowStatusTransition({ + sourceTaskRef: localTaskRef, + from: 'in_progress', + to: 'superseded', + operation: 'ordinary', + successorTaskRef: sharedTaskRef, + }), + ).toThrow(/publish or promote/); + expect(() => + assertWorkflowStatusTransition({ + sourceTaskRef: localTaskRef, + from: 'in_progress', + to: 'superseded', + operation: 'publish', + successorTaskRef: localTaskRef, + }), + ).toThrow(/new shared TaskRef/); + expect(() => + assertWorkflowStatusTransition({ + sourceTaskRef: localTaskRef, + from: 'in_progress', + to: 'superseded', + operation: 'promote', + successorTaskRef: sharedTaskRef, + }), + ).not.toThrow(); + }); + + it('uses deterministic canonical JSON and rejects unsafe input', () => { + expect(canonicalizeJson({ z: [true, null], a: 1 })).toBe( + '{"a":1,"z":[true,null]}', + ); + expect(sortUtf8StringSet(['z', 'é', 'a', 'é'])).toEqual(['a', 'z', 'é']); + expect(digestCanonicalJson({ a: 1, z: [true, null] })).toBe( + 'sha256:d79ef1418118f481e327f7703cda523ad18181b74f2779b866bc9d0cb41d11dc', + ); + expect(() => canonicalizeJson({ value: -0 })).toThrow(/negative zero/); + expect(() => canonicalizeJson({ value: 1.5 })).toThrow(/safe integers/); + expect(() => canonicalizeJson({ value: '\uD800' })).toThrow( + /lone surrogate/, + ); + const sparse = new Array(2); + expect(() => canonicalizeJson(sparse)).toThrow(/sparse/); + }); + + it('matches RFC 8785 number and object-key ordering vectors', () => { + const numberVector = JSON.parse( + '{"numbers":[333333333.33333329,1e30,4.5,2e-3,0.000000000000000000000000001]}', + ); + expect(canonicalizeJson(numberVector, { numberPolicy: 'finite' })).toBe( + '{"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27]}', + ); + expect(digestCanonicalJson(numberVector, { numberPolicy: 'finite' })).toBe( + 'sha256:7c892d3452ad85ad65857a43e8dcac93b79475d2334fc3e85bac5c599142c158', + ); + expect( + canonicalizeJson({ + '\u20ac': 'Euro Sign', + '\r': 'Carriage Return', + '\ufb33': 'Hebrew Letter Dalet With Dagesh', + '\u0001': 'Start of Heading', + '😀': 'Emoji: Grinning Face', + '\u0080': 'Control', + ö: 'Latin Small Letter O With Diaeresis', + }), + ).toBe( + '{"\\u0001":"Start of Heading","\\r":"Carriage Return","\u0080":"Control","ö":"Latin Small Letter O With Diaeresis","€":"Euro Sign","😀":"Emoji: Grinning Face","דּ":"Hebrew Letter Dalet With Dagesh"}', + ); + }); + + it('marks a child parent snapshot stale after a parent contract change', () => { + const snapshot = parseParentSnapshot({ + parent: { + taskRef: { namespace: 'shared', taskId: SHARED_ID }, + revisionAtCreate: 7, + planVersionAtCreate: 2, + requirementsDigestAtCreate: DIGEST, + implementationScopeDigestAtCreate: DIGEST, + visibility: 'shared', + coordination: 'team', + participants: [OTHER_SHARED_ID, SHARED_ID], + }, + }); + expect(snapshot.participants).toEqual([SHARED_ID, OTHER_SHARED_ID]); + expect( + parentSnapshotStaleReasons(snapshot, { + taskRef: { namespace: 'shared', taskId: SHARED_ID }, + revision: 8, + planVersion: 2, + requirementsDigest: DIGEST, + implementationScopeDigest: DIGEST, + visibility: 'shared', + coordination: 'team', + }), + ).toEqual(['revision']); + }); +}); diff --git a/tests/context-pack-contracts.test.ts b/tests/context-pack-contracts.test.ts new file mode 100644 index 0000000..7af0d66 --- /dev/null +++ b/tests/context-pack-contracts.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import { + CONTEXT_PACK_BUDGET_ALGORITHM_VERSION, + CONTEXT_PACK_TOKENIZER_ID, + type ContextPackBuildInput, + type ContextPackSectionInput, + buildContextPack, + contextPackDigest, + contextPackTokenCounter, + parseContextPack, +} from '../src/context/context-pack.js'; + +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; + +describe('Context Pack V2 contract', () => { + it('keeps the required envelope, trims only complete optional sections, and has a stable digest', () => { + const full = buildContextPack(buildInput(10_000)); + const limited = buildContextPack( + buildInput(Math.max(0, full.budget.estimated - 20)), + ); + const regenerated = buildContextPack({ + ...buildInput(Math.max(0, full.budget.estimated - 20)), + generatedAt: '2026-07-17T12:01:00.000Z', + }); + + expect(limited.session).toEqual({ sessionId: 'session-1' }); + expect(limited.activeTask).toMatchObject({ taskRef: `local:${TASK_ID}` }); + expect(limited.omissions.some((item) => item.reason === 'budget')).toBe( + true, + ); + expect(limited.packDigest).toBe(regenerated.packDigest); + expect(limited.budget).toMatchObject({ + tokenizerId: CONTEXT_PACK_TOKENIZER_ID, + algorithmVersion: CONTEXT_PACK_BUDGET_ALGORITHM_VERSION, + }); + expect(parseContextPack(limited)).toEqual(limited); + }); + + it('records purpose and privacy omissions without leaking raw content', () => { + const orient = buildContextPack({ + ...buildInput(10_000), + purpose: 'orient', + sections: [ + ...requiredSections(), + section('/project', { privatePlan: 'not shown for orient' }), + ], + }); + expect(orient.project).toBeNull(); + expect(orient.omissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + targetJsonPointer: '/project', + reason: 'purpose_excluded', + }), + ]), + ); + + const handoff = buildContextPack({ + ...buildInput(10_000), + purpose: 'handoff', + sections: [ + ...requiredSections(), + section('/latestHandoff', { + summary: 'See /Users/alice/private-log.txt before continuing.', + }), + ], + }); + expect(handoff.latestHandoff).toBeNull(); + expect(JSON.stringify(handoff)).not.toContain('/Users/alice'); + expect(handoff.omissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + targetJsonPointer: '/latestHandoff', + reason: 'privacy', + }), + ]), + ); + }); + + it('marks an oversized required envelope instead of removing it', () => { + const pack = buildContextPack(buildInput(1)); + expect(pack.budget.exceededByRequiredEnvelope).toBe(true); + expect(pack.session).toEqual({ sessionId: 'session-1' }); + expect(pack.activeTask).not.toBeNull(); + }); + + it('uses the fixed cl100k tokenizer and does not let callers bypass level or estimates', () => { + expect(contextPackTokenCounter().count('hello world')).toBe(2); + expect(() => + buildContextPack({ + ...buildInput(400), + level: 'bootstrap', + purpose: 'orient', + sections: [...requiredSections(), section('/actor', { id: 'actor-1' })], + }), + ).toThrow('MANCODE_CONTEXT_LEVEL_EXCLUDED'); + + const pack = buildContextPack(buildInput(10_000)); + const tampered = { + ...pack, + budget: { ...pack.budget, estimated: pack.budget.estimated + 1 }, + packDigest: '', + }; + tampered.packDigest = contextPackDigest(tampered); + expect(() => parseContextPack(tampered)).toThrow(/fixed tokenizer/); + }); + + it('keeps full context artifact-on-demand and records provenance for the snapshot itself', () => { + expect(() => + buildContextPack({ + ...buildInput(10_000), + level: 'full', + sections: [ + ...requiredSections(), + section('/project', { id: 'project' }), + ], + }), + ).toThrow('MANCODE_CONTEXT_FULL_ARTIFACTS_ON_DEMAND'); + + const full = buildContextPack({ + ...buildInput(10_000), + level: 'full', + sections: requiredSections(), + }); + expect(full.provenance).toEqual( + expect.arrayContaining([ + expect.objectContaining({ targetJsonPointer: '/snapshot' }), + ]), + ); + expect(parseContextPack(full)).toEqual(full); + }); +}); + +function buildInput(budgetLimit: number): ContextPackBuildInput { + return { + generatedAt: '2026-07-17T12:00:00.000Z', + level: 'task', + purpose: 'plan', + snapshot: { + schemaEpoch: TASK_ID, + taskRevision: 3, + requirementsDigest: `sha256:${'a'.repeat(64)}`, + reviewDigest: `sha256:${'b'.repeat(64)}`, + verificationDigest: `sha256:${'c'.repeat(64)}`, + ownershipEpoch: 1, + coordinationRevision: 2, + }, + budgetLimit, + sections: [ + ...requiredSections(), + section('/project', { facts: 'x'.repeat(400) }), + section('/parentFreshness', { + status: 'fresh', + details: 'y'.repeat(300), + }), + section('/governance/requirements', { + goal: 'Rate-limit login failures.', + }), + ], + }; +} + +function requiredSections(): ContextPackSectionInput[] { + return [ + section('/session', { sessionId: 'session-1' }, true), + section('/activeTask', { taskRef: `local:${TASK_ID}`, revision: 3 }, true), + section('/conflicts', [], true), + section('/capabilities', { claimAcquisition: 'enforced' }, true), + section('/transportFreshness', { state: 'unavailable' }, true), + ]; +} + +function section( + targetJsonPointer: ContextPackSectionInput['targetJsonPointer'], + value: unknown, + required = false, +): ContextPackSectionInput { + return { + targetJsonPointer, + value, + required, + provenance: [ + { + targetJsonPointer, + sourceKind: 'entity', + taskRef: { namespace: 'local', taskId: TASK_ID }, + artifactRef: null, + entityKey: 'fixture', + sourceRevision: 1, + sourceDigest: `sha256:${'d'.repeat(64)}`, + selectedJsonPointers: [''], + redactions: [], + }, + ], + }; +} diff --git a/tests/context-resolver-contracts.test.ts b/tests/context-resolver-contracts.test.ts new file mode 100644 index 0000000..d2f3b59 --- /dev/null +++ b/tests/context-resolver-contracts.test.ts @@ -0,0 +1,473 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildTaskAggregateManifest, + taskAggregateDigest, +} from '../src/context/aggregate.js'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import { + type RequirementsLedgerV1, + requirementsLedgerDigest, +} from '../src/context/requirements-ledger.js'; +import { ContextResolver } from '../src/context/resolver.js'; +import { + type ReviewLedgerV1, + reviewLedgerDigest, +} from '../src/context/review-ledger.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { + type VerificationLedgerV1, + verificationLedgerDigest, +} from '../src/context/verification-ledger.js'; +import type { WorkflowMetadataV3 } from '../src/context/workflow-metadata.js'; +import { + resolveTaskEntityHomeStore, + taskHeadDirectory, +} from '../src/runtime/entity-home-store.js'; +import type { OperationJournalV1 } from '../src/runtime/operation-journal.js'; + +const EPOCH = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const CHECKOUT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; +const SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7P'; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7Q'; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); +}); + +describe('V3 Context Resolver', () => { + it('reads one stable shared tuple, checks its task head fence, and creates a purpose-filtered pack', async () => { + const fixture = await createFixture(); + const result = await fixture.resolver.resolve({ + session: fixture.session, + taskRef: TASK_ID, + level: 'task', + purpose: 'plan', + compatibility: fixture.compatibility, + codeHead: 'abc1234', + generatedAt: new Date('2026-07-17T12:00:00.000Z'), + }); + + expect(result.repair).toBeNull(); + expect(result.mutatingAllowed).toBe(true); + expect(result.aggregate).not.toBeNull(); + expect(result.pack.activeTask).toMatchObject({ + taskRef: { namespace: 'shared', taskId: TASK_ID }, + revision: 1, + }); + expect(result.pack.governance.requirements).toMatchObject({ + taskRef: { namespace: 'shared', taskId: TASK_ID }, + status: 'draft', + }); + expect(result.pack.governance.review).toBeNull(); + expect(result.pack.snapshot).toMatchObject({ + schemaEpoch: EPOCH, + taskRevision: 1, + }); + expect(result.pack.project).toMatchObject({ + facts: { + trust: 'detected', + profile: { projectKind: 'web', frameworks: ['React'] }, + }, + }); + expect(result.pack.provenance).toEqual( + expect.arrayContaining([ + expect.objectContaining({ entityKey: 'project-facts' }), + ]), + ); + + await expect( + fixture.resolver.resolve({ + session: fixture.session, + taskRef: `shared:${TASK_ID}`, + level: 'task', + purpose: 'implement', + compatibility: fixture.compatibility, + codeHead: 'abc1234', + intent: 'mutate', + }), + ).resolves.toMatchObject({ mutatingAllowed: true }); + }); + + it('returns a minimal repair envelope for a durable unfinished operation and refuses mutation', async () => { + const fixture = await createFixture(); + await writeJson( + path.join(fixture.homeStore.root, 'operations', `${OPERATION_ID}.json`), + pendingJournal(fixture.homeStore.storeId), + ); + + const readResult = await fixture.resolver.resolve({ + session: fixture.session, + level: 'task', + purpose: 'implement', + compatibility: fixture.compatibility, + codeHead: 'abc1234', + }); + expect(readResult.repair).toMatchObject({ + state: 'repair_required', + issues: [ + expect.objectContaining({ + code: 'MANCODE_OPERATION_REPAIR_REQUIRED', + operationIds: [OPERATION_ID], + }), + ], + }); + expect(readResult.metadata).toBeNull(); + expect(readResult.aggregate).toBeNull(); + expect(readResult.pack.activeTask).toEqual({ + taskRef: { namespace: 'shared', taskId: TASK_ID }, + state: 'repair_required', + }); + expect(readResult.pack.governance).toEqual({ + requirements: null, + review: null, + verification: null, + }); + + await expect( + fixture.resolver.resolve({ + session: fixture.session, + level: 'task', + purpose: 'implement', + compatibility: fixture.compatibility, + codeHead: 'abc1234', + intent: 'mutate', + }), + ).rejects.toThrow('MANCODE_OPERATION_REPAIR_REQUIRED'); + }); + + it('does not follow a symlink that replaces a task authority file', async () => { + const fixture = await createFixture(); + const taskRoot = path.join( + fixture.root, + '.mancode', + 'shared', + 'workflows', + TASK_ID, + ); + const outside = path.join(fixture.root, 'outside.json'); + await writeFile(outside, '{}\n', 'utf8'); + await rm(path.join(taskRoot, 'metadata.json')); + await symlink(outside, path.join(taskRoot, 'metadata.json')); + + await expect( + new V3ContextStore(fixture.root).readTaskSnapshot({ + namespace: 'shared', + taskId: TASK_ID, + }), + ).rejects.toThrow('MANCODE_CONTEXT_PATH_UNSAFE'); + }); +}); + +async function createFixture() { + const root = await mkdtemp( + path.join(os.tmpdir(), 'mancode-context-resolver-'), + ); + roots.push(root); + const taskRef = { namespace: 'shared' as const, taskId: TASK_ID }; + const requirements = requirementsLedger(); + const review = reviewLedger(requirements.contentDigest); + const verification = verificationLedger(requirements.contentDigest); + const metadata = workflowMetadata(requirements, review, verification); + const aggregate = buildTaskAggregateManifest({ + metadata, + requirements, + review, + verification, + planDigest: null, + latestCheckpoint: null, + }); + const context = { + projectRoot: root, + workspaceId: WORKSPACE_ID, + checkoutId: CHECKOUT_ID, + gitCommonDir: null, + repositoryBindingId: null, + }; + const homeStore = resolveTaskEntityHomeStore(context, taskRef); + const taskRoot = path.join(root, '.mancode', 'shared', 'workflows', TASK_ID); + await Promise.all([ + writeJson(path.join(root, '.mancode', 'schema.json'), { + manifestVersion: 1, + layoutVersion: 3, + epoch: EPOCH, + activationState: 'v3_active', + minReaderVersion: '1.0.0', + minWriterVersion: '1.0.0', + activatedAt: '2026-07-17T10:00:00.000Z', + legacyBaseline: null, + managedAdapters: adapters(), + lastOperationId: null, + }), + writeJson(path.join(root, '.mancode', 'shared', 'config.json'), { + schemaVersion: 1, + revision: 1, + workspaceId: WORKSPACE_ID, + transport: { mode: 'local', remote: null }, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }), + writeJson( + path.join(root, '.mancode', 'shared', 'context', 'project.json'), + { + schemaVersion: 1, + revision: 1, + trust: 'detected', + profile: { + version: '1.0', + projectKind: 'web', + languages: ['JavaScript/TypeScript'], + frameworks: ['React'], + sourceRoots: ['src'], + manifests: ['package.json'], + availableValidation: ['npm test'], + uiAssets: 'detected', + browserAutomation: 'available', + confidence: 'high', + }, + uiLibrary: null, + detectedAt: '2026-07-17T10:00:00.000Z', + lastOperationId: null, + }, + ), + writeJson(path.join(root, '.mancode', 'shared', 'team', 'policy.json'), { + schemaVersion: 1, + revision: 1, + workspaceId: WORKSPACE_ID, + policy: 'auto', + recentDays: 30, + defaultVisibility: 'shared', + shareConfirmedDecisions: true, + retention: { + localRawArtifactDays: 30, + localCacheDays: 7, + completedSessionDays: 30, + }, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }), + writeJson(path.join(taskRoot, 'metadata.json'), metadata), + writeJson(path.join(taskRoot, 'requirements.json'), requirements), + writeJson(path.join(taskRoot, 'review-ledger.json'), review), + writeJson(path.join(taskRoot, 'verification-ledger.json'), verification), + writeJson(path.join(taskHeadDirectory(homeStore), `${TASK_ID}.json`), { + schemaVersion: 1, + workspaceId: WORKSPACE_ID, + taskRef, + fenceRevision: 1, + taskRevision: metadata.revision, + aggregateDigest: taskAggregateDigest(aggregate), + ownershipEpoch: metadata.ownershipEpoch, + codeRef: { head: 'abc1234' }, + checkoutId: CHECKOUT_ID, + remoteRevision: null, + lastOperationId: OPERATION_ID, + updatedAt: '2026-07-17T10:00:00.000Z', + }), + ]); + const session = { + schemaVersion: 1 as const, + sessionId: SESSION_ID, + identitySource: 'explicit' as const, + identityLookupKeyHash: null, + actorId: ACTOR_ID, + client: 'codex', + status: 'active' as const, + activeTaskRef: taskRef, + activeMode: 'manteam' as const, + lastSeenRevision: 1, + executionIds: [], + startedAt: '2026-07-17T10:00:00.000Z', + closedAt: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; + return { + root, + homeStore, + session, + compatibility: { + expectedSchemaEpoch: EPOCH, + readerVersion: '1.0.0', + writerVersion: '1.0.0', + adapterVersions: adapters(), + }, + resolver: new ContextResolver({ + projectRoot: root, + entityHomeStoreContext: context, + now: () => new Date('2026-07-17T12:00:00.000Z'), + }), + }; +} + +function requirementsLedger(): RequirementsLedgerV1 { + const draft: RequirementsLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: { namespace: 'shared', taskId: TASK_ID }, + revision: 1, + status: 'draft', + goal: 'Plan a safe rate limit.', + functionalScope: { inScope: [], outOfScope: [] }, + technicalDecisions: [], + defaults: [], + coverage: [], + requirements: [], + acceptanceCriteria: [], + blockingUnknowns: [], + legacySource: null, + contentDigest: '', + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; + return { ...draft, contentDigest: requirementsLedgerDigest(draft) }; +} + +function reviewLedger(requirementsDigest: string): ReviewLedgerV1 { + const draft: ReviewLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: { namespace: 'shared', taskId: TASK_ID }, + revision: 1, + status: 'pending', + depth: 'targeted', + requirementsDigest, + planVersion: 1, + requiredDomains: ['quality'], + domains: [{ domain: 'quality', status: 'pending', reportRef: null }], + blockers: [], + remediationRound: 0, + skip: null, + legacySource: null, + contentDigest: '', + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; + return { ...draft, contentDigest: reviewLedgerDigest(draft) }; +} + +function verificationLedger(requirementsDigest: string): VerificationLedgerV1 { + const draft: VerificationLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: { namespace: 'shared', taskId: TASK_ID }, + revision: 1, + status: 'pending', + requirementsDigest, + planVersion: 1, + remediationRound: 0, + checks: [], + legacySource: null, + contentDigest: '', + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; + return { ...draft, contentDigest: verificationLedgerDigest(draft) }; +} + +function workflowMetadata( + requirements: RequirementsLedgerV1, + review: ReviewLedgerV1, + verification: VerificationLedgerV1, +): WorkflowMetadataV3 { + const scope = { + source: 'explicit' as const, + include: ['src/**'], + exclude: [], + modules: [], + }; + return { + schemaVersion: 3, + taskRef: { namespace: 'shared', taskId: TASK_ID }, + displaySlug: 'rate-limit', + task: 'Plan a safe rate limit.', + workflowMode: 'manteam', + visibility: 'shared', + coordination: 'team', + status: 'planned', + currentStep: 1, + skippedSteps: [], + blockingReason: null, + outcome: null, + revision: 1, + transitionState: 'stable', + lastOperationId: null, + ownerActorId: ACTOR_ID, + ownershipEpoch: 1, + participants: [ACTOR_ID], + createdBy: { actorId: ACTOR_ID, client: 'codex', source: 'actor' }, + base: { branch: 'main', head: 'abc1234', upstream: null }, + implementationScope: { + ...scope, + digest: digestCanonicalJson(scope), + }, + governance: { + requirementsStatus: 'needs_clarification', + requirementsDigest: requirements.contentDigest, + planVersion: 1, + planDecision: null, + policyVersions: { planning: null, review: null, verification: null }, + reviewStatus: review.status, + reviewLedgerDigest: review.contentDigest, + verificationStatus: verification.status, + verificationLedgerDigest: verification.contentDigest, + }, + soloExecution: null, + latestCheckpointRef: null, + parent: null, + successorTaskRef: null, + legacyCompatibility: null, + startedAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} + +function pendingJournal(primaryStoreId: string): OperationJournalV1 { + return { + schemaVersion: 1, + operationId: OPERATION_ID, + type: 'verification_record', + state: 'prepared', + primaryStoreId, + checkoutId: CHECKOUT_ID, + secondaryReservations: [], + actorId: ACTOR_ID, + sessionId: SESSION_ID, + authorizationBasis: { + schemaVersion: 1, + action: 'shared_ledger_evidence', + actorId: ACTOR_ID, + sessionId: SESSION_ID, + trustBoundary: 'repo-collaborators', + decisionDigest: `sha256:${'a'.repeat(64)}`, + authorizedAt: '2026-07-17T10:00:00.000Z', + }, + entityLocks: [`task:shared:${TASK_ID}`], + expectedRevisions: { [`task:shared:${TASK_ID}`]: 1 }, + steps: [{ id: 'validate', state: 'pending' }], + startedAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} + +function adapters() { + return { + 'claude-code': '1.0.0', + codex: '1.0.0', + cursor: '1.0.0', + copilot: '1.0.0', + zcode: '1.0.0', + }; +} + +async function writeJson(target: string, value: unknown): Promise { + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} diff --git a/tests/diagnostics-contracts.test.ts b/tests/diagnostics-contracts.test.ts new file mode 100644 index 0000000..73633f9 --- /dev/null +++ b/tests/diagnostics-contracts.test.ts @@ -0,0 +1,112 @@ +import { mkdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { contextDiagnostics } from '../src/commands/context.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { createUlid } from '../src/context/ids.js'; +import { + localDiagnosticsPath, + parseLocalDiagnostics, + readLocalDiagnostics, + recordLocalDiagnostic, + setLocalDiagnosticsEnabled, +} from '../src/runtime/diagnostics.js'; + +const NOW = new Date('2026-07-18T12:00:00.000Z'); + +describe('local diagnostics contract', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-diagnostics-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await initializeV3Project({ + projectRoot: root, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('stores only fixed aggregate counts and drops them immediately when disabled', async () => { + await recordLocalDiagnostic(root, { kind: 'context_stale' }, NOW); + await recordLocalDiagnostic( + root, + { kind: 'claim_conflict', level: 'blocker' }, + NOW, + ); + await recordLocalDiagnostic(root, { kind: 'repair_operation' }, NOW); + + await expect(readLocalDiagnostics(root)).resolves.toMatchObject({ + contextStaleCount: 1, + claimConflictCounts: { blocker: 1 }, + repairOperationCount: 1, + }); + const persisted = await readFile(localDiagnosticsPath(root), 'utf8'); + expect(persisted).not.toContain(root); + expect(persisted).not.toContain('task'); + expect(persisted).not.toContain('actor'); + + await setLocalDiagnosticsEnabled(root, false, NOW); + await expect(readLocalDiagnostics(root)).resolves.toBeNull(); + await expect( + recordLocalDiagnostic(root, { kind: 'revision_conflict' }, NOW), + ).resolves.toBeNull(); + await expect(readLocalDiagnostics(root)).resolves.toBeNull(); + }); + + it('exposes explicit show, disable, and enable commands', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await contextDiagnostics(root, 'disable', { json: true })).toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + config: { enabled: false }, + diagnostics: null, + }); + expect(await contextDiagnostics(root, 'enable', { json: true })).toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + config: { enabled: true }, + }); + expect(await contextDiagnostics(root, 'invalid', { json: true })).toBe(2); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { code: 'MANCODE_DIAGNOSTICS_ACTION_INVALID' }, + }); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + + it('rejects unrecognized stored fields rather than accepting accidental content', () => { + expect(() => + parseLocalDiagnostics({ + schemaVersion: 1, + contextStaleCount: 0, + revisionConflictCount: 0, + claimConflictCounts: { info: 0, warning: 0, blocker: 0, unknown: 0 }, + repairOperationCount: 0, + migrationSplitBrainDetectionCount: 0, + adapterCapabilityDowngradeCount: 0, + updatedAt: NOW.toISOString(), + task: 'must not be stored', + }), + ).toThrow(/unknown field/); + }); +}); + +function id(offset: number) { + return createUlid( + Date.parse('2026-07-18T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/entity-home-store-contracts.test.ts b/tests/entity-home-store-contracts.test.ts new file mode 100644 index 0000000..7dbd637 --- /dev/null +++ b/tests/entity-home-store-contracts.test.ts @@ -0,0 +1,156 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + claimDirectory, + resolveCoordinationEntityHomeStore, + resolveTaskEntityHomeStore, +} from '../src/runtime/entity-home-store.js'; +import { + acquireEntityLocks, + acquireLocalLock, + readLocalLock, +} from '../src/runtime/local-lock.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const CHECKOUT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const BINDING_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const NEXT_OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7P'; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); +}); + +describe('entity home stores and canonical local locks', () => { + it('separates local task storage from the shared coordination store', () => { + const context = { + projectRoot: '/checkout/project', + workspaceId: WORKSPACE_ID, + checkoutId: CHECKOUT_ID, + gitCommonDir: '/repo/.git', + repositoryBindingId: BINDING_ID, + }; + const local = resolveTaskEntityHomeStore(context, { + namespace: 'local', + taskId: TASK_ID, + }); + const shared = resolveTaskEntityHomeStore(context, { + namespace: 'shared', + taskId: TASK_ID, + }); + expect(local.storeId).toBe(`checkout:${CHECKOUT_ID}:${WORKSPACE_ID}`); + expect(local.root).toBe('/checkout/project/.mancode/local/runtime'); + expect(shared.storeId).toBe(`workspace:${BINDING_ID}:${WORKSPACE_ID}`); + expect(shared.root).toBe(`/repo/.git/mancode/workspaces/${WORKSPACE_ID}`); + expect(claimDirectory(shared)).toBe( + `/repo/.git/mancode/workspaces/${WORKSPACE_ID}/claims`, + ); + expect(() => claimDirectory(local)).toThrow(/shared coordination/); + + const nonGit = resolveCoordinationEntityHomeStore({ + ...context, + gitCommonDir: null, + repositoryBindingId: null, + }); + expect(nonGit.storeId).toBe(`non-git:${WORKSPACE_ID}`); + expect(nonGit.root).toBe( + `/checkout/project/.mancode/runtime/non-git/${WORKSPACE_ID}`, + ); + }); + + it('acquires and releases store-local entity locks in UTF-8 key order', async () => { + const projectRoot = await temporaryRoot(); + const store = resolveCoordinationEntityHomeStore({ + projectRoot, + workspaceId: WORKSPACE_ID, + checkoutId: CHECKOUT_ID, + gitCommonDir: null, + repositoryBindingId: null, + }); + await mkdir(store.root, { recursive: true }); + const first = await acquireLocalLock(store, { + operationId: OPERATION_ID, + entityLockKey: `task:shared:${TASK_ID}`, + processId: 123, + now: new Date('2026-07-17T10:00:00.000Z'), + }); + await expect( + acquireLocalLock(store, { + operationId: NEXT_OPERATION_ID, + entityLockKey: `task:shared:${TASK_ID}`, + processId: 456, + now: new Date('2026-07-17T10:00:01.000Z'), + }), + ).rejects.toThrow('MANCODE_LOCK_HELD'); + await expect( + readLocalLock(store, `task:shared:${TASK_ID}`), + ).resolves.toMatchObject({ operationId: OPERATION_ID, processId: 123 }); + await first.release(); + await expect( + readLocalLock(store, `task:shared:${TASK_ID}`), + ).resolves.toBeNull(); + + const locks = await acquireEntityLocks( + store, + OPERATION_ID, + [`task:shared:${TASK_ID}`, `handoff:${TASK_ID}`], + { processId: 123, now: new Date('2026-07-17T10:00:00.000Z') }, + ); + expect(locks.map((lock) => lock.entityLockKey)).toEqual([ + `handoff:${TASK_ID}`, + `task:shared:${TASK_ID}`, + ]); + await Promise.all(locks.map((lock) => lock.release())); + }); + + it('reclaims only an expired lease from a dead process and renews live work', async () => { + const projectRoot = await temporaryRoot(); + const store = resolveCoordinationEntityHomeStore({ + projectRoot, + workspaceId: WORKSPACE_ID, + checkoutId: CHECKOUT_ID, + gitCommonDir: null, + repositoryBindingId: null, + }); + await mkdir(store.root, { recursive: true }); + const abandoned = await acquireLocalLock(store, { + operationId: OPERATION_ID, + entityLockKey: `task:shared:${TASK_ID}`, + processId: 999_999, + now: new Date('2026-07-17T10:00:00.000Z'), + leaseMs: 1_000, + }); + await abandoned.renew(new Date('2026-07-17T10:00:00.500Z')); + await expect( + acquireLocalLock(store, { + operationId: NEXT_OPERATION_ID, + entityLockKey: `task:shared:${TASK_ID}`, + processId: 456, + now: new Date('2026-07-17T10:00:01.200Z'), + }), + ).rejects.toThrow('MANCODE_LOCK_HELD'); + + const replacement = await acquireLocalLock(store, { + operationId: NEXT_OPERATION_ID, + entityLockKey: `task:shared:${TASK_ID}`, + processId: 456, + now: new Date('2026-07-17T10:00:01.600Z'), + }); + await expect(abandoned.release()).rejects.toThrow( + 'MANCODE_LOCK_OWNERSHIP_LOST', + ); + await replacement.release(); + }); +}); + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'mancode-home-store-')); + roots.push(root); + return root; +} diff --git a/tests/git-ref-cache-contracts.test.ts b/tests/git-ref-cache-contracts.test.ts new file mode 100644 index 0000000..a463f22 --- /dev/null +++ b/tests/git-ref-cache-contracts.test.ts @@ -0,0 +1,81 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + capabilitiesFromGitRefCache, + readGitRefTeamCache, + writeGitRefTeamCache, +} from '../src/team/git-ref-cache.js'; +import { parseProjectConfig } from '../src/team/policy.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('git-ref local cache contract', () => { + it('reports freshness without performing a remote read', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'mancode-cache-')); + roots.push(root); + const config = gitRefConfig(3); + const cache = await writeGitRefTeamCache(root, config, { + manifest: null, + commit: null, + receipt: null, + fetchedAt: '2026-07-18T10:00:00.000Z', + }); + + await expect(readGitRefTeamCache(root, config)).resolves.toEqual(cache); + expect( + capabilitiesFromGitRefCache( + config, + cache, + new Date('2026-07-18T10:04:59.000Z'), + ), + ).toMatchObject({ + claimAcquisition: 'unavailable', + transportFreshness: 'fresh', + remoteRevision: 0, + }); + expect( + capabilitiesFromGitRefCache( + config, + cache, + new Date('2026-07-18T10:05:01.000Z'), + ), + ).toMatchObject({ + claimAcquisition: 'unavailable', + transportFreshness: 'stale', + }); + }); + + it('does not reuse a cache from an older transport epoch', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'mancode-cache-')); + roots.push(root); + await writeGitRefTeamCache(root, gitRefConfig(2), { + manifest: null, + commit: null, + receipt: null, + fetchedAt: '2026-07-18T10:00:00.000Z', + }); + await expect( + readGitRefTeamCache(root, gitRefConfig(3)), + ).resolves.toBeNull(); + }); +}); + +function gitRefConfig(epoch: number) { + return parseProjectConfig({ + schemaVersion: 1, + revision: epoch, + workspaceId: WORKSPACE_ID, + transport: { mode: 'git-ref', remote: 'origin', epoch }, + lastOperationId: null, + updatedAt: '2026-07-18T10:00:00.000Z', + }); +} diff --git a/tests/git-ref-clock-skew-contracts.test.ts b/tests/git-ref-clock-skew-contracts.test.ts new file mode 100644 index 0000000..1ccd95e --- /dev/null +++ b/tests/git-ref-clock-skew-contracts.test.ts @@ -0,0 +1,416 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import { V3ContextStore } from '../src/context/store.js'; +import type { TaskRef } from '../src/context/task-ref.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import type { WorkflowMetadataV3 } from '../src/context/workflow-metadata.js'; +import { createSession } from '../src/runtime/session.js'; +import { gitRefCoordinationDomainId } from '../src/runtime/workspace-binding.js'; +import { + type SharedActorProfileV1, + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, +} from '../src/team/actor.js'; +import type { ClaimV1 } from '../src/team/claims.js'; +import { + assessClaimConflicts, + deriveClaimValidity, +} from '../src/team/conflicts.js'; +import { createGitRefTaskBundle } from '../src/team/git-ref-bundle.js'; +import { + type PreparedGitRefCoordinationMutation, + prepareGitRefCoordinationMutation, +} from '../src/team/git-ref-coordination.js'; +import { + type GitRefTaskBundleV1, + type GitRefTeamManifestStore, + type GitRefTeamManifestV1, + GitRefTeamManifestStore as ManifestStore, + resolveGitRefRemoteIdentityHash, +} from '../src/team/git-ref-transport.js'; + +const execFile = promisify(execFileCallback); +const TASK_REF: TaskRef = { namespace: 'shared', taskId: id(1) }; +const WORKSPACE_ID = id(2); +const OWNER_ID = id(3); +const PARTICIPANT_ID = id(4); +const CLAIM_ID = id(5); +const SCHEMA_EPOCH = id(6); +const OWNER_SESSION_ID = id(7); +const NOW = new Date('2026-07-18T10:00:00.000Z'); +const EXPIRES_AT = new Date('2026-07-18T10:01:00.000Z'); +const SKEWED_NOW = new Date('2026-07-18T10:02:00.000Z'); +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('git-ref clock-skew coordination contract', () => { + it('requires explicit reclaim plus claim/revision CAS before an expiry candidate changes remote state', async () => { + const fixture = await createFixture(); + let clock = NOW; + const storeA = store(fixture.cloneA, () => clock); + const storeB = store(fixture.cloneB, () => clock); + const remoteIdentityHash = await resolveGitRefRemoteIdentityHash( + fixture.cloneA, + 'origin', + ); + const coordinationDomainId = gitRefCoordinationDomainId( + remoteIdentityHash, + WORKSPACE_ID, + 1, + ); + const { metadata, taskBundle } = await createTaskBundle( + fixture.cloneA, + fixture.head, + ); + const claim = activeClaim(coordinationDomainId, metadata, taskBundle); + const establishOperationId = id(10); + + await storeA.establishCoordinationAuthority({ + operationId: establishOperationId, + actorId: OWNER_ID, + expectedRemoteRevision: 0, + expectedPriorTransportEpoch: null, + targetTransportEpoch: 1, + actorProfiles: [ + actorProfile(OWNER_ID, 'Owner'), + actorProfile(PARTICIPANT_ID, 'Participant'), + ], + ownershipFences: [ + { + schemaVersion: 1, + taskRef: TASK_REF, + ownerActorId: OWNER_ID, + ownershipEpoch: 1, + taskRevision: taskBundle.taskRevision, + aggregateDigest: taskBundle.aggregateDigest, + remoteRevision: 1, + lastOperationId: establishOperationId, + updatedAt: NOW.toISOString(), + }, + ], + claims: [{ ...claim, lastOperationId: establishOperationId }], + handoffs: [], + taskBundles: [taskBundle], + }); + + const snapshotA = await requireManifest(storeA); + const snapshotB = await requireManifest(storeB); + const unchangedSnapshot = structuredClone(snapshotA); + clock = SKEWED_NOW; + + expect( + deriveClaimValidity(snapshotA.claims[0] as ClaimV1, { + taskRef: TASK_REF, + taskRevision: taskBundle.taskRevision, + implementationScopeDigest: metadata.implementationScope.digest, + ownershipEpoch: taskBundle.ownershipEpoch, + codeRefHead: taskBundle.codeRef.head, + now: SKEWED_NOW, + transportFreshness: 'fresh', + }), + ).toBe('expiry_candidate'); + expect( + assessClaimConflicts(scope(), snapshotA.claims, { + transportFreshness: 'fresh', + claimAcquisition: 'enforced', + }), + ).toMatchObject({ + level: 'blocker', + acquisition: 'reject', + conflictingClaimIds: [CLAIM_ID], + }); + + expect(() => + prepareGitRefCoordinationMutation(snapshotA, { + kind: 'claim_acquire', + operationId: id(20), + actorId: PARTICIPANT_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 1, + claim: pendingClaim(coordinationDomainId, metadata, taskBundle, id(21)), + now: SKEWED_NOW, + }), + ).toThrow('MANCODE_CLAIM_REVALIDATION_REQUIRED'); + expect(snapshotA).toEqual(unchangedSnapshot); + await expect(requireManifest(storeA)).resolves.toEqual(unchangedSnapshot); + + expect(() => + prepareReclaim(snapshotA, { + operationId: id(22), + expectedRemoteRevision: 1, + expectedClaimRevision: 2, + }), + ).toThrow('MANCODE_EXPECTED_REVISION_CONFLICT'); + expect(() => + prepareReclaim(snapshotA, { + operationId: id(23), + expectedRemoteRevision: 0, + expectedClaimRevision: 1, + }), + ).toThrow('MANCODE_TRANSPORT_REVISION_CONFLICT'); + await expect(requireManifest(storeB)).resolves.toEqual(unchangedSnapshot); + + const reclaimA = prepareReclaim(snapshotA, { + operationId: id(30), + expectedRemoteRevision: 1, + expectedClaimRevision: 1, + }); + const reclaimB = prepareReclaim(snapshotB, { + operationId: id(31), + expectedRemoteRevision: 1, + expectedClaimRevision: 1, + }); + const results = await Promise.allSettled([ + storeA.mutateCoordination(reclaimA), + storeB.mutateCoordination(reclaimB), + ]); + + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + expect(rejected?.reason).toBeInstanceOf(Error); + expect((rejected?.reason as Error).message).toMatch( + /MANCODE_TRANSPORT_(CAS|REVISION)_CONFLICT/, + ); + await expect(storeB.mutateCoordination(reclaimB)).rejects.toThrow( + 'MANCODE_TRANSPORT_REVISION_CONFLICT', + ); + + const committed = await requireManifest(storeA); + expect(committed).toMatchObject({ + revision: 2, + claims: [ + { + claimId: CLAIM_ID, + state: 'expired', + revision: 2, + authority: { mode: 'git-ref', remoteRevision: '2' }, + }, + ], + }); + expect([id(30), id(31)]).toContain(committed.lastOperationId); + }, 20_000); +}); + +function prepareReclaim( + manifest: GitRefTeamManifestV1, + input: { + operationId: string; + expectedRemoteRevision: number; + expectedClaimRevision: number; + }, +): PreparedGitRefCoordinationMutation { + return prepareGitRefCoordinationMutation(manifest, { + kind: 'claim_reclaim', + operationId: input.operationId, + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: input.expectedRemoteRevision, + expectedOwnershipEpoch: 1, + claimId: CLAIM_ID, + expectedClaimRevision: input.expectedClaimRevision, + reason: 'The freshly pulled remote claim is an expiry candidate.', + now: SKEWED_NOW, + }); +} + +function activeClaim( + coordinationDomainId: string, + metadata: WorkflowMetadataV3, + taskBundle: GitRefTaskBundleV1, +): ClaimV1 { + return { + ...pendingClaim(coordinationDomainId, metadata, taskBundle, CLAIM_ID), + authority: { mode: 'git-ref', remoteRevision: '1' }, + state: 'active', + expiresAt: EXPIRES_AT.toISOString(), + }; +} + +function pendingClaim( + coordinationDomainId: string, + metadata: WorkflowMetadataV3, + taskBundle: GitRefTaskBundleV1, + claimId: string, +): ClaimV1 { + const claimScope = scope(); + return { + schemaVersion: 1, + claimId, + workspaceId: WORKSPACE_ID, + coordinationDomainId, + authority: { mode: 'git-ref', remoteRevision: null }, + taskRef: TASK_REF, + taskRevisionAtAcquire: taskBundle.taskRevision, + lastValidatedTaskRevision: taskBundle.taskRevision, + implementationScopeDigest: metadata.implementationScope.digest, + ownershipEpochAtAcquire: taskBundle.ownershipEpoch, + ownerActorId: claimId === CLAIM_ID ? OWNER_ID : PARTICIPANT_ID, + state: 'pending', + revision: 1, + scope: claimScope, + scopeDigest: digestCanonicalJson(claimScope), + codeRefAtAcquire: taskBundle.codeRef, + lastValidatedCodeRef: taskBundle.codeRef, + acquisitionEnforcement: 'enforced', + writeGuard: 'advisory', + expiresAt: new Date(SKEWED_NOW.getTime() + 60_000).toISOString(), + predecessorClaimId: null, + successorClaimId: null, + lastOperationId: null, + createdAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }; +} + +function scope() { + return { + paths: ['src/auth/token.ts'], + modules: ['auth'], + apis: [], + schemas: [], + }; +} + +function actorProfile( + actorId: string, + displayName: string, +): SharedActorProfileV1 { + return { + schemaVersion: 1, + actorId, + displayName, + joinedAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }; +} + +function store(projectRoot: string, now: () => Date): GitRefTeamManifestStore { + return new ManifestStore({ + projectRoot, + remote: 'origin', + workspaceId: WORKSPACE_ID, + schemaEpoch: SCHEMA_EPOCH, + now, + }); +} + +async function createTaskBundle( + projectRoot: string, + head: string, +): Promise<{ + metadata: WorkflowMetadataV3; + taskBundle: GitRefTaskBundleV1; +}> { + await initializeV3Project({ + projectRoot, + operationId: id(40), + workspaceId: WORKSPACE_ID, + schemaEpoch: SCHEMA_EPOCH, + now: NOW, + }); + const owner = await createLocalActor(projectRoot, { + actorId: OWNER_ID, + displayName: 'Owner', + now: NOW, + }); + await Promise.all([ + publishSharedActorProfile( + projectRoot, + createSharedActorProfile(owner, NOW), + ), + publishSharedActorProfile( + projectRoot, + actorProfile(PARTICIPANT_ID, 'Participant'), + ), + ]); + await createSession(projectRoot, { + actorId: OWNER_ID, + sessionId: OWNER_SESSION_ID, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + const workflow = await createV3Workflow({ + projectRoot, + task: 'Keep remote claim authority stable across local clock skew.', + workflowMode: 'manteam', + sessionId: OWNER_SESSION_ID, + client: 'vitest', + sharedPrivacyConfirmed: true, + participantActorIds: [PARTICIPANT_ID], + implementationScope: { include: ['src/auth/**'], modules: ['auth'] }, + taskId: TASK_REF.taskId, + operationId: id(41), + now: NOW, + }); + const task = await new V3ContextStore(projectRoot).readTaskSnapshot( + workflow.taskRef, + ); + return { + metadata: task.metadata, + taskBundle: createGitRefTaskBundle({ + task, + codeRef: { branch: 'main', head }, + now: NOW, + }), + }; +} + +async function requireManifest( + store: GitRefTeamManifestStore, +): Promise { + const manifest = (await store.pull()).manifest; + if (manifest === null) throw new Error('missing git-ref manifest'); + return manifest; +} + +async function createFixture(): Promise<{ + cloneA: string; + cloneB: string; + head: string; +}> { + const root = await mkdtemp(path.join(os.tmpdir(), 'mancode-clock-skew-')); + temporaryRoots.push(root); + const remote = path.join(root, 'remote.git'); + const cloneA = path.join(root, 'clone-a'); + const cloneB = path.join(root, 'clone-b'); + await execFile('git', ['init', '--bare', remote]); + await execFile('git', ['clone', remote, cloneA]); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: cloneA, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: cloneA }); + await writeFile(path.join(cloneA, 'README.md'), '# clock skew fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: cloneA }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: cloneA }); + await execFile('git', ['branch', '-M', 'main'], { cwd: cloneA }); + await execFile('git', ['push', '-u', 'origin', 'main'], { cwd: cloneA }); + await execFile('git', ['clone', '--branch', 'main', remote, cloneB]); + const { stdout } = await execFile('git', ['rev-parse', 'HEAD'], { + cwd: cloneA, + }); + return { cloneA, cloneB, head: stdout.trim() }; +} + +function id(value: number): string { + return `01JZ4B6W5Z0A1B2C3D4E5F${value.toString().padStart(4, '0')}`; +} diff --git a/tests/git-ref-command-contracts.test.ts b/tests/git-ref-command-contracts.test.ts new file mode 100644 index 0000000..bf651dc --- /dev/null +++ b/tests/git-ref-command-contracts.test.ts @@ -0,0 +1,342 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + teamClaim, + teamClaimReclaim, + teamClaimRelease, + teamClaimRenew, + teamClaimRevalidate, + teamClaimTransfer, + teamConflicts, + teamHandoffAccept, + teamHandoffCancel, + teamHandoffDraft, + teamHandoffOffer, + teamHandoffReject, + teamStatus, +} from '../src/commands/team.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; +import { + gitRefCachePath, + writeGitRefTeamCache, +} from '../src/team/git-ref-cache.js'; +import type { ProjectConfigV1 } from '../src/team/policy.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-18T12:00:00.000Z'); + +describe('git-ref command explicit-sync contract', () => { + let root: string; + let sessionId: Ulid; + let receiverActorId: Ulid; + let taskRef: `shared:${string}`; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'mancode-git-ref-command-')); + await initializeGitFixture(root); + const fixture = await initializeSharedTask(root); + sessionId = fixture.sessionId; + receiverActorId = fixture.receiverActorId; + taskRef = `shared:${fixture.taskId}`; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); + }); + + it('keeps git-ref mutations behind explicit sync and reads status from cache', async () => { + const config = await setGitRefConfig(root); + const fetchedAt = new Date().toISOString(); + await writeGitRefTeamCache(root, config, { + manifest: null, + commit: null, + receipt: null, + fetchedAt, + }); + const cacheBefore = await readFile(gitRefCachePath(root), 'utf8'); + const tracePath = path.join(root, 'git-trace.jsonl'); + vi.stubEnv('GIT_TRACE2_EVENT', tracePath); + + const claimId = id(20); + const handoffId = id(21); + const common = { session: sessionId, client: 'vitest', json: true }; + const mutations: Array<{ name: string; action: () => Promise }> = [ + { + name: 'claim', + action: () => + teamClaim(root, { + ...common, + task: taskRef, + expectedTaskRevision: '1', + paths: ['src/**'], + }), + }, + { + name: 'handoff draft', + action: () => + teamHandoffDraft(root, { + ...common, + task: taskRef, + expectedTaskRevision: '1', + to: receiverActorId, + }), + }, + ]; + const claimOptions = { + ...common, + claimId, + expectedRevision: '1', + to: receiverActorId, + reason: 'Recover abandoned scope.', + }; + for (const [name, command] of [ + ['claim renew', teamClaimRenew], + ['claim release', teamClaimRelease], + ['claim transfer', teamClaimTransfer], + ['claim reclaim', teamClaimReclaim], + ['claim revalidate', teamClaimRevalidate], + ] as const) { + mutations.push({ + name, + action: () => command(root, claimOptions), + }); + } + const handoffOptions = { + ...common, + handoffId, + expectedRevision: '1', + reason: 'Receiver declined the handoff.', + }; + for (const [name, command] of [ + ['handoff offer', teamHandoffOffer], + ['handoff accept', teamHandoffAccept], + ['handoff reject', teamHandoffReject], + ['handoff cancel', teamHandoffCancel], + ] as const) { + mutations.push({ + name, + action: () => command(root, handoffOptions), + }); + } + + const results = []; + for (const { name, action } of mutations) { + const output = await captureJson(action); + results.push({ + name, + exitCode: output.exitCode, + code: output.value.error?.code, + }); + } + expect(results).toEqual( + results.map(({ name }) => ({ + name, + exitCode: 3, + code: 'MANCODE_EXPLICIT_SYNC_REQUIRED', + })), + ); + + const status = await captureJson(() => teamStatus(root, { json: true })); + expect(status).toMatchObject({ + exitCode: 0, + value: { + capabilities: { + transport: 'git-ref', + claimAcquisition: 'unavailable', + }, + remoteSnapshot: { revision: 0, fetchedAt }, + }, + }); + const conflicts = await captureJson(() => + teamConflicts(root, { task: taskRef, json: true }), + ); + expect(conflicts).toMatchObject({ + exitCode: 0, + value: { + capabilities: { transport: 'git-ref' }, + claims: [], + handoffs: [], + }, + }); + + expect(await readFile(gitRefCachePath(root), 'utf8')).toBe(cacheBefore); + expect(await remoteGitCommands(tracePath)).toEqual([]); + }); + + it('rejects explicit sync when the configured transport is local', async () => { + const common = { + session: sessionId, + client: 'vitest', + sync: true, + json: true, + }; + const results = [ + await captureJson(() => + teamClaim(root, { + ...common, + task: taskRef, + expectedTaskRevision: '1', + paths: ['src/**'], + }), + ), + await captureJson(() => + teamHandoffDraft(root, { + ...common, + task: taskRef, + expectedTaskRevision: '1', + to: receiverActorId, + }), + ), + ]; + + expect( + results.map((result) => ({ + exitCode: result.exitCode, + code: result.value.error?.code, + })), + ).toEqual([ + { exitCode: 3, code: 'MANCODE_TRANSPORT_UNAVAILABLE' }, + { exitCode: 3, code: 'MANCODE_TRANSPORT_UNAVAILABLE' }, + ]); + }); +}); + +async function initializeGitFixture(projectRoot: string): Promise { + await execFile('git', ['init'], { cwd: projectRoot }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: projectRoot, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { + cwd: projectRoot, + }); + await writeFile(path.join(projectRoot, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: projectRoot }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: projectRoot }); +} + +async function initializeSharedTask(projectRoot: string): Promise<{ + taskId: Ulid; + sessionId: Ulid; + receiverActorId: Ulid; +}> { + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const ownerActorId = id(4); + const sessionId = id(5); + const receiverActorId = id(6); + await createLocalActor(projectRoot, { + actorId: ownerActorId, + displayName: 'Command Owner', + now: NOW, + }); + const owner = await readLocalActor(projectRoot); + if (owner === null) throw new Error('missing command owner'); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(owner, NOW), + ); + await publishSharedActorProfile(projectRoot, { + schemaVersion: 1, + actorId: receiverActorId, + displayName: 'Command Receiver', + joinedAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }); + await createSession(projectRoot, { + actorId: ownerActorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + const taskId = id(7); + await createV3Workflow({ + projectRoot, + task: 'Prove command-level explicit synchronization.', + workflowMode: 'manteam', + sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + participantActorIds: [receiverActorId], + implementationScope: { include: ['src/**'] }, + taskId, + operationId: id(8), + now: NOW, + }); + return { taskId, sessionId, receiverActorId }; +} + +async function setGitRefConfig(projectRoot: string): Promise { + const target = path.join(projectRoot, '.mancode', 'shared', 'config.json'); + const config = JSON.parse(await readFile(target, 'utf8')) as ProjectConfigV1; + const next: ProjectConfigV1 = { + ...config, + revision: config.revision + 1, + transport: { + mode: 'git-ref', + remote: 'remote-that-must-not-be-contacted', + epoch: config.transport.epoch + 1, + }, + lastOperationId: id(9), + updatedAt: NOW.toISOString(), + }; + await writeFile(target, `${JSON.stringify(next, null, 2)}\n`); + return next; +} + +async function captureJson(action: () => Promise): Promise<{ + exitCode: number; + value: Record & { error?: { code?: string } }; +}> { + const writes: string[] = []; + const previous = console.log; + console.log = (value: unknown) => writes.push(String(value)); + try { + return { + exitCode: await action(), + value: JSON.parse(writes.at(-1) ?? '{}'), + }; + } finally { + console.log = previous; + } +} + +async function remoteGitCommands(tracePath: string): Promise { + const trace = await readFile(tracePath, 'utf8'); + return trace + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { event?: string; argv?: string[] }) + .filter( + (event): event is { event: string; argv: string[] } => + event.event === 'start' && Array.isArray(event.argv), + ) + .map((event) => event.argv) + .filter((argv) => ['ls-remote', 'fetch', 'push'].includes(argv[1] ?? '')); +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-18T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/git-ref-coordination-contracts.test.ts b/tests/git-ref-coordination-contracts.test.ts new file mode 100644 index 0000000..de14169 --- /dev/null +++ b/tests/git-ref-coordination-contracts.test.ts @@ -0,0 +1,985 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { + type TaskAggregateManifestV1, + buildTaskAggregateManifest, + taskAggregateDigest, +} from '../src/context/aggregate.js'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import { V3ContextStore } from '../src/context/store.js'; +import type { TaskRef } from '../src/context/task-ref.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { + type WorkflowMetadataV3, + parseWorkflowMetadata, + workflowMetadataDigest, +} from '../src/context/workflow-metadata.js'; +import { createSession } from '../src/runtime/session.js'; +import { gitRefCoordinationDomainId } from '../src/runtime/workspace-binding.js'; +import { + type SharedActorProfileV1, + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, +} from '../src/team/actor.js'; +import type { ClaimV1 } from '../src/team/claims.js'; +import { createGitRefTaskBundle } from '../src/team/git-ref-bundle.js'; +import { + type PreparedGitRefCoordinationMutation, + materializeGitRefCoordination, + prepareGitRefCoordinationMutation, +} from '../src/team/git-ref-coordination.js'; +import type { + GitRefOwnershipFenceV1, + GitRefTaskBundleV1, + GitRefTeamManifestV1, +} from '../src/team/git-ref-transport.js'; +import { GitRefTeamManifestStore } from '../src/team/git-ref-transport.js'; +import { resolveGitRefRemoteIdentityHash } from '../src/team/git-ref-transport.js'; +import type { HandoffV1 } from '../src/team/handoff.js'; + +const execFile = promisify(execFileCallback); +const TASK_REF: TaskRef = { namespace: 'shared', taskId: id(1) }; +const WORKSPACE_ID = id(2); +const OWNER_ID = id(3); +const RECEIVER_ID = id(4); +const CLAIM_ID = id(5); +const HANDOFF_ID = id(6); +const CHECKPOINT_ID = id(7); +const NOW = new Date('2026-07-18T10:00:00.000Z'); +const DIGEST = `sha256:${'a'.repeat(64)}`; +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('git-ref coordination domain contracts', () => { + it('bootstraps a revision-zero ownership fence without copying workflow authority', () => { + const empty = { + ...baseManifest(), + ownershipFences: [], + taskBundles: [], + }; + const initialBundle = bundle(metadata({ ownershipEpoch: 0, revision: 1 })); + + const prepared = prepareGitRefCoordinationMutation(empty, { + kind: 'ownership_fence', + operationId: id(19), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 0, + taskBundle: initialBundle, + now: NOW, + }); + + expect(prepared.ownershipFence).toEqual({ + schemaVersion: 1, + taskRef: TASK_REF, + ownerActorId: OWNER_ID, + ownershipEpoch: 0, + taskRevision: 1, + aggregateDigest: initialBundle.aggregateDigest, + remoteRevision: 2, + lastOperationId: id(19), + updatedAt: NOW.toISOString(), + }); + }); + + it('materializes remote handoffs as fetched without changing business state', () => { + const current = { + ...baseManifest({ + claims: [activeClaim()], + handoffs: [cancelledHandoff()], + }), + taskBundles: [], + }; + + const result = materializeGitRefCoordination( + current, + '2026-07-18T10:01:00.000Z', + ); + + expect(result).toMatchObject({ + remoteRevision: 1, + claims: [{ claimId: CLAIM_ID, state: 'active' }], + handoffs: [ + { + handoffId: HANDOFF_ID, + state: 'cancelled', + transport: { + state: 'fetched', + fetchedAt: '2026-07-18T10:01:00.000Z', + }, + }, + ], + }); + expect(current.handoffs[0]?.transport.state).toBe('published'); + }); + + it('rejects stale ownership epochs and fresh blocking scope conflicts', () => { + const current = baseManifest({ claims: [activeClaim()] }); + expect(() => + prepareGitRefCoordinationMutation(current, { + kind: 'claim_release', + operationId: id(20), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 0, + claimId: CLAIM_ID, + expectedClaimRevision: 1, + now: NOW, + }), + ).toThrow('MANCODE_OWNERSHIP_EPOCH_STALE'); + + expect(() => + prepareGitRefCoordinationMutation(current, { + kind: 'claim_acquire', + operationId: id(21), + actorId: RECEIVER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 1, + claim: pendingClaim({ + claimId: id(22), + ownerActorId: RECEIVER_ID, + }), + now: NOW, + }), + ).toThrow('MANCODE_SCOPE_CONFLICT'); + + expect(() => + prepareGitRefCoordinationMutation(current, { + kind: 'ownership_fence', + operationId: id(23), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 1, + taskBundle: bundle( + metadata({ task: 'A divergent task at the same remote revision.' }), + ), + now: NOW, + }), + ).toThrow('MANCODE_SPLIT_BRAIN'); + }); + + it('prepares the complete remote claim lifecycle', () => { + let current = baseManifest(); + const acquired = prepareGitRefCoordinationMutation(current, { + kind: 'claim_acquire', + operationId: id(30), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 1, + claim: pendingClaim(), + now: NOW, + }); + expect(acquired.claims).toMatchObject([ + { + claimId: CLAIM_ID, + state: 'active', + revision: 1, + authority: { mode: 'git-ref', remoteRevision: '2' }, + }, + ]); + + current = commitPrepared(current, acquired); + const renewed = prepareGitRefCoordinationMutation(current, { + kind: 'claim_renew', + operationId: id(31), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 2, + expectedOwnershipEpoch: 1, + claimId: CLAIM_ID, + expectedClaimRevision: 1, + expiresAt: '2026-08-20T10:00:00.000Z', + now: NOW, + }); + expect(renewed.claims[0]).toMatchObject({ revision: 2 }); + + current = commitPrepared(current, renewed); + const advancedFence = prepareGitRefCoordinationMutation(current, { + kind: 'ownership_fence', + operationId: id(32), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 3, + expectedOwnershipEpoch: 1, + taskBundle: bundle(metadata({ revision: 8 })), + now: NOW, + }); + current = commitPrepared(current, advancedFence); + const revalidated = prepareGitRefCoordinationMutation(current, { + kind: 'claim_revalidate', + operationId: id(33), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 4, + expectedOwnershipEpoch: 1, + claimId: CLAIM_ID, + expectedClaimRevision: 2, + now: NOW, + }); + expect(revalidated.claims[0]).toMatchObject({ + revision: 3, + lastValidatedTaskRevision: 8, + }); + + current = commitPrepared(current, revalidated); + const transferred = prepareGitRefCoordinationMutation(current, { + kind: 'claim_transfer', + operationId: id(34), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 5, + expectedOwnershipEpoch: 1, + claimId: CLAIM_ID, + expectedClaimRevision: 3, + toActorId: RECEIVER_ID, + successorClaimId: id(35), + now: NOW, + }); + expect(transferred.claims).toMatchObject([ + { claimId: CLAIM_ID, state: 'transferred', successorClaimId: id(35) }, + { + claimId: id(35), + state: 'active', + ownerActorId: RECEIVER_ID, + predecessorClaimId: CLAIM_ID, + }, + ]); + + current = commitPrepared(current, transferred); + const released = prepareGitRefCoordinationMutation(current, { + kind: 'claim_release', + operationId: id(36), + actorId: RECEIVER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 6, + expectedOwnershipEpoch: 1, + claimId: id(35), + expectedClaimRevision: 1, + now: NOW, + }); + expect(released.claims[1]).toMatchObject({ + state: 'released', + revision: 2, + }); + + const expired = baseManifest({ + claims: [ + activeClaim({ + expiresAt: '2026-07-18T09:00:00.000Z', + }), + ], + }); + const reclaimed = prepareGitRefCoordinationMutation(expired, { + kind: 'claim_reclaim', + operationId: id(37), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 1, + claimId: CLAIM_ID, + expectedClaimRevision: 1, + reason: 'The remote claim expired and was confirmed from a fresh pull.', + now: NOW, + }); + expect(reclaimed.claims[0]).toMatchObject({ + state: 'expired', + revision: 2, + }); + }); + + it('prepares draft, offer, reject, and cancel through the remote state machine', () => { + let current = baseManifest({ claims: [activeClaim()] }); + const drafted = prepareGitRefCoordinationMutation(current, { + kind: 'handoff_draft', + operationId: id(40), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 1, + handoff: draftHandoff(), + now: NOW, + }); + expect(drafted.handoffs[0]).toMatchObject({ + state: 'draft', + transport: { state: 'published', transportRevision: 2 }, + }); + + current = commitPrepared(current, drafted); + const offered = prepareGitRefCoordinationMutation(current, { + kind: 'handoff_offer', + operationId: id(41), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 2, + expectedOwnershipEpoch: 1, + handoffId: HANDOFF_ID, + expectedHandoffRevision: 1, + now: NOW, + }); + expect(offered.handoffs[0]).toMatchObject({ + state: 'offered', + revision: 2, + }); + + current = commitPrepared(current, offered); + const rejected = prepareGitRefCoordinationMutation(current, { + kind: 'handoff_reject', + operationId: id(42), + actorId: RECEIVER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 3, + expectedOwnershipEpoch: 1, + handoffId: HANDOFF_ID, + expectedHandoffRevision: 2, + reason: 'The receiver needs a narrower implementation scope.', + now: NOW, + }); + expect(rejected.handoffs[0]).toMatchObject({ + state: 'rejected', + revision: 3, + resolution: { actorId: RECEIVER_ID }, + }); + + const cancellable = commitPrepared( + baseManifest({ claims: [activeClaim()] }), + prepareGitRefCoordinationMutation( + baseManifest({ claims: [activeClaim()] }), + { + kind: 'handoff_draft', + operationId: id(43), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 1, + handoff: draftHandoff({ handoffId: id(44) }), + now: NOW, + }, + ), + ); + const cancelled = prepareGitRefCoordinationMutation(cancellable, { + kind: 'handoff_cancel', + operationId: id(45), + actorId: OWNER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 2, + expectedOwnershipEpoch: 1, + handoffId: id(44), + expectedHandoffRevision: 1, + now: NOW, + }); + expect(cancelled.handoffs[0]).toMatchObject({ state: 'cancelled' }); + }); + + it('atomically accepts a handoff by advancing owner, epoch, claims, and repair target', () => { + const current = baseManifest({ + claims: [activeClaim()], + handoffs: [offeredHandoff()], + }); + const nextBundle = bundle( + metadata({ + ownerActorId: RECEIVER_ID, + ownershipEpoch: 2, + revision: 9, + lastOperationId: id(50), + }), + ); + + const accepted = prepareGitRefCoordinationMutation(current, { + kind: 'handoff_accept', + operationId: id(50), + actorId: RECEIVER_ID, + taskRef: TASK_REF, + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 1, + handoffId: HANDOFF_ID, + expectedHandoffRevision: 2, + successorClaimIds: [id(51)], + taskBundle: nextBundle, + codeReachable: true, + now: NOW, + }); + + expect(accepted.ownershipFence).toMatchObject({ + ownerActorId: RECEIVER_ID, + ownershipEpoch: 2, + taskRevision: 9, + remoteRevision: 2, + lastOperationId: id(50), + }); + expect(accepted.handoffs).toMatchObject([ + { + handoffId: HANDOFF_ID, + state: 'accepted', + revision: 3, + transport: { taskBundleDigest: nextBundle.bundleDigest }, + }, + ]); + expect(accepted.claims).toMatchObject([ + { claimId: CLAIM_ID, state: 'transferred', successorClaimId: id(51) }, + { + claimId: id(51), + state: 'active', + ownerActorId: RECEIVER_ID, + ownershipEpochAtAcquire: 2, + taskRevisionAtAcquire: 9, + }, + ]); + expect(accepted.forwardRepair).toMatchObject({ + ownerActorId: RECEIVER_ID, + ownershipEpoch: 2, + operationId: id(50), + }); + }); + + it('feeds participant claim and recipient reject mutations directly into the git-ref store', async () => { + const fixture = await createRemoteFixture(); + await initializeV3Project({ + projectRoot: fixture.clone, + operationId: id(80), + workspaceId: WORKSPACE_ID, + schemaEpoch: id(81), + now: NOW, + }); + const localOwner = await createLocalActor(fixture.clone, { + actorId: OWNER_ID, + displayName: 'Owner', + now: NOW, + }); + await publishSharedActorProfile( + fixture.clone, + createSharedActorProfile(localOwner, NOW), + ); + const sessionId = id(82); + await createSession(fixture.clone, { + actorId: OWNER_ID, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + const workflow = await createV3Workflow({ + projectRoot: fixture.clone, + task: 'Exercise a prepared remote coordination mutation.', + workflowMode: 'manteam', + sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/auth/**'], modules: ['auth'] }, + taskId: TASK_REF.taskId, + operationId: id(83), + now: NOW, + }); + const stored = await new V3ContextStore(fixture.clone).readTaskSnapshot( + workflow.taskRef, + ); + const sharedMetadata = parseWorkflowMetadata({ + ...stored.metadata, + participants: [OWNER_ID, RECEIVER_ID], + }); + const aggregate = buildTaskAggregateManifest({ + metadata: sharedMetadata, + requirements: stored.requirements, + review: stored.review, + verification: stored.verification, + planDigest: stored.plan?.digest ?? null, + latestCheckpoint: stored.latestCheckpoint, + }); + const taskBundle = createGitRefTaskBundle({ + task: { ...stored, metadata: sharedMetadata, aggregate }, + codeRef: { branch: 'main', head: fixture.head }, + now: NOW, + }); + const store = new GitRefTeamManifestStore({ + projectRoot: fixture.clone, + remote: 'origin', + workspaceId: WORKSPACE_ID, + schemaEpoch: id(81), + now: () => NOW, + }); + const establishOperationId = id(84); + await store.establishCoordinationAuthority({ + operationId: establishOperationId, + actorId: OWNER_ID, + expectedRemoteRevision: 0, + expectedPriorTransportEpoch: null, + targetTransportEpoch: 1, + actorProfiles: [ + actorProfile(OWNER_ID, 'Owner'), + actorProfile(RECEIVER_ID, 'Receiver'), + ], + ownershipFences: [ + { + schemaVersion: 1, + taskRef: workflow.taskRef, + ownerActorId: OWNER_ID, + ownershipEpoch: sharedMetadata.ownershipEpoch, + taskRevision: sharedMetadata.revision, + aggregateDigest: taskBundle.aggregateDigest, + remoteRevision: 1, + lastOperationId: establishOperationId, + updatedAt: NOW.toISOString(), + }, + ], + claims: [], + handoffs: [], + taskBundles: [taskBundle], + }); + + let manifest = await requirePulledManifest(store); + const remoteClaimId = id(85); + const coordinationDomainId = gitRefCoordinationDomainId( + await resolveGitRefRemoteIdentityHash(fixture.clone, 'origin'), + WORKSPACE_ID, + 1, + ); + const acquired = prepareGitRefCoordinationMutation(manifest, { + kind: 'claim_acquire', + operationId: id(86), + actorId: RECEIVER_ID, + taskRef: workflow.taskRef, + expectedRemoteRevision: 1, + expectedOwnershipEpoch: sharedMetadata.ownershipEpoch, + claim: pendingClaim({ + claimId: remoteClaimId, + ownerActorId: RECEIVER_ID, + coordinationDomainId, + taskRevisionAtAcquire: taskBundle.taskRevision, + lastValidatedTaskRevision: taskBundle.taskRevision, + implementationScopeDigest: sharedMetadata.implementationScope.digest, + ownershipEpochAtAcquire: taskBundle.ownershipEpoch, + codeRefAtAcquire: taskBundle.codeRef, + lastValidatedCodeRef: taskBundle.codeRef, + }), + now: NOW, + }); + await store.mutateCoordination(acquired); + manifest = await requirePulledManifest(store); + expect(manifest.claims).toMatchObject([ + { claimId: remoteClaimId, ownerActorId: RECEIVER_ID, state: 'active' }, + ]); + + const released = prepareGitRefCoordinationMutation(manifest, { + kind: 'claim_release', + operationId: id(87), + actorId: RECEIVER_ID, + taskRef: workflow.taskRef, + expectedRemoteRevision: 2, + expectedOwnershipEpoch: sharedMetadata.ownershipEpoch, + claimId: remoteClaimId, + expectedClaimRevision: 1, + now: NOW, + }); + await store.mutateCoordination(released); + manifest = await requirePulledManifest(store); + + const handoffId = id(88); + const proposal = draftHandoff({ + handoffId, + taskRef: workflow.taskRef, + taskRevision: taskBundle.taskRevision, + ownershipEpochAtOffer: taskBundle.ownershipEpoch, + claimIds: [], + transport: { + mode: 'git-ref', + state: 'stale', + transportRevision: null, + publishedAt: null, + fetchedAt: null, + taskBundleDigest: taskBundle.bundleDigest, + codeRef: taskBundle.codeRef, + codeReachable: true, + receipt: null, + }, + }); + const drafted = prepareGitRefCoordinationMutation(manifest, { + kind: 'handoff_draft', + operationId: id(89), + actorId: OWNER_ID, + taskRef: workflow.taskRef, + expectedRemoteRevision: 3, + expectedOwnershipEpoch: sharedMetadata.ownershipEpoch, + handoff: proposal, + now: NOW, + }); + await store.mutateCoordination(drafted); + manifest = await requirePulledManifest(store); + const offered = prepareGitRefCoordinationMutation(manifest, { + kind: 'handoff_offer', + operationId: id(90), + actorId: OWNER_ID, + taskRef: workflow.taskRef, + expectedRemoteRevision: 4, + expectedOwnershipEpoch: sharedMetadata.ownershipEpoch, + handoffId, + expectedHandoffRevision: 1, + now: NOW, + }); + await store.mutateCoordination(offered); + manifest = await requirePulledManifest(store); + const rejected = prepareGitRefCoordinationMutation(manifest, { + kind: 'handoff_reject', + operationId: id(91), + actorId: RECEIVER_ID, + taskRef: workflow.taskRef, + expectedRemoteRevision: 5, + expectedOwnershipEpoch: sharedMetadata.ownershipEpoch, + handoffId, + expectedHandoffRevision: 2, + reason: 'The receiver needs a narrower implementation scope.', + now: NOW, + }); + await store.mutateCoordination(rejected); + manifest = await requirePulledManifest(store); + expect(manifest.handoffs).toMatchObject([ + { handoffId, state: 'rejected', resolution: { actorId: RECEIVER_ID } }, + ]); + }); +}); + +function baseManifest( + options: { claims?: ClaimV1[]; handoffs?: HandoffV1[] } = {}, +): GitRefTeamManifestV1 { + const currentBundle = bundle(metadata()); + return { + schemaVersion: 1, + workspaceId: WORKSPACE_ID, + schemaEpoch: id(70), + minReaderVersion: '0.3.9', + minWriterVersion: '0.3.9', + transportEpoch: 1, + configRevision: 1, + configDigest: DIGEST, + authorityState: 'active', + authorityTombstone: null, + revision: 1, + lastOperationId: id(10), + actorProfiles: [ + actorProfile(OWNER_ID, 'Owner'), + actorProfile(RECEIVER_ID, 'Receiver'), + ], + ownershipFences: [fence(currentBundle)], + claims: options.claims ?? [], + handoffs: options.handoffs ?? [], + taskBundles: [currentBundle], + receipts: [], + lastMutation: null, + updatedAt: '2026-07-18T09:00:00.000Z', + }; +} + +function commitPrepared( + previous: GitRefTeamManifestV1, + prepared: PreparedGitRefCoordinationMutation, +): GitRefTeamManifestV1 { + return { + ...previous, + revision: previous.revision + 1, + lastOperationId: prepared.operationId, + ownershipFences: [prepared.ownershipFence], + claims: prepared.claims, + handoffs: prepared.handoffs, + taskBundles: prepared.taskBundle === null ? [] : [prepared.taskBundle], + }; +} + +function fence(taskBundle: GitRefTaskBundleV1): GitRefOwnershipFenceV1 { + return { + schemaVersion: 1, + taskRef: TASK_REF, + ownerActorId: OWNER_ID, + ownershipEpoch: 1, + taskRevision: 7, + aggregateDigest: taskBundle.aggregateDigest, + remoteRevision: 1, + lastOperationId: id(10), + updatedAt: '2026-07-18T09:00:00.000Z', + }; +} + +function metadata( + overrides: Partial = {}, +): WorkflowMetadataV3 { + const scope = { + source: 'explicit' as const, + include: ['src/auth/**'], + exclude: [], + modules: ['auth'], + }; + return { + schemaVersion: 3, + taskRef: TASK_REF, + displaySlug: 'remote-auth-change', + task: 'Coordinate the remote authentication change.', + workflowMode: 'manteam', + visibility: 'shared', + coordination: 'team', + status: 'in_progress', + currentStep: 5, + skippedSteps: [], + blockingReason: null, + outcome: null, + revision: 7, + transitionState: 'stable', + lastOperationId: null, + ownerActorId: OWNER_ID, + ownershipEpoch: 1, + participants: [OWNER_ID, RECEIVER_ID], + createdBy: { actorId: OWNER_ID, client: 'vitest', source: 'actor' }, + base: { branch: 'feature/auth', head: 'abc1234', upstream: null }, + implementationScope: { ...scope, digest: digestCanonicalJson(scope) }, + governance: { + requirementsStatus: 'ready', + requirementsDigest: DIGEST, + planVersion: 1, + planDecision: 'governed_execution', + policyVersions: { planning: 1, review: 1, verification: 1 }, + reviewStatus: 'pending', + reviewLedgerDigest: DIGEST, + verificationStatus: 'pending', + verificationLedgerDigest: DIGEST, + }, + soloExecution: null, + latestCheckpointRef: null, + parent: null, + successorTaskRef: null, + legacyCompatibility: null, + startedAt: '2026-07-18T08:00:00.000Z', + updatedAt: '2026-07-18T09:00:00.000Z', + ...overrides, + }; +} + +function bundle(value: WorkflowMetadataV3): GitRefTaskBundleV1 { + const aggregate: TaskAggregateManifestV1 = { + taskRef: TASK_REF, + taskRevision: value.revision, + ownershipEpoch: value.ownershipEpoch, + metadataDigest: workflowMetadataDigest(value), + requirementsDigest: DIGEST, + reviewDigest: DIGEST, + verificationDigest: DIGEST, + planVersion: 1, + planDigest: DIGEST, + latestCheckpointId: null, + latestCheckpointDigest: null, + parentSnapshotDigest: null, + }; + const body = { + schemaVersion: 1 as const, + taskRef: TASK_REF, + taskRevision: value.revision, + ownershipEpoch: value.ownershipEpoch, + aggregate, + aggregateDigest: taskAggregateDigest(aggregate), + codeRef: { branch: 'feature/auth', head: 'abc1234' }, + artifacts: [ + { + kind: 'metadata' as const, + relativePath: 'metadata.json', + content: value, + contentDigest: digestCanonicalJson(value), + }, + ], + createdAt: '2026-07-18T09:00:00.000Z', + }; + return { ...body, bundleDigest: digestCanonicalJson(body) }; +} + +function pendingClaim(overrides: Partial = {}): ClaimV1 { + const scope = { + paths: ['src/auth/token.ts'], + modules: ['auth'], + apis: [], + schemas: [], + }; + return { + schemaVersion: 1, + claimId: CLAIM_ID, + workspaceId: WORKSPACE_ID, + coordinationDomainId: `git-ref:${id(60)}:${WORKSPACE_ID}:${id(61)}`, + authority: { mode: 'git-ref', remoteRevision: null }, + taskRef: TASK_REF, + taskRevisionAtAcquire: 7, + lastValidatedTaskRevision: 7, + implementationScopeDigest: metadata().implementationScope.digest, + ownershipEpochAtAcquire: 1, + ownerActorId: OWNER_ID, + state: 'pending', + revision: 1, + scope, + scopeDigest: digestCanonicalJson(scope), + codeRefAtAcquire: { branch: 'feature/auth', head: 'abc1234' }, + lastValidatedCodeRef: { branch: 'feature/auth', head: 'abc1234' }, + acquisitionEnforcement: 'enforced', + writeGuard: 'advisory', + expiresAt: '2026-08-18T10:00:00.000Z', + predecessorClaimId: null, + successorClaimId: null, + lastOperationId: null, + createdAt: '2026-07-18T09:00:00.000Z', + updatedAt: '2026-07-18T09:00:00.000Z', + ...overrides, + }; +} + +function activeClaim(overrides: Partial = {}): ClaimV1 { + return { + ...pendingClaim(), + authority: { mode: 'git-ref', remoteRevision: '1' }, + state: 'active', + lastOperationId: id(10), + ...overrides, + }; +} + +function draftHandoff(overrides: Partial = {}): HandoffV1 { + const currentBundle = bundle(metadata()); + return { + schemaVersion: 1, + handoffId: HANDOFF_ID, + taskRef: TASK_REF, + taskRevision: 7, + ownershipEpochAtOffer: 1, + state: 'draft', + revision: 1, + fromActorId: OWNER_ID, + toActorId: RECEIVER_ID, + claimIds: [CLAIM_ID], + checkpointRef: { + taskRef: TASK_REF, + kind: 'checkpoint', + artifactId: CHECKPOINT_ID, + }, + summary: { + completed: [], + inProgress: ['Remote coordination domain layer'], + notStarted: [], + changedFiles: ['src/team/git-ref-coordination.ts'], + verification: [], + blockers: [], + risks: [], + nextAction: 'Accept the remote handoff from a fresh clone.', + }, + transport: { + mode: 'git-ref', + state: 'stale', + transportRevision: null, + publishedAt: null, + fetchedAt: null, + taskBundleDigest: currentBundle.bundleDigest, + codeRef: currentBundle.codeRef, + codeReachable: true, + receipt: null, + }, + lastOperationId: null, + offeredAt: null, + resolution: null, + createdAt: '2026-07-18T09:00:00.000Z', + updatedAt: '2026-07-18T09:00:00.000Z', + ...overrides, + }; +} + +function offeredHandoff(): HandoffV1 { + const currentBundle = bundle(metadata()); + return { + ...draftHandoff(), + state: 'offered', + revision: 2, + transport: { + mode: 'git-ref', + state: 'published', + transportRevision: 1, + publishedAt: '2026-07-18T09:00:00.000Z', + fetchedAt: null, + taskBundleDigest: currentBundle.bundleDigest, + codeRef: currentBundle.codeRef, + codeReachable: true, + receipt: `git-ref-revision:1:${id(10)}`, + }, + lastOperationId: id(10), + offeredAt: '2026-07-18T09:00:00.000Z', + }; +} + +function cancelledHandoff(): HandoffV1 { + return { + ...offeredHandoff(), + state: 'cancelled', + revision: 3, + resolution: { + state: 'cancelled', + actorId: OWNER_ID, + at: '2026-07-18T09:30:00.000Z', + reason: null, + }, + }; +} + +function id(value: number): string { + return `01JZ4B6W5Z0A1B2C3D4E5F${value.toString().padStart(4, '0')}`; +} + +function actorProfile( + actorId: string, + displayName: string, +): SharedActorProfileV1 { + return { + schemaVersion: 1 as const, + actorId, + displayName, + joinedAt: '2026-07-18T08:00:00.000Z', + updatedAt: '2026-07-18T08:00:00.000Z', + }; +} + +async function requirePulledManifest( + store: GitRefTeamManifestStore, +): Promise { + const manifest = (await store.pull()).manifest; + if (manifest === null) throw new Error('missing git-ref manifest'); + return manifest; +} + +async function createRemoteFixture(): Promise<{ + clone: string; + head: string; +}> { + const root = await mkdtemp(path.join(tmpdir(), 'mancode-git-ref-domain-')); + temporaryRoots.push(root); + const remote = path.join(root, 'remote.git'); + const clone = path.join(root, 'clone'); + await execFile('git', ['init', '--bare', remote]); + await execFile('git', ['clone', remote, clone]); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: clone, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: clone }); + await writeFile(path.join(clone, 'README.md'), '# git-ref domain fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: clone }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: clone }); + await execFile('git', ['branch', '-M', 'main'], { cwd: clone }); + await execFile('git', ['push', '-u', 'origin', 'main'], { cwd: clone }); + const { stdout } = await execFile('git', ['rev-parse', 'HEAD'], { + cwd: clone, + }); + return { clone, head: stdout.trim() }; +} diff --git a/tests/git-ref-cross-clone-e2e.test.ts b/tests/git-ref-cross-clone-e2e.test.ts new file mode 100644 index 0000000..13c7d4e --- /dev/null +++ b/tests/git-ref-cross-clone-e2e.test.ts @@ -0,0 +1,604 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { teamSyncPull } from '../src/commands/team.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { createV3Checkpoint } from '../src/context/checkpoint-create.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { formatTaskRef } from '../src/context/task-ref.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { + ensureProjectRuntimeContext, + readCheckoutBranch, +} from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { + type SharedActorProfileV1, + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, +} from '../src/team/actor.js'; +import { createGitRefTaskBundle } from '../src/team/git-ref-bundle.js'; +import { createGitRefTeamManifestStore } from '../src/team/git-ref-client.js'; +import { prepareGitRefCoordinationMutation } from '../src/team/git-ref-coordination.js'; +import { + acceptGitRefHandoffWithRepair, + recoverGitRefHandoffRepairs, +} from '../src/team/git-ref-handoff-repair.js'; +import { + acquireGitRefClaim, + createGitRefHandoffDraft, + mutateGitRefHandoff, + syncGitRefTask, +} from '../src/team/git-ref-operation.js'; +import type { + GitRefOwnershipFenceV1, + GitRefTaskBundleV1, + GitRefTeamManifestStore, +} from '../src/team/git-ref-transport.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-18T10:00:00.000Z'); +const WORKSPACE_ID = id(1); +const SCHEMA_EPOCH = id(2); +const ACTOR_A = id(3); +const ACTOR_B = id(4); +const SESSION_A = id(5); +const SESSION_B = id(6); +const TASK_ID = id(7); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('git-ref coordination across independent clones', () => { + it('fences claims, materializes a quarantined bundle, and transfers ownership once', async () => { + const fixture = await createFixture(); + const created = await createRemoteTask(fixture.cloneA, fixture.codeHead); + const storeA = await strictStore(fixture.cloneA); + const storeB = await strictStore(fixture.cloneB); + const initialBundle = await taskBundle(fixture.cloneA); + const establishOperationId = id(20); + const initialFence = ownershipFence( + initialBundle, + ACTOR_A, + 1, + establishOperationId, + ); + + await expect( + storeA.establishCoordinationAuthority({ + operationId: establishOperationId, + actorId: ACTOR_A, + expectedRemoteRevision: 0, + expectedPriorTransportEpoch: null, + targetTransportEpoch: 2, + actorProfiles: fixture.profiles, + ownershipFences: [initialFence], + claims: [], + handoffs: [], + taskBundles: [initialBundle], + }), + ).resolves.toMatchObject({ remoteRevision: 1, transportEpoch: 2 }); + + const claim = await acquireGitRefClaim({ + projectRoot: fixture.cloneA, + taskRef: created.taskRef, + sessionId: SESSION_A, + expectedTaskRevision: created.metadata.revision, + scope: scope('src/auth/**'), + claimId: id(21), + operationId: id(22), + now: at(1), + }); + expect(claim).toMatchObject({ + remoteRevision: 2, + ownershipEpoch: 1, + claim: { state: 'active', ownerActorId: ACTOR_A }, + }); + + const unreachablePull = await captureJson(() => + teamSyncPull(fixture.cloneB, { + task: formatTaskRef(created.taskRef), + json: true, + }), + ); + expect(unreachablePull.exitCode).toBe(0); + expect(unreachablePull.value).toMatchObject({ + remoteRevision: 2, + materializedBundles: [ + { + taskRef: created.taskRef, + codeReachable: false, + status: 'quarantined', + quarantinePath: expect.stringContaining( + initialBundle.bundleDigest.slice(7), + ), + }, + ], + }); + await expect( + readFile( + String(unreachablePull.value.materializedBundles[0].quarantinePath), + 'utf8', + ), + ).resolves.toContain(initialBundle.bundleDigest); + await expect( + new V3ContextStore(fixture.cloneB).readTaskSnapshot(created.taskRef), + ).rejects.toThrow('MANCODE_TASK_NOT_FOUND'); + + await git(fixture.cloneA, ['push', 'origin', 'main']); + await git(fixture.cloneB, ['fetch', 'origin']); + const reachablePull = await captureJson(() => + teamSyncPull(fixture.cloneB, { + task: formatTaskRef(created.taskRef), + json: true, + }), + ); + expect(reachablePull.exitCode).toBe(0); + expect(reachablePull.value).toMatchObject({ + materializedBundles: [ + { + taskRef: created.taskRef, + codeReachable: true, + status: 'created', + aggregateDigest: initialBundle.aggregateDigest, + }, + ], + }); + await expect( + new V3ContextStore(fixture.cloneB).readTaskSnapshot(created.taskRef), + ).resolves.toMatchObject({ + metadata: { revision: initialBundle.taskRevision }, + aggregate: initialBundle.aggregate, + }); + await git(fixture.cloneB, ['merge', '--ff-only', 'origin/main']); + + await expect( + acquireGitRefClaim({ + projectRoot: fixture.cloneB, + taskRef: created.taskRef, + sessionId: SESSION_B, + expectedTaskRevision: created.metadata.revision, + scope: scope('src/auth/**'), + claimId: id(23), + operationId: id(24), + now: at(2), + }), + ).rejects.toThrow('MANCODE_SCOPE_CONFLICT'); + + await writeFile(path.join(fixture.cloneA, 'README.md'), '# dirty\n'); + await expect( + createGitRefHandoffDraft({ + projectRoot: fixture.cloneA, + taskRef: created.taskRef, + sessionId: SESSION_A, + expectedTaskRevision: created.metadata.revision, + toActorId: ACTOR_B, + handoffId: id(25), + operationId: id(26), + now: at(3), + }), + ).rejects.toThrow('MANCODE_HANDOFF_DIRTY_WORKTREE'); + await expect(storeB.pull()).resolves.toMatchObject({ + manifest: { revision: 2, handoffs: [] }, + }); + await writeFile( + path.join(fixture.cloneA, 'README.md'), + '# git-ref cross-clone fixture\n\ncode head\n', + ); + + const drafted = await createGitRefHandoffDraft({ + projectRoot: fixture.cloneA, + taskRef: created.taskRef, + sessionId: SESSION_A, + expectedTaskRevision: created.metadata.revision, + toActorId: ACTOR_B, + handoffId: id(27), + operationId: id(28), + now: at(4), + }); + expect(drafted).toMatchObject({ + remoteRevision: 3, + handoff: { state: 'draft', revision: 1 }, + }); + const offered = await mutateGitRefHandoff({ + projectRoot: fixture.cloneA, + handoffId: drafted.handoff.handoffId, + sessionId: SESSION_A, + expectedHandoffRevision: 1, + mutation: { kind: 'offer' }, + operationId: id(29), + now: at(5), + }); + expect(offered).toMatchObject({ + remoteRevision: 4, + handoff: { state: 'offered', revision: 2 }, + }); + + const offeredPull = await captureJson(() => + teamSyncPull(fixture.cloneB, { + task: formatTaskRef(created.taskRef), + json: true, + }), + ); + expect(offeredPull.exitCode).toBe(0); + expect(offeredPull.value).toMatchObject({ + remoteRevision: 4, + handoffs: [{ handoffId: drafted.handoff.handoffId, state: 'offered' }], + materializedBundles: [{ status: 'unchanged' }], + }); + + const beforeAccept = await storeA.pull(); + const beforeAcceptManifest = beforeAccept.manifest; + if (beforeAcceptManifest === null) + throw new Error('missing remote manifest'); + const staleOwnerMutation = prepareGitRefCoordinationMutation( + beforeAcceptManifest, + { + kind: 'claim_renew', + operationId: id(30), + actorId: ACTOR_A, + taskRef: created.taskRef, + expectedRemoteRevision: beforeAcceptManifest.revision, + expectedOwnershipEpoch: 1, + claimId: claim.claim.claimId, + expectedClaimRevision: 1, + expiresAt: at(2 * 24 * 60).toISOString(), + now: at(6), + }, + ); + let preparedRemoteRevision: number | null = null; + const accepted = await acceptGitRefHandoffWithRepair({ + projectRoot: fixture.cloneB, + handoffId: drafted.handoff.handoffId, + sessionId: SESSION_B, + expectedHandoffRevision: 2, + operationId: id(31), + now: at(7), + beforeRemoteCommit: (prepared) => { + preparedRemoteRevision = prepared.targetRemoteRevision; + }, + }); + expect(preparedRemoteRevision).toBe(5); + expect(accepted).toMatchObject({ + remoteRevision: 5, + ownershipEpoch: 2, + handoff: { state: 'accepted', revision: 3 }, + taskBundle: { + taskRevision: initialBundle.taskRevision + 2, + ownershipEpoch: 2, + }, + forwardRepair: { remoteRevision: 5, ownerActorId: ACTOR_B }, + }); + + const committed = await storeB.pull(); + expect(committed).toMatchObject({ + manifest: { + revision: 5, + ownershipFences: [ + { ownerActorId: ACTOR_B, ownershipEpoch: 2, remoteRevision: 5 }, + ], + receipts: expect.arrayContaining([ + expect.objectContaining({ + operationId: id(31), + remoteRevision: 5, + ownershipEpoch: 2, + }), + ]), + }, + }); + await expect( + new V3ContextStore(fixture.cloneB).readTaskSnapshot(created.taskRef), + ).resolves.toMatchObject({ + metadata: { + ownerActorId: ACTOR_B, + ownershipEpoch: 2, + revision: initialBundle.taskRevision + 2, + transitionState: 'stable', + }, + }); + await expect(recoverGitRefHandoffRepairs(fixture.cloneB)).resolves.toEqual( + [], + ); + + await expect(storeA.mutateCoordination(staleOwnerMutation)).rejects.toThrow( + 'MANCODE_TRANSPORT_REVISION_CONFLICT', + ); + await expect( + syncGitRefTask({ + projectRoot: fixture.cloneA, + taskRef: created.taskRef, + sessionId: SESSION_A, + expectedTaskRevision: initialBundle.taskRevision, + operationId: id(32), + now: at(8), + }), + ).rejects.toThrow('MANCODE_TASK_OWNER_REQUIRED'); + await expect(storeA.pull()).resolves.toMatchObject({ + manifest: { revision: 5, ownershipFences: [{ ownerActorId: ACTOR_B }] }, + }); + + const concurrent = await Promise.allSettled([ + storeA.publishActorProfile({ + operationId: id(33), + expectedRemoteRevision: 5, + profile: profile(id(34), 'Concurrent A'), + }), + storeB.publishActorProfile({ + operationId: id(35), + expectedRemoteRevision: 5, + profile: profile(id(36), 'Concurrent B'), + }), + ]); + expect( + concurrent.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + const rejected = concurrent.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + expect(rejected?.reason).toBeInstanceOf(Error); + expect((rejected?.reason as Error).message).toMatch( + /MANCODE_TRANSPORT_(CAS|REVISION)_CONFLICT/, + ); + await expect(storeA.pull()).resolves.toMatchObject({ + manifest: { revision: 6 }, + }); + }, 20_000); +}); + +interface Fixture { + cloneA: string; + cloneB: string; + codeHead: string; + profiles: SharedActorProfileV1[]; +} + +async function createFixture(): Promise { + const root = await mkdtemp( + path.join(os.tmpdir(), 'mancode-git-ref-cross-clone-'), + ); + roots.push(root); + const remote = path.join(root, 'remote.git'); + const cloneA = path.join(root, 'clone-a'); + const cloneB = path.join(root, 'clone-b'); + await execFile('git', ['init', '--bare', remote]); + await execFile('git', ['clone', remote, cloneA]); + await configureGit(cloneA); + await writeFile( + path.join(cloneA, 'README.md'), + '# git-ref cross-clone fixture\n', + ); + await writeFile(path.join(cloneA, '.gitignore'), '.mancode/\n'); + await git(cloneA, ['add', 'README.md', '.gitignore']); + await git(cloneA, ['commit', '-m', 'fixture baseline']); + await git(cloneA, ['branch', '-M', 'main']); + + await initializeV3Project({ + projectRoot: cloneA, + operationId: id(10), + workspaceId: WORKSPACE_ID, + schemaEpoch: SCHEMA_EPOCH, + now: NOW, + }); + const localA = await createLocalActor(cloneA, { + actorId: ACTOR_A, + displayName: 'Actor A', + now: NOW, + }); + const profileA = createSharedActorProfile(localA, NOW); + const profileB = profile(ACTOR_B, 'Actor B'); + await Promise.all([ + publishSharedActorProfile(cloneA, profileA), + publishSharedActorProfile(cloneA, profileB), + ]); + await createSession(cloneA, { + actorId: ACTOR_A, + sessionId: SESSION_A, + client: 'vitest-a', + identitySource: 'explicit', + now: NOW, + }); + await setGitRefConfig(cloneA); + await git(cloneA, [ + 'add', + '--force', + '.mancode/schema.json', + '.mancode/shared', + ]); + await git(cloneA, ['commit', '-m', 'configure shared git-ref authority']); + await git(cloneA, ['push', '-u', 'origin', 'main']); + + await execFile('git', ['clone', '--branch', 'main', remote, cloneB]); + await configureGit(cloneB); + await ensureProjectRuntimeContext(cloneB, NOW); + await createLocalActor(cloneB, { + actorId: ACTOR_B, + displayName: 'Actor B', + now: NOW, + }); + await createSession(cloneB, { + actorId: ACTOR_B, + sessionId: SESSION_B, + client: 'vitest-b', + identitySource: 'explicit', + now: NOW, + }); + + await writeFile( + path.join(cloneA, 'README.md'), + '# git-ref cross-clone fixture\n\ncode head\n', + ); + await git(cloneA, ['add', 'README.md']); + await git(cloneA, ['commit', '-m', 'unpublished task code']); + const codeHead = await gitOutput(cloneA, ['rev-parse', 'HEAD']); + return { cloneA, cloneB, codeHead, profiles: [profileA, profileB] }; +} + +async function createRemoteTask(projectRoot: string, codeHead: string) { + const created = await createV3Workflow({ + projectRoot, + task: 'Coordinate authentication boundary changes across clones.', + workflowMode: 'manteam', + sessionId: SESSION_A, + client: 'vitest-a', + sharedPrivacyConfirmed: true, + participantActorIds: [ACTOR_B], + implementationScope: { include: ['src/**', 'tests/**'] }, + taskId: TASK_ID, + operationId: id(11), + now: NOW, + }); + const checkpoint = await createV3Checkpoint({ + projectRoot, + taskRef: created.taskRef, + sessionId: SESSION_A, + expectedTaskRevision: created.metadata.revision, + kind: 'diagnostic_started', + summary: 'Remote handoff checkpoint.', + nextAction: 'Transfer the task to Actor B.', + checkpointId: id(12), + operationId: id(13), + now: at(1), + }); + expect(await gitOutput(projectRoot, ['rev-parse', 'HEAD'])).toBe(codeHead); + return { ...created, metadata: checkpoint.metadata }; +} + +async function strictStore( + projectRoot: string, +): Promise { + const project = await new V3ContextStore(projectRoot).readProjectSnapshot(); + return createGitRefTeamManifestStore( + projectRoot, + project.config, + project.manifest, + ); +} + +async function taskBundle(projectRoot: string): Promise { + const task = await new V3ContextStore(projectRoot).readTaskSnapshot({ + namespace: 'shared', + taskId: TASK_ID, + }); + return createGitRefTaskBundle({ + task, + codeRef: { + branch: (await readCheckoutBranch(projectRoot)) ?? 'HEAD', + head: await gitOutput(projectRoot, ['rev-parse', 'HEAD']), + }, + now: NOW, + }); +} + +function ownershipFence( + bundle: GitRefTaskBundleV1, + ownerActorId: Ulid, + remoteRevision: number, + operationId: Ulid, +): GitRefOwnershipFenceV1 { + return { + schemaVersion: 1, + taskRef: bundle.taskRef, + ownerActorId, + ownershipEpoch: bundle.ownershipEpoch, + taskRevision: bundle.taskRevision, + aggregateDigest: bundle.aggregateDigest, + remoteRevision, + lastOperationId: operationId, + updatedAt: NOW.toISOString(), + }; +} + +async function setGitRefConfig(projectRoot: string): Promise { + const target = path.join(projectRoot, '.mancode', 'shared', 'config.json'); + const config = JSON.parse(await readFile(target, 'utf8')) as { + revision: number; + transport: { mode: string; remote: string | null; epoch: number }; + lastOperationId: string | null; + updatedAt: string; + }; + await writeFile( + target, + `${JSON.stringify( + { + ...config, + revision: config.revision + 1, + transport: { mode: 'git-ref', remote: 'origin', epoch: 2 }, + lastOperationId: id(14), + updatedAt: NOW.toISOString(), + }, + null, + 2, + )}\n`, + ); +} + +async function captureJson( + action: () => Promise, +): Promise<{ exitCode: number; value: SyncPullJson }> { + const writes: string[] = []; + const previous = console.log; + console.log = (value: unknown) => writes.push(String(value)); + try { + const exitCode = await action(); + return { + exitCode, + value: JSON.parse(writes.at(-1) ?? '{}') as SyncPullJson, + }; + } finally { + console.log = previous; + } +} + +interface SyncPullJson extends Record { + materializedBundles: Array< + Record & { quarantinePath?: string } + >; +} + +function scope(pathPattern: string) { + return { paths: [pathPattern], modules: [], apis: [], schemas: [] }; +} + +function profile(actorId: Ulid, displayName: string): SharedActorProfileV1 { + return { + schemaVersion: 1, + actorId, + displayName, + joinedAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }; +} + +async function configureGit(projectRoot: string): Promise { + await git(projectRoot, ['config', 'user.email', 'vitest@example.test']); + await git(projectRoot, ['config', 'user.name', 'Vitest']); +} + +async function git(projectRoot: string, args: string[]): Promise { + await execFile('git', args, { cwd: projectRoot }); +} + +async function gitOutput(projectRoot: string, args: string[]): Promise { + const { stdout } = await execFile('git', args, { cwd: projectRoot }); + return stdout.trim(); +} + +function at(minutes: number): Date { + return new Date(NOW.getTime() + minutes * 60_000); +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-18T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/git-ref-handoff-repair-contracts.test.ts b/tests/git-ref-handoff-repair-contracts.test.ts new file mode 100644 index 0000000..d8edf1b --- /dev/null +++ b/tests/git-ref-handoff-repair-contracts.test.ts @@ -0,0 +1,428 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { buildTaskAggregateManifest } from '../src/context/aggregate.js'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { parseWorkflowMetadata } from '../src/context/workflow-metadata.js'; +import { resolveCoordinationEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { readTaskHeadFence } from '../src/runtime/task-head-store.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, +} from '../src/team/actor.js'; +import { createGitRefTaskBundle } from '../src/team/git-ref-bundle.js'; +import { + acceptGitRefHandoffWithRepair, + recoverGitRefHandoffRepair, + recoverGitRefHandoffRepairs, +} from '../src/team/git-ref-handoff-repair.js'; +import type { + AcceptGitRefHandoffInput, + PreparedGitRefHandoffAcceptV1, +} from '../src/team/git-ref-operation.js'; +import type { + GitRefRemoteMutationReceiptV1, + GitRefTaskBundleV1, + GitRefTeamManifestSnapshot, +} from '../src/team/git-ref-transport.js'; + +const mocks = vi.hoisted(() => ({ + accept: vi.fn(), + pull: vi.fn(), +})); + +vi.mock('../src/team/git-ref-operation.js', () => ({ + acceptGitRefHandoff: mocks.accept, +})); + +vi.mock('../src/team/git-ref-client.js', () => ({ + createGitRefTeamManifestStore: () => ({ pull: mocks.pull }), +})); + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-18T10:00:00.000Z'); +const WORKSPACE_ID = id(1); +const OWNER_ID = id(2); +const RECEIVER_ID = id(3); +const OWNER_SESSION_ID = id(4); +const TASK_ID = id(5); +const HANDOFF_ID = id(6); +const OPERATION_ID = id(7); +const PREDECESSOR_CLAIM_ID = id(8); +const SUCCESSOR_CLAIM_ID = id(9); +const roots: string[] = []; + +beforeEach(() => { + mocks.accept.mockReset(); + mocks.pull.mockReset(); +}); + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('git-ref handoff external-commit repair', () => { + it('leaves no journal or metadata change when accept crashes before prepare', async () => { + const fixture = await bootstrap(); + const before = await readMetadata(fixture.root); + mocks.accept.mockRejectedValueOnce(new Error('crash before prepare')); + + await expect( + acceptGitRefHandoffWithRepair(acceptInput(fixture.root)), + ).rejects.toThrow('crash before prepare'); + + expect(await readMetadata(fixture.root)).toEqual(before); + await expect( + readFile(journalPath(fixture.root), 'utf8'), + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(mocks.pull).not.toHaveBeenCalled(); + }); + + it('restores metadata and aborts the journal when remote CAS did not commit', async () => { + const fixture = await bootstrap(); + mocks.pull.mockResolvedValue(remoteSnapshot(fixture.prepared, null)); + mocks.accept.mockImplementationOnce( + async (input: AcceptGitRefHandoffInput) => { + await input.beforeRemoteCommit?.(fixture.prepared); + expect(await readMetadata(fixture.root)).toMatchObject({ + revision: fixture.predecessor.taskRevision + 1, + transitionState: 'operation_pending', + lastOperationId: OPERATION_ID, + }); + throw new Error('remote CAS rejected'); + }, + ); + + await expect( + acceptGitRefHandoffWithRepair(acceptInput(fixture.root)), + ).rejects.toThrow('remote CAS rejected'); + + expect(await readMetadata(fixture.root)).toEqual(fixture.originalMetadata); + expect(await readJournal(fixture.root)).toMatchObject({ + operationId: OPERATION_ID, + state: 'aborted', + remoteReceipt: null, + transportReceipt: null, + }); + await expect( + recoverGitRefHandoffRepair(fixture.root, OPERATION_ID, null), + ).resolves.toMatchObject({ state: 'aborted' }); + }); + + it('uses the durable receipt to converge after a committed CAS response is lost', async () => { + const fixture = await bootstrap(); + const receipt = committedReceipt(fixture.prepared); + mocks.accept.mockImplementationOnce( + async (input: AcceptGitRefHandoffInput) => { + await input.beforeRemoteCommit?.(fixture.prepared); + mocks.pull.mockResolvedValue(remoteSnapshot(fixture.prepared, receipt)); + throw new Error('response lost after remote commit'); + }, + ); + + await expect( + acceptGitRefHandoffWithRepair(acceptInput(fixture.root)), + ).rejects.toThrow('response lost after remote commit'); + + const snapshot = await new V3ContextStore(fixture.root).readTaskSnapshot( + fixture.target.taskRef, + ); + expect(snapshot.metadata).toMatchObject({ + revision: fixture.target.taskRevision, + transitionState: 'stable', + lastOperationId: OPERATION_ID, + ownerActorId: RECEIVER_ID, + ownershipEpoch: fixture.target.ownershipEpoch, + }); + expect(snapshot.aggregate).toEqual(fixture.target.aggregate); + expect(await readJournal(fixture.root)).toMatchObject({ + state: 'committed', + remoteReceipt: receipt, + transportReceipt: 'git-ref:committed:test', + }); + const runtime = await readProjectRuntimeContext(fixture.root); + const fence = await readTaskHeadFence( + resolveCoordinationEntityHomeStore(runtime.entityHomeStoreContext), + fixture.target.taskRef, + ); + expect(fence).toMatchObject({ + taskRevision: fixture.target.taskRevision, + aggregateDigest: fixture.target.aggregateDigest, + ownershipEpoch: fixture.target.ownershipEpoch, + remoteRevision: fixture.prepared.targetRemoteRevision, + lastOperationId: OPERATION_ID, + }); + }); + + it('treats committed recovery as idempotent', async () => { + const fixture = await bootstrap(); + const receipt = committedReceipt(fixture.prepared); + mocks.accept.mockImplementationOnce( + async (input: AcceptGitRefHandoffInput) => { + await input.beforeRemoteCommit?.(fixture.prepared); + mocks.pull.mockResolvedValue(remoteSnapshot(fixture.prepared, receipt)); + throw new Error('response lost after remote commit'); + }, + ); + await expect( + acceptGitRefHandoffWithRepair(acceptInput(fixture.root)), + ).rejects.toThrow('response lost after remote commit'); + const metadataBefore = await readFile(metadataPath(fixture.root), 'utf8'); + const journalBefore = await readFile(journalPath(fixture.root), 'utf8'); + + await expect( + recoverGitRefHandoffRepair( + fixture.root, + OPERATION_ID, + 'git-ref:ignored:duplicate', + ), + ).resolves.toMatchObject({ state: 'committed' }); + await expect(recoverGitRefHandoffRepairs(fixture.root)).resolves.toEqual( + [], + ); + + expect(await readFile(metadataPath(fixture.root), 'utf8')).toBe( + metadataBefore, + ); + expect(await readFile(journalPath(fixture.root), 'utf8')).toBe( + journalBefore, + ); + }); +}); + +async function bootstrap(): Promise<{ + root: string; + originalMetadata: Awaited>; + predecessor: GitRefTaskBundleV1; + target: GitRefTaskBundleV1; + prepared: PreparedGitRefHandoffAcceptV1; +}> { + const root = path.join( + tmpdir(), + `mancode-git-ref-handoff-repair-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`, + ); + roots.push(root); + await mkdir(root, { recursive: true }); + await execFile('git', ['init'], { cwd: root }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: root, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: root }); + await writeFile(path.join(root, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: root }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: root }); + await initializeV3Project({ + projectRoot: root, + operationId: id(20), + workspaceId: WORKSPACE_ID, + schemaEpoch: id(21), + now: NOW, + }); + const owner = await createLocalActor(root, { + actorId: OWNER_ID, + displayName: 'Current owner', + now: NOW, + }); + await publishSharedActorProfile(root, createSharedActorProfile(owner, NOW)); + await publishSharedActorProfile(root, { + schemaVersion: 1, + actorId: RECEIVER_ID, + displayName: 'Receiving owner', + joinedAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }); + await createSession(root, { + actorId: OWNER_ID, + sessionId: OWNER_SESSION_ID, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + await createV3Workflow({ + projectRoot: root, + task: 'Recover accepted remote ownership handoff', + workflowMode: 'manteam', + sessionId: OWNER_SESSION_ID, + client: 'vitest', + participantActorIds: [RECEIVER_ID], + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/**'] }, + taskId: TASK_ID, + operationId: id(22), + now: NOW, + }); + const snapshot = await new V3ContextStore(root).readTaskSnapshot({ + namespace: 'shared', + taskId: TASK_ID, + }); + const originalMetadata = snapshot.metadata; + const { stdout } = await execFile('git', ['rev-parse', 'HEAD'], { + cwd: root, + }); + const codeRef = { branch: 'main', head: stdout.trim() }; + const predecessor = createGitRefTaskBundle({ + task: snapshot, + codeRef, + now: NOW, + }); + const targetMetadata = parseWorkflowMetadata({ + ...snapshot.metadata, + revision: snapshot.metadata.revision + 2, + transitionState: 'stable', + lastOperationId: OPERATION_ID, + ownerActorId: RECEIVER_ID, + ownershipEpoch: snapshot.metadata.ownershipEpoch + 1, + updatedAt: '2026-07-18T10:01:00.000Z', + }); + const targetAggregate = buildTaskAggregateManifest({ + metadata: targetMetadata, + requirements: snapshot.requirements, + review: snapshot.review, + verification: snapshot.verification, + planDigest: snapshot.plan?.digest ?? null, + latestCheckpoint: snapshot.latestCheckpoint, + }); + const target = createGitRefTaskBundle({ + task: { ...snapshot, metadata: targetMetadata, aggregate: targetAggregate }, + codeRef, + now: new Date('2026-07-18T10:01:00.000Z'), + }); + const prepared: PreparedGitRefHandoffAcceptV1 = { + schemaVersion: 1, + operationId: OPERATION_ID, + expectedRemoteRevision: 10, + expectedOwnershipEpoch: predecessor.ownershipEpoch, + targetRemoteRevision: 11, + targetOwnershipEpoch: target.ownershipEpoch, + predecessorBundle: predecessor, + targetBundle: target, + forwardRepair: { + schemaVersion: 1, + operationId: OPERATION_ID, + taskRef: target.taskRef, + ownerActorId: RECEIVER_ID, + ownershipEpoch: target.ownershipEpoch, + taskRevision: target.taskRevision, + aggregateDigest: target.aggregateDigest, + handoffId: HANDOFF_ID, + predecessorClaimIds: [PREDECESSOR_CLAIM_ID], + successorClaimIds: [SUCCESSOR_CLAIM_ID], + bundleDigest: target.bundleDigest, + remoteRevision: 11, + }, + }; + return { root, originalMetadata, predecessor, target, prepared }; +} + +function acceptInput(projectRoot: string): AcceptGitRefHandoffInput { + return { + projectRoot, + handoffId: HANDOFF_ID, + sessionId: OWNER_SESSION_ID, + expectedHandoffRevision: 2, + operationId: OPERATION_ID, + now: NOW, + }; +} + +function committedReceipt( + prepared: PreparedGitRefHandoffAcceptV1, +): GitRefRemoteMutationReceiptV1 { + const emptyDigest = digestCanonicalJson([]); + return { + schemaVersion: 1, + kind: 'coordination', + operationId: prepared.operationId, + actorId: RECEIVER_ID, + taskRef: prepared.targetBundle.taskRef, + remoteRevision: prepared.targetRemoteRevision, + ownershipEpoch: prepared.targetOwnershipEpoch, + entityDigests: { + actorProfiles: emptyDigest, + ownershipFence: emptyDigest, + claims: emptyDigest, + handoffs: emptyDigest, + taskBundle: digestCanonicalJson(prepared.targetBundle), + }, + committedAt: '2026-07-18T10:01:00.000Z', + }; +} + +function remoteSnapshot( + prepared: PreparedGitRefHandoffAcceptV1, + receipt: GitRefRemoteMutationReceiptV1 | null, +): GitRefTeamManifestSnapshot { + return { + manifest: { + revision: + receipt === null + ? prepared.expectedRemoteRevision + : prepared.targetRemoteRevision, + receipts: receipt === null ? [] : [receipt], + } as GitRefTeamManifestSnapshot['manifest'], + commit: receipt === null ? null : 'a'.repeat(40), + receipt: receipt === null ? null : 'git-ref:committed:test', + fetchedAt: '2026-07-18T10:01:00.000Z', + }; +} + +async function readMetadata(projectRoot: string) { + return ( + await new V3ContextStore(projectRoot).readTaskSnapshot({ + namespace: 'shared', + taskId: TASK_ID, + }) + ).metadata; +} + +async function readJournal( + projectRoot: string, +): Promise> { + return JSON.parse(await readFile(journalPath(projectRoot), 'utf8')) as Record< + string, + unknown + >; +} + +function metadataPath(projectRoot: string): string { + return path.join( + projectRoot, + '.mancode', + 'shared', + 'workflows', + TASK_ID, + 'metadata.json', + ); +} + +function journalPath(projectRoot: string): string { + return path.join( + projectRoot, + '.mancode', + 'local', + 'journals', + 'git-ref-handoff', + `${OPERATION_ID}.json`, + ); +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-18T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/git-ref-manifest-authority-contracts.test.ts b/tests/git-ref-manifest-authority-contracts.test.ts new file mode 100644 index 0000000..205f896 --- /dev/null +++ b/tests/git-ref-manifest-authority-contracts.test.ts @@ -0,0 +1,445 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import type { SharedActorProfileV1 } from '../src/team/actor.js'; +import { + GitRefTeamManifestStore, + type GitRefTeamManifestStoreOptions, + parseGitRefTaskBundle, + parseGitRefTeamManifest, + resolveGitRefRemoteIdentityHash, +} from '../src/team/git-ref-transport.js'; + +const execFile = promisify(execFileCallback); +const WORKSPACE_ID = id(1); +const SCHEMA_EPOCH = id(2); +const ACTOR_ID = id(3); +const TASK_ID = id(4); +const NOW = '2026-07-18T10:00:00.000Z'; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('git-ref remote manifest contracts', () => { + it('only upgrades the exact legacy profile shape and enforces size/count/schema gates', () => { + const legacy = { + schemaVersion: 1, + workspaceId: WORKSPACE_ID, + revision: 1, + lastOperationId: id(10), + actorProfiles: [profile(ACTOR_ID)], + updatedAt: NOW, + }; + const upgraded = parseGitRefTeamManifest(legacy); + expect(upgraded).toMatchObject({ + authorityState: 'active', + authorityTombstone: null, + schemaEpoch: WORKSPACE_ID, + transportEpoch: 1, + ownershipFences: [], + claims: [], + handoffs: [], + taskBundles: [], + }); + expect(() => parseGitRefTeamManifest(upgraded)).toThrow( + 'git-ref manifest receipts require lastMutation', + ); + expect(() => + parseGitRefTeamManifest({ ...legacy, unexpected: true }), + ).toThrow(); + expect(() => + parseGitRefTeamManifest({ + ...legacy, + actorProfiles: Array.from({ length: 257 }, () => profile(ACTOR_ID)), + }), + ).toThrow('exceeds the entity limit'); + expect(() => + parseGitRefTeamManifest({ + ...legacy, + updatedAt: 'x'.repeat(1_000_001), + }), + ).toThrow('MANCODE_TRANSPORT_MANIFEST_TOO_LARGE'); + }); + + it('rejects unsafe paths, private content, and invalid artifact digests before materialization', () => { + expect(() => + parseGitRefTaskBundle( + bundleWithArtifact('../metadata.json', {}, digest({})), + ), + ).toThrow('MANCODE_ARTIFACT_PATH_UNSAFE'); + expect(() => + parseGitRefTaskBundle( + bundleWithArtifact( + 'metadata.json', + 'api_key=do-not-publish', + digest('api_key=do-not-publish'), + ), + ), + ).toThrow('MANCODE_PRIVACY_BLOCKED'); + expect(() => + parseGitRefTaskBundle( + bundleWithArtifact('metadata.json', {}, digest({ different: true })), + ), + ).toThrow('MANCODE_TRANSPORT_BUNDLE_ARTIFACT_DIGEST_MISMATCH'); + expect(() => + parseGitRefTaskBundle( + bundleWithArtifact('nested/metadata.json', {}, digest({})), + ), + ).toThrow('MANCODE_TRANSPORT_BUNDLE_ARTIFACT_PATH_MISMATCH'); + }); + + it('fences task mutations, leaves a read-only tombstone, and establishes a new epoch', async () => { + const fixture = await createFixture(); + const epochOne = store(fixture.clone, 1, 1); + await epochOne.publishActorProfile({ + operationId: id(20), + expectedRemoteRevision: 0, + profile: profile(ACTOR_ID), + }); + await expect( + epochOne.mutateCoordination({ + operationId: id(21), + actorId: ACTOR_ID, + taskRef: taskRef(), + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 0, + ownershipFence: fence(id(21), 2), + claims: [], + handoffs: [], + taskBundle: null, + }), + ).resolves.toMatchObject({ remoteRevision: 2, ownershipEpoch: 0 }); + await expect( + epochOne.mutateCoordination({ + operationId: id(22), + actorId: ACTOR_ID, + taskRef: taskRef(), + expectedRemoteRevision: 1, + expectedOwnershipEpoch: 0, + ownershipFence: fence(id(22), 2), + claims: [], + handoffs: [], + taskBundle: null, + }), + ).rejects.toThrow('MANCODE_TRANSPORT_REVISION_CONFLICT'); + await expect( + epochOne.mutateCoordination({ + operationId: id(23), + actorId: id(99), + taskRef: taskRef(), + expectedRemoteRevision: 2, + expectedOwnershipEpoch: 0, + ownershipFence: { ...fence(id(23), 3), ownerActorId: id(99) }, + claims: [], + handoffs: [], + taskBundle: null, + }), + ).rejects.toThrow('MANCODE_TRANSPORT_ACTOR_NOT_JOINED'); + await expect( + epochOne.freezeCoordinationAuthority({ + operationId: id(24), + actorId: ACTOR_ID, + expectedRemoteRevision: 2, + expectedPriorTransportEpoch: 1, + successorMode: 'git-ref', + successorEpoch: 2, + }), + ).resolves.toMatchObject({ remoteRevision: 3, transportEpoch: 1 }); + await expect( + epochOne.publishActorProfile({ + operationId: id(25), + expectedRemoteRevision: 3, + profile: profile(id(5)), + }), + ).rejects.toThrow('MANCODE_TRANSPORT_AUTHORITY_NOT_ACTIVE'); + await expect( + epochOne.unfreezeCoordinationAuthority({ + operationId: id(25), + actorId: ACTOR_ID, + expectedRemoteRevision: 3, + expectedPriorTransportEpoch: 1, + freezeOperationId: id(24), + }), + ).resolves.toMatchObject({ remoteRevision: 4, transportEpoch: 1 }); + await expect( + epochOne.freezeCoordinationAuthority({ + operationId: id(26), + actorId: ACTOR_ID, + expectedRemoteRevision: 4, + expectedPriorTransportEpoch: 1, + successorMode: 'git-ref', + successorEpoch: 2, + }), + ).resolves.toMatchObject({ remoteRevision: 5, transportEpoch: 1 }); + await expect( + epochOne.tombstoneCoordinationAuthority({ + operationId: id(26), + actorId: ACTOR_ID, + expectedRemoteRevision: 5, + expectedPriorTransportEpoch: 1, + successorMode: 'git-ref', + successorEpoch: 2, + }), + ).resolves.toMatchObject({ remoteRevision: 6, transportEpoch: 1 }); + await expect( + epochOne.tombstoneCoordinationAuthority({ + operationId: id(26), + actorId: ACTOR_ID, + expectedRemoteRevision: 5, + expectedPriorTransportEpoch: 1, + successorMode: 'git-ref', + successorEpoch: 2, + }), + ).resolves.toMatchObject({ remoteRevision: 6, transportEpoch: 1 }); + await expect( + epochOne.publishActorProfile({ + operationId: id(27), + expectedRemoteRevision: 6, + profile: profile(id(5)), + }), + ).rejects.toThrow('MANCODE_TRANSPORT_AUTHORITY_NOT_ACTIVE'); + + const epochTwo = store(fixture.clone, 2, 2); + await expect( + epochTwo.establishCoordinationAuthority({ + operationId: id(28), + actorId: ACTOR_ID, + expectedRemoteRevision: 6, + expectedPriorTransportEpoch: 1, + targetTransportEpoch: 2, + actorProfiles: [profile(ACTOR_ID)], + ownershipFences: [], + claims: [], + handoffs: [], + taskBundles: [], + }), + ).resolves.toMatchObject({ remoteRevision: 7, transportEpoch: 2 }); + await expect(epochTwo.pull()).resolves.toMatchObject({ + manifest: { + authorityState: 'active', + authorityFreeze: null, + authorityTombstone: null, + transportEpoch: 2, + revision: 7, + lastMutation: { kind: 'authority_establish' }, + }, + }); + await epochTwo.freezeCoordinationAuthority({ + operationId: id(29), + actorId: ACTOR_ID, + expectedRemoteRevision: 7, + expectedPriorTransportEpoch: 2, + successorMode: 'local', + successorEpoch: 3, + }); + await epochTwo.tombstoneCoordinationAuthority({ + operationId: id(29), + actorId: ACTOR_ID, + expectedRemoteRevision: 8, + expectedPriorTransportEpoch: 2, + successorMode: 'local', + successorEpoch: 3, + }); + const epochFour = store(fixture.clone, 4, 4); + await expect( + epochFour.establishCoordinationAuthority({ + operationId: id(30), + actorId: ACTOR_ID, + expectedRemoteRevision: 9, + expectedRemoteTransportEpoch: 2, + expectedPriorTransportEpoch: 3, + targetTransportEpoch: 4, + actorProfiles: [profile(ACTOR_ID)], + ownershipFences: [], + claims: [], + handoffs: [], + taskBundles: [], + }), + ).resolves.toMatchObject({ remoteRevision: 10, transportEpoch: 4 }); + }); + + it('derives remote identity from its URL rather than the local alias', async () => { + const fixture = await createFixture(); + await execFile( + 'git', + [ + 'remote', + 'add', + 'credentialed', + 'https://user:password@example.com/team/repo.git', + ], + { cwd: fixture.clone }, + ); + await expect( + Promise.all([ + resolveGitRefRemoteIdentityHash(fixture.clone, 'origin'), + resolveGitRefRemoteIdentityHash(fixture.clone, fixture.remote), + ]), + ).resolves.toSatisfy(([fromAlias, fromUrl]: string[]) => { + return fromAlias === fromUrl && /^sha256:[a-f0-9]{64}$/.test(fromAlias); + }); + await expect( + Promise.all([ + resolveGitRefRemoteIdentityHash(fixture.clone, 'credentialed'), + resolveGitRefRemoteIdentityHash( + fixture.clone, + 'https://example.com/team/repo.git', + ), + ]), + ).resolves.toSatisfy(([credentialed, publicUrl]: string[]) => { + return credentialed === publicUrl; + }); + }); + + it('rejects schema and version header mismatches before remote writes', async () => { + const fixture = await createFixture(); + await store(fixture.clone, 1, 1).publishActorProfile({ + operationId: id(31), + expectedRemoteRevision: 0, + profile: profile(ACTOR_ID), + }); + + const mismatches: Array< + [ + Partial, + `MANCODE_TRANSPORT_${string}_MISMATCH`, + ] + > = [ + [{ schemaEpoch: id(99) }, 'MANCODE_TRANSPORT_SCHEMA_EPOCH_MISMATCH'], + [ + { minReaderVersion: '0.4.0' }, + 'MANCODE_TRANSPORT_MIN_READER_VERSION_MISMATCH', + ], + [ + { minWriterVersion: '0.4.0' }, + 'MANCODE_TRANSPORT_MIN_WRITER_VERSION_MISMATCH', + ], + ]; + for (const [overrides, errorCode] of mismatches) { + const incompatible = store(fixture.clone, 1, 1, overrides); + await expect(incompatible.pull()).rejects.toThrowError( + new RegExp(`^${errorCode}$`), + ); + await expect( + incompatible.publishActorProfile({ + operationId: id(32), + expectedRemoteRevision: 1, + profile: profile(id(5)), + }), + ).rejects.toThrowError(new RegExp(`^${errorCode}$`)); + } + }); +}); + +function store( + projectRoot: string, + transportEpoch: number, + configRevision: number, + overrides: Partial = {}, +) { + return new GitRefTeamManifestStore({ + projectRoot, + remote: 'origin', + workspaceId: WORKSPACE_ID, + schemaEpoch: SCHEMA_EPOCH, + minReaderVersion: '0.3.9', + minWriterVersion: '0.3.9', + transportEpoch, + configRevision, + configDigest: digest({ configRevision, transportEpoch }), + now: () => new Date(NOW), + ...overrides, + }); +} + +function fence(operationId: string, remoteRevision: number) { + return { + schemaVersion: 1 as const, + taskRef: taskRef(), + ownerActorId: ACTOR_ID, + ownershipEpoch: 0, + taskRevision: 1, + aggregateDigest: digest({ task: TASK_ID }), + remoteRevision, + lastOperationId: operationId, + updatedAt: NOW, + }; +} + +function taskRef() { + return { namespace: 'shared' as const, taskId: TASK_ID }; +} + +function profile(actorId: string): SharedActorProfileV1 { + return { + schemaVersion: 1, + actorId, + displayName: `Actor ${actorId.slice(-4)}`, + joinedAt: NOW, + updatedAt: NOW, + }; +} + +function bundleWithArtifact( + relativePath: string, + content: unknown, + contentDigest: string, +) { + const aggregate = { + taskRef: taskRef(), + taskRevision: 1, + ownershipEpoch: 0, + metadataDigest: digest('metadata'), + requirementsDigest: digest('requirements'), + reviewDigest: digest('review'), + verificationDigest: digest('verification'), + planVersion: 1, + planDigest: null, + latestCheckpointId: null, + latestCheckpointDigest: null, + parentSnapshotDigest: null, + }; + return { + schemaVersion: 1, + taskRef: taskRef(), + taskRevision: 1, + ownershipEpoch: 0, + aggregate, + aggregateDigest: digest(aggregate), + codeRef: { branch: 'main', head: 'a'.repeat(40) }, + artifacts: [{ kind: 'metadata', relativePath, content, contentDigest }], + bundleDigest: digest('bundle'), + createdAt: NOW, + }; +} + +function digest(value: unknown): string { + return digestCanonicalJson(value); +} + +function id(value: number): string { + return `01JZ4B6W5Z0A1B2C3D4E5F${value.toString().padStart(4, '0')}`; +} + +async function createFixture(): Promise<{ + remote: string; + clone: string; +}> { + const root = await mkdtemp( + path.join(os.tmpdir(), 'mancode-git-ref-authority-'), + ); + roots.push(root); + const remote = path.join(root, 'remote.git'); + const clone = path.join(root, 'clone'); + await execFile('git', ['init', '--bare', remote]); + await execFile('git', ['clone', remote, clone]); + return { remote, clone }; +} diff --git a/tests/git-ref-materialization-contracts.test.ts b/tests/git-ref-materialization-contracts.test.ts new file mode 100644 index 0000000..97277a1 --- /dev/null +++ b/tests/git-ref-materialization-contracts.test.ts @@ -0,0 +1,217 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { createV3Checkpoint } from '../src/context/checkpoint-create.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, +} from '../src/team/actor.js'; +import { createGitRefTaskBundle } from '../src/team/git-ref-bundle.js'; +import { materializeGitRefTaskBundle } from '../src/team/git-ref-materialization.js'; +import type { + GitRefOwnershipFenceV1, + GitRefTaskBundleV1, +} from '../src/team/git-ref-transport.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-18T10:00:00.000Z'); +const WORKSPACE_ID = id(1); +const TASK_ID = id(2); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('git-ref task bundle materialization', () => { + it('creates a missing task and advances it only from an exact predecessor', async () => { + const source = await bootstrap('source', id(3), id(4)); + const target = await bootstrap('target', id(5), id(6)); + const created = await createSharedWorkflow(source.root, source.sessionId); + const first = await bundle(source.root); + + await expect( + materializeGitRefTaskBundle({ + projectRoot: target.root, + remoteRevision: 1, + ownershipFence: remoteFence(first, 1, id(7)), + bundle: first, + operationId: id(8), + now: NOW, + }), + ).resolves.toMatchObject({ + status: 'created', + taskRevision: created.metadata.revision, + taskHeadFence: { remoteRevision: 1 }, + }); + await expect( + new V3ContextStore(target.root).readTaskSnapshot(created.taskRef), + ).resolves.toMatchObject({ aggregate: first.aggregate }); + + await createV3Checkpoint({ + projectRoot: source.root, + taskRef: created.taskRef, + sessionId: source.sessionId, + expectedTaskRevision: created.metadata.revision, + kind: 'diagnostic_started', + summary: 'Checkpoint the remote materialization fixture.', + operationId: id(9), + checkpointId: id(10), + now: new Date('2026-07-18T10:01:00.000Z'), + }); + const second = await bundle(source.root); + await expect( + materializeGitRefTaskBundle({ + projectRoot: target.root, + remoteRevision: 2, + ownershipFence: remoteFence(second, 2, id(11)), + bundle: second, + predecessorBundle: first, + operationId: id(12), + now: new Date('2026-07-18T10:01:00.000Z'), + }), + ).resolves.toMatchObject({ + status: 'updated', + taskRevision: second.taskRevision, + taskHeadFence: { remoteRevision: 2 }, + }); + await expect( + new V3ContextStore(target.root).readTaskSnapshot(created.taskRef), + ).resolves.toMatchObject({ + metadata: { revision: second.taskRevision }, + aggregate: second.aggregate, + }); + }); + + it('rejects a divergent local task instead of overwriting it', async () => { + const source = await bootstrap('source', id(20), id(21)); + const target = await bootstrap('target', id(22), id(23)); + await createSharedWorkflow(source.root, source.sessionId, 'Remote task'); + await createSharedWorkflow(target.root, target.sessionId, 'Local fork'); + const remote = await bundle(source.root); + + await expect( + materializeGitRefTaskBundle({ + projectRoot: target.root, + remoteRevision: 1, + ownershipFence: remoteFence(remote, 1, id(24)), + bundle: remote, + operationId: id(25), + now: NOW, + }), + ).rejects.toThrow('MANCODE_SPLIT_BRAIN'); + }); +}); + +async function bootstrap(label: string, actorId: Ulid, sessionId: Ulid) { + const root = path.join( + tmpdir(), + `mancode-git-ref-materialize-${label}-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`, + ); + roots.push(root); + await mkdir(root, { recursive: true }); + await execFile('git', ['init'], { cwd: root }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: root, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: root }); + await writeFile(path.join(root, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: root }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: root }); + await initializeV3Project({ + projectRoot: root, + operationId: id(actorId === id(3) ? 30 : 31), + workspaceId: WORKSPACE_ID, + schemaEpoch: id(32), + now: NOW, + }); + const actor = await createLocalActor(root, { + actorId, + displayName: `Actor ${label}`, + now: NOW, + }); + await publishSharedActorProfile(root, createSharedActorProfile(actor, NOW)); + await createSession(root, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + return { root, sessionId }; +} + +async function createSharedWorkflow( + root: string, + sessionId: Ulid, + task = 'Remote materialization task', +) { + return createV3Workflow({ + projectRoot: root, + task, + workflowMode: 'manteam', + sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/**'] }, + taskId: TASK_ID, + operationId: id(task === 'Local fork' ? 40 : 41), + now: NOW, + }); +} + +async function bundle(root: string): Promise { + const task = await new V3ContextStore(root).readTaskSnapshot({ + namespace: 'shared', + taskId: TASK_ID, + }); + const { stdout } = await execFile('git', ['rev-parse', 'HEAD'], { + cwd: root, + }); + return createGitRefTaskBundle({ + task, + codeRef: { branch: 'main', head: stdout.trim() }, + now: NOW, + }); +} + +function remoteFence( + bundle: GitRefTaskBundleV1, + remoteRevision: number, + operationId: Ulid, +): GitRefOwnershipFenceV1 { + const metadata = bundle.artifacts.find( + (artifact) => artifact.kind === 'metadata', + )?.content as { ownerActorId: Ulid }; + return { + schemaVersion: 1, + taskRef: bundle.taskRef, + ownerActorId: metadata.ownerActorId, + ownershipEpoch: bundle.ownershipEpoch, + taskRevision: bundle.taskRevision, + aggregateDigest: bundle.aggregateDigest, + remoteRevision, + lastOperationId: operationId, + updatedAt: NOW.toISOString(), + }; +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-18T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/git-ref-transport-e2e.test.ts b/tests/git-ref-transport-e2e.test.ts new file mode 100644 index 0000000..a3a8899 --- /dev/null +++ b/tests/git-ref-transport-e2e.test.ts @@ -0,0 +1,221 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { teamJoin } from '../src/commands/team.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { createSession } from '../src/runtime/session.js'; +import type { SharedActorProfileV1 } from '../src/team/actor.js'; +import { createLocalActor } from '../src/team/actor.js'; +import { GitRefTeamManifestStore } from '../src/team/git-ref-transport.js'; + +const execFile = promisify(execFileCallback); +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('git-ref profile transport cross-clone E2E', () => { + it('pulls profiles across clones and rejects a stale revision', async () => { + const fixture = await createFixture(); + const storeA = store(fixture.cloneA); + const storeB = store(fixture.cloneB); + + await expect(storeA.pull()).resolves.toMatchObject({ + manifest: null, + commit: null, + receipt: null, + }); + const first = await storeA.publishActorProfile({ + operationId: id(10), + expectedRemoteRevision: 0, + profile: profile(id(1), 'Alice'), + }); + expect(first).toMatchObject({ + remoteRevision: 1, + receipt: expect.stringMatching(/^git-ref:[0-9a-f]{40}:/), + }); + + await expect(storeB.pull()).resolves.toMatchObject({ + manifest: { + revision: 1, + actorProfiles: [expect.objectContaining({ displayName: 'Alice' })], + }, + receipt: first.receipt, + }); + await expect( + storeB.publishActorProfile({ + operationId: id(11), + expectedRemoteRevision: 1, + profile: profile(id(2), 'Bob'), + }), + ).resolves.toMatchObject({ remoteRevision: 2 }); + await expect( + storeA.publishActorProfile({ + operationId: id(12), + expectedRemoteRevision: 1, + profile: profile(id(3), 'Carol'), + }), + ).rejects.toThrow('MANCODE_TRANSPORT_REVISION_CONFLICT'); + }); + + it('allows exactly one concurrent compare-and-swap push', async () => { + const fixture = await createFixture(); + const storeA = store(fixture.cloneA); + const storeB = store(fixture.cloneB); + await storeA.publishActorProfile({ + operationId: id(20), + expectedRemoteRevision: 0, + profile: profile(id(4), 'Alice'), + }); + await Promise.all([storeA.pull(), storeB.pull()]); + + const results = await Promise.allSettled([ + storeA.publishActorProfile({ + operationId: id(21), + expectedRemoteRevision: 1, + profile: profile(id(5), 'Bob'), + }), + storeB.publishActorProfile({ + operationId: id(22), + expectedRemoteRevision: 1, + profile: profile(id(6), 'Carol'), + }), + ]); + + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + expect(rejected?.reason).toBeInstanceOf(Error); + expect((rejected?.reason as Error).message).toMatch( + /MANCODE_TRANSPORT_(CAS|REVISION)_CONFLICT/, + ); + await expect(storeA.pull()).resolves.toMatchObject({ + manifest: { revision: 2, actorProfiles: expect.any(Array) }, + }); + }); + + it('publishes a joined profile through the explicit command sync path', async () => { + const fixture = await createFixture(); + await initializeV3Project({ + projectRoot: fixture.cloneA, + operationId: id(30), + workspaceId: WORKSPACE_ID, + schemaEpoch: id(31), + now: new Date('2026-07-18T10:00:00.000Z'), + }); + const configPath = path.join( + fixture.cloneA, + '.mancode', + 'shared', + 'config.json', + ); + const config = JSON.parse(await readFile(configPath, 'utf8')) as { + revision: number; + transport: { mode: 'local' | 'git-ref'; remote: string | null }; + updatedAt: string; + }; + await writeFile( + configPath, + `${JSON.stringify( + { + ...config, + revision: config.revision + 1, + transport: { mode: 'git-ref', remote: 'origin' }, + updatedAt: '2026-07-18T10:00:00.000Z', + }, + null, + 2, + )}\n`, + ); + const actor = await createLocalActor(fixture.cloneA, { + actorId: id(32), + displayName: 'Command Alice', + now: new Date('2026-07-18T10:00:00.000Z'), + }); + const sessionId = id(33); + await createSession(fixture.cloneA, { + actorId: actor.actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: new Date('2026-07-18T10:00:00.000Z'), + }); + const writes: string[] = []; + const previousLog = console.log; + console.log = (value: unknown) => writes.push(String(value)); + try { + await expect( + teamJoin(fixture.cloneA, { + name: actor.displayName, + session: sessionId, + client: 'vitest', + sync: true, + json: true, + }), + ).resolves.toBe(0); + } finally { + console.log = previousLog; + } + expect(JSON.parse(writes.at(-1) ?? '{}')).toMatchObject({ + syncReceipt: expect.stringMatching(/^git-ref:[0-9a-f]{40}:/), + }); + await expect(store(fixture.cloneB).pull()).resolves.toMatchObject({ + manifest: { + actorProfiles: [ + expect.objectContaining({ + actorId: actor.actorId, + displayName: 'Command Alice', + }), + ], + }, + }); + }); +}); + +function store(projectRoot: string): GitRefTeamManifestStore { + return new GitRefTeamManifestStore({ + projectRoot, + remote: 'origin', + workspaceId: WORKSPACE_ID, + now: () => new Date('2026-07-18T10:00:00.000Z'), + }); +} + +function profile(actorId: string, displayName: string): SharedActorProfileV1 { + return { + schemaVersion: 1, + actorId, + displayName, + joinedAt: '2026-07-18T10:00:00.000Z', + updatedAt: '2026-07-18T10:00:00.000Z', + }; +} + +function id(value: number): string { + return `01JZ4B6W5Z0A1B2C3D4E5F${value.toString().padStart(4, '0')}`; +} + +async function createFixture(): Promise<{ + cloneA: string; + cloneB: string; +}> { + const root = await mkdtemp(path.join(os.tmpdir(), 'mancode-git-ref-e2e-')); + roots.push(root); + const remote = path.join(root, 'remote.git'); + const cloneA = path.join(root, 'clone-a'); + const cloneB = path.join(root, 'clone-b'); + await execFile('git', ['init', '--bare', remote]); + await execFile('git', ['clone', remote, cloneA]); + await execFile('git', ['clone', remote, cloneB]); + return { cloneA, cloneB }; +} diff --git a/tests/greenfield-init-contracts.test.ts b/tests/greenfield-init-contracts.test.ts new file mode 100644 index 0000000..bdadc18 --- /dev/null +++ b/tests/greenfield-init-contracts.test.ts @@ -0,0 +1,259 @@ +import { + lstat, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + abortStagedGreenfieldInitialization, + greenfieldStagingPath, + greenfieldTargetPath, + initializeGreenfield, + recoverGreenfieldInitialization, + stageGreenfieldInitialization, +} from '../src/context/greenfield-init.js'; +import { parseSchemaManifest } from '../src/context/manifest.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; + +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const EPOCH = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; + +describe('journaled greenfield initialization contract', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-greenfield-contract-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('stages outside .mancode and activates only after binding registration', async () => { + let registrations = 0; + const journal = await initializeGreenfield(input(root), { + registerWorkspaceBinding: async () => { + registrations += 1; + }, + now: new Date('2026-07-17T12:00:00.000Z'), + }); + expect(journal.state).toBe('activated'); + expect(registrations).toBe(1); + await expect( + readFile(greenfieldStagingPath(root, OPERATION_ID)), + ).rejects.toThrow(); + const manifest = parseSchemaManifest( + JSON.parse( + await readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), + ), + ); + expect(manifest).toMatchObject({ + activationState: 'v3_active', + legacyBaseline: null, + }); + expect( + await readFile(path.join(root, '.mancode', '.gitignore'), 'utf8'), + ).toContain('local/'); + expect( + JSON.parse( + await readFile( + path.join(root, '.mancode', 'shared', 'context', 'project.json'), + 'utf8', + ), + ), + ).toMatchObject({ + schemaVersion: 1, + revision: 1, + trust: 'detected', + profile: { projectKind: 'unknown' }, + lastOperationId: OPERATION_ID, + }); + expect(journal.projectFactsDigest).toMatch(/^sha256:/); + }); + + it('refuses legacy authority before it creates a staging root', async () => { + await mkdir(path.join(root, '.mancode'), { recursive: true }); + await writeFile(path.join(root, '.mancode', 'state.json'), '{}'); + await expect(stageGreenfieldInitialization(input(root))).rejects.toThrow( + 'MANCODE_LEGACY_AUTHORITY_PRESENT', + ); + await expect( + readFile(greenfieldStagingPath(root, OPERATION_ID)), + ).rejects.toThrow(); + }); + + it('never replaces a competing V3 target and permits only verified staging abort', async () => { + await stageGreenfieldInitialization(input(root)); + await mkdir(greenfieldTargetPath(root)); + await expect( + recoverGreenfieldInitialization({ + projectRoot: root, + operationId: OPERATION_ID, + registerWorkspaceBinding: async () => {}, + }), + ).rejects.toThrow('MANCODE_GREENFIELD_REPAIR_REQUIRED'); + await rm(greenfieldTargetPath(root), { recursive: true }); + await abortStagedGreenfieldInitialization(root, OPERATION_ID); + await expect( + readFile(greenfieldStagingPath(root, OPERATION_ID)), + ).rejects.toThrow(); + }); + + it('forward-repairs a crash after atomic publication rather than rolling it back', async () => { + await stageGreenfieldInitialization(input(root)); + await rename( + greenfieldStagingPath(root, OPERATION_ID), + greenfieldTargetPath(root), + ); + let registrations = 0; + await expect( + recoverGreenfieldInitialization({ + projectRoot: root, + operationId: OPERATION_ID, + registerWorkspaceBinding: async () => { + registrations += 1; + }, + now: new Date('2026-07-17T12:01:00.000Z'), + }), + ).resolves.toBe('forward_repaired'); + expect(registrations).toBe(1); + const manifest = parseSchemaManifest( + JSON.parse( + await readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), + ), + ); + expect(manifest.activationState).toBe('v3_active'); + }); + + it('runs the real initializer and custom recovery at every declared crash point', async () => { + for (const [ + index, + fixture, + ] of OPERATION_CRASH_FIXTURES.greenfield_initialize.entries()) { + const caseRoot = path.join(root, `case-${index}`); + await mkdir(caseRoot, { recursive: true }); + let registrations = 0; + const recoveryInput = { + projectRoot: caseRoot, + operationId: OPERATION_ID, + registerWorkspaceBinding: async () => { + registrations += 1; + }, + now: new Date('2026-07-17T12:02:00.000Z'), + }; + + await expect( + withOperationCrashInjectionForTesting(fixture, () => + initializeGreenfield(input(caseRoot), recoveryInput), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + if (fixture.expectedRecovery === 'safe_abort') { + if (fixture.crashAfter === 'verify-no-legacy-authority') { + await expect( + recoverGreenfieldInitialization(recoveryInput), + ).rejects.toThrow('MANCODE_GREENFIELD_INITIALIZATION_NOT_FOUND'); + } else { + await expect( + recoverGreenfieldInitialization(recoveryInput), + ).resolves.toBe('safe_abort_available'); + await abortStagedGreenfieldInitialization(caseRoot, OPERATION_ID); + } + expect( + await pathExists(greenfieldStagingPath(caseRoot, OPERATION_ID)), + ).toBe(false); + expect(await pathExists(greenfieldTargetPath(caseRoot))).toBe(false); + await expect( + initializeGreenfield(input(caseRoot), recoveryInput), + ).resolves.toMatchObject({ state: 'activated' }); + } else { + await expect( + recoverGreenfieldInitialization(recoveryInput), + ).resolves.toBe( + fixture.crashAfter === 'commit' + ? 'already_activated' + : 'forward_repaired', + ); + } + + await expect( + recoverGreenfieldInitialization(recoveryInput), + ).resolves.toBe('already_activated'); + await expect( + recoverGreenfieldInitialization(recoveryInput), + ).resolves.toBe('already_activated'); + expect(registrations).toBeGreaterThanOrEqual(1); + } + }); +}); + +async function pathExists(target: string): Promise { + try { + await lstat(target); + return true; + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) { + return false; + } + throw error; + } +} + +function input(projectRoot: string) { + return { + projectRoot, + operationId: OPERATION_ID, + workspaceId: WORKSPACE_ID, + schemaEpoch: EPOCH, + minReaderVersion: '0.4.0', + minWriterVersion: '0.4.0', + managedAdapters: { + 'claude-code': '3', + codex: '3', + cursor: '3', + copilot: '3', + zcode: '3', + }, + projectConfig: { + schemaVersion: 1 as const, + revision: 1, + workspaceId: WORKSPACE_ID, + transport: { mode: 'local' as const, remote: null }, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }, + teamPolicy: { + schemaVersion: 1 as const, + revision: 1, + workspaceId: WORKSPACE_ID, + policy: 'auto' as const, + recentDays: 30, + defaultVisibility: 'local' as const, + shareConfirmedDecisions: false, + retention: { + localRawArtifactDays: 7, + localCacheDays: 7, + completedSessionDays: 30, + }, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }, + now: new Date('2026-07-17T10:00:00.000Z'), + }; +} diff --git a/tests/handoff-contracts.test.ts b/tests/handoff-contracts.test.ts new file mode 100644 index 0000000..2f3a5d1 --- /dev/null +++ b/tests/handoff-contracts.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import { + type HandoffV1, + assertHandoffTransition, + parseHandoff, +} from '../src/team/handoff.js'; + +const HANDOFF_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const FROM_ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const TO_ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const CHECKPOINT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; +const DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('handoff contract', () => { + it('keeps business handoff state independent from local transport availability', () => { + const offered = parseHandoff({ + ...handoff(), + state: 'offered', + offeredAt: '2026-07-17T10:01:00.000Z', + }); + expect(offered.transport.state).toBe('local_only'); + expect(offered.state).toBe('offered'); + expect(() => + parseHandoff({ + ...offered, + transport: { ...offered.transport, mode: 'local', state: 'published' }, + }), + ).toThrow(/local_only/); + }); + + it('requires a named recipient and an offered handoff before acceptance', () => { + const draft = parseHandoff(handoff()); + const accepted = parseHandoff({ + ...draft, + state: 'accepted', + revision: 2, + offeredAt: '2026-07-17T10:01:00.000Z', + resolution: { + state: 'accepted', + actorId: TO_ACTOR_ID, + at: '2026-07-17T10:02:00.000Z', + reason: null, + }, + }); + expect(() => assertHandoffTransition(draft, accepted, TO_ACTOR_ID)).toThrow( + /invalid handoff state transition/, + ); + const offered = parseHandoff({ + ...draft, + state: 'offered', + revision: 2, + offeredAt: '2026-07-17T10:01:00.000Z', + }); + const offeredAccepted = parseHandoff({ ...accepted, revision: 3 }); + expect(() => + assertHandoffTransition(offered, offeredAccepted, FROM_ACTOR_ID), + ).toThrow(/only the receiving actor/); + expect(() => + assertHandoffTransition(offered, offeredAccepted, TO_ACTOR_ID), + ).not.toThrow(); + }); + + it('rejects open handoffs and references that escape the shared task', () => { + expect(() => + parseHandoff({ ...handoff(), toActorId: FROM_ACTOR_ID }), + ).toThrow(/distinct receiving actor/); + expect(() => + parseHandoff({ + ...handoff(), + checkpointRef: { + taskRef: { namespace: 'local', taskId: TASK_ID }, + kind: 'checkpoint', + artifactId: CHECKPOINT_ID, + }, + }), + ).toThrow(/cannot reference local/); + }); + + it('blocks sensitive text from the shared handoff projection', () => { + expect(() => + parseHandoff({ + ...handoff(), + summary: { + ...handoff().summary, + nextAction: 'Inspect /Users/alice/private/report.txt before merge.', + }, + }), + ).toThrow(/MANCODE_PRIVACY_BLOCKED/); + }); +}); + +function handoff(): HandoffV1 { + return { + schemaVersion: 1, + handoffId: HANDOFF_ID, + taskRef: { namespace: 'shared', taskId: TASK_ID }, + taskRevision: 7, + ownershipEpochAtOffer: 3, + state: 'draft', + revision: 1, + fromActorId: FROM_ACTOR_ID, + toActorId: TO_ACTOR_ID, + claimIds: [], + checkpointRef: { + taskRef: { namespace: 'shared', taskId: TASK_ID }, + kind: 'checkpoint', + artifactId: CHECKPOINT_ID, + }, + summary: { + completed: [], + inProgress: [], + notStarted: [], + changedFiles: [], + verification: [], + blockers: [], + risks: [], + nextAction: 'Run the security review.', + }, + transport: { + mode: 'local', + state: 'local_only', + transportRevision: null, + publishedAt: null, + fetchedAt: null, + taskBundleDigest: DIGEST, + codeRef: { branch: 'feature/login', head: 'abc1234' }, + codeReachable: true, + receipt: null, + }, + lastOperationId: null, + offeredAt: null, + resolution: null, + createdAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} diff --git a/tests/handoff-store-contracts.test.ts b/tests/handoff-store-contracts.test.ts new file mode 100644 index 0000000..58cf21e --- /dev/null +++ b/tests/handoff-store-contracts.test.ts @@ -0,0 +1,125 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Ulid } from '../src/context/ids.js'; +import type { EntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { + createHandoff, + handoffPath, + listHandoffs, + readHandoff, + updateHandoff, +} from '../src/runtime/handoff-store.js'; +import type { HandoffV1 } from '../src/team/handoff.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H' as Ulid; +const HANDOFF_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J' as Ulid; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K' as Ulid; +const FROM_ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M' as Ulid; +const TO_ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N' as Ulid; +const CHECKPOINT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7P' as Ulid; + +describe('handoff authority store', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-handoff-store-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('creates immutable handoff IDs and applies revision-CAS transitions', async () => { + const store = homeStore(root); + const draft = handoff(); + await expect(createHandoff(store, draft)).resolves.toEqual(draft); + await expect(createHandoff(store, draft)).resolves.toEqual(draft); + await expect(readHandoff(store, HANDOFF_ID)).resolves.toEqual(draft); + await expect(listHandoffs(store, draft.taskRef)).resolves.toEqual([draft]); + + const offered: HandoffV1 = { + ...draft, + state: 'offered', + revision: 2, + offeredAt: '2026-07-17T10:01:00.000Z', + lastOperationId: HANDOFF_ID, + updatedAt: '2026-07-17T10:01:00.000Z', + }; + await expect( + updateHandoff(store, offered, 1, FROM_ACTOR_ID), + ).resolves.toEqual(offered); + await expect( + updateHandoff(store, offered, 1, FROM_ACTOR_ID), + ).rejects.toThrow('MANCODE_EXPECTED_REVISION_CONFLICT'); + }); + + it('refuses a different snapshot under an existing handoff ID', async () => { + const store = homeStore(root); + const draft = handoff(); + await createHandoff(store, draft); + await expect( + createHandoff(store, { ...draft, toActorId: WORKSPACE_ID }), + ).rejects.toThrow('MANCODE_HANDOFF_ID_CONFLICT'); + expect(handoffPath(store, HANDOFF_ID)).toContain(`${HANDOFF_ID}.json`); + }); +}); + +function homeStore(root: string): EntityHomeStore { + return { + kind: 'non_git_shared', + storeId: `non-git:${WORKSPACE_ID}`, + root, + workspaceId: WORKSPACE_ID, + checkoutId: null, + repositoryBindingId: null, + }; +} + +function handoff(): HandoffV1 { + const taskRef = { namespace: 'shared' as const, taskId: TASK_ID }; + return { + schemaVersion: 1, + handoffId: HANDOFF_ID, + taskRef, + taskRevision: 7, + ownershipEpochAtOffer: 3, + state: 'draft', + revision: 1, + fromActorId: FROM_ACTOR_ID, + toActorId: TO_ACTOR_ID, + claimIds: [], + checkpointRef: { taskRef, kind: 'checkpoint', artifactId: CHECKPOINT_ID }, + summary: { + completed: [], + inProgress: [], + notStarted: [], + changedFiles: [], + verification: [], + blockers: [], + risks: [], + nextAction: 'Review the latest checkpoint.', + }, + transport: { + mode: 'local', + state: 'local_only', + transportRevision: null, + publishedAt: null, + fetchedAt: null, + taskBundleDigest: `sha256:${'a'.repeat(64)}`, + codeRef: { branch: 'main', head: 'abc123' }, + codeReachable: true, + receipt: null, + }, + lastOperationId: null, + offeredAt: null, + resolution: null, + createdAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} diff --git a/tests/join-contracts.test.ts b/tests/join-contracts.test.ts new file mode 100644 index 0000000..f51f233 --- /dev/null +++ b/tests/join-contracts.test.ts @@ -0,0 +1,144 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + type LocalActorIdentityV1, + createLocalActor, + readSharedActorProfile, +} from '../src/team/actor.js'; +import { listTeamEvents } from '../src/team/events.js'; +import { joinTeam, prepareTeamJoin } from '../src/team/join.js'; +import type { ProjectConfigV1, TeamPolicyV1 } from '../src/team/policy.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const EVENT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); +}); + +describe('team join contract', () => { + it('requires an explicit confirmation and rejects sync when transport is local', () => { + const actor = actorFixture(); + expect(() => + prepareTeamJoin(joinInput(actor, { confirmed: false })), + ).toThrow('MANCODE_JOIN_CONFIRMATION_REQUIRED'); + expect(() => prepareTeamJoin(joinInput(actor, { sync: true }))).toThrow( + 'MANCODE_TRANSPORT_UNAVAILABLE', + ); + }); + + it('publishes a narrow profile then appends one join audit event', async () => { + const root = await temporaryRoot(); + const actor = await createLocalActor(root, { + actorId: ACTOR_ID, + displayName: 'Alice Example', + }); + const result = await joinTeam({ + ...joinInput(actor), + projectRoot: root, + }); + expect(result.syncReceipt).toBeNull(); + await expect(readSharedActorProfile(root, ACTOR_ID)).resolves.toEqual( + result.profile, + ); + await expect(listTeamEvents(root)).resolves.toEqual([result.event]); + }); + + it('only invokes a git-ref publisher when the user explicitly requests sync', async () => { + const root = await temporaryRoot(); + const actor = await createLocalActor(root, { + actorId: ACTOR_ID, + displayName: 'Alice Example', + }); + const calls: string[] = []; + const result = await joinTeam({ + ...joinInput(actor, { transport: 'git-ref', sync: true }), + projectRoot: root, + syncPublisher: { + async publishActorProfile({ profile }) { + calls.push(profile.actorId); + return { receipt: 'receipt:abc123' }; + }, + }, + }); + expect(calls).toEqual([ACTOR_ID]); + expect(result.syncReceipt).toBe('receipt:abc123'); + }); +}); + +function joinInput( + actor: LocalActorIdentityV1, + overrides: { + confirmed?: boolean; + sync?: boolean; + transport?: 'local' | 'git-ref'; + } = {}, +) { + const transport = overrides.transport ?? 'local'; + return { + actor, + projectConfig: config(transport), + teamPolicy: policy(), + operationId: OPERATION_ID, + eventId: EVENT_ID, + confirmed: overrides.confirmed ?? true, + sync: overrides.sync ?? false, + now: new Date('2026-07-17T10:00:00.000Z'), + }; +} + +function actorFixture(): LocalActorIdentityV1 { + return { + schemaVersion: 1, + actorId: ACTOR_ID, + displayName: 'Alice Example', + createdAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} + +function config(transport: 'local' | 'git-ref'): ProjectConfigV1 { + return { + schemaVersion: 1, + revision: 1, + workspaceId: WORKSPACE_ID, + transport: { + mode: transport, + remote: transport === 'git-ref' ? 'origin/mancode-team' : null, + }, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} + +function policy(): TeamPolicyV1 { + return { + schemaVersion: 1, + revision: 1, + workspaceId: WORKSPACE_ID, + policy: 'auto', + recentDays: 30, + defaultVisibility: 'local', + shareConfirmedDecisions: true, + retention: { + localRawArtifactDays: 7, + localCacheDays: 7, + completedSessionDays: 30, + }, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'mancode-join-contract-')); + roots.push(root); + return root; +} diff --git a/tests/layout-contracts.test.ts b/tests/layout-contracts.test.ts new file mode 100644 index 0000000..ac57d1d --- /dev/null +++ b/tests/layout-contracts.test.ts @@ -0,0 +1,88 @@ +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + assertGreenfieldInitializationPreflight, + assertV3PhysicalIsolation, + inspectMancodeLayout, + sameLegacyBaseline, +} from '../src/context/layout.js'; + +describe('legacy/V3 physical layout contract', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-layout-contract-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('keeps legacy and V3 authority paths physically disjoint', () => { + expect(() => assertV3PhysicalIsolation()).not.toThrow(); + }); + + it('detects every legacy authority category in the baseline without exposing its contents', async () => { + await mkdir(path.join(root, '.mancode', 'workflows', 'legacy-task'), { + recursive: true, + }); + await mkdir(path.join(root, '.mancode', 'memory'), { recursive: true }); + await writeFile(path.join(root, '.mancode', 'state.json'), '{"task":"x"}'); + await writeFile( + path.join(root, '.mancode', 'config.json'), + '{"team":true}', + ); + await writeFile( + path.join(root, '.mancode', 'workflows', 'legacy-task', 'metadata.json'), + '{"status":"active"}', + ); + await writeFile(path.join(root, '.mancode', 'memory', 'note.md'), 'secret'); + + const first = await inspectMancodeLayout(root); + expect(first.legacy.authorityPresent).toBe(true); + expect(first.legacy.baseline).not.toBeNull(); + expect(JSON.stringify(first.legacy)).not.toContain('secret'); + await writeFile( + path.join(root, '.mancode', 'config.json'), + '{"team":false}', + ); + const second = await inspectMancodeLayout(root); + expect( + sameLegacyBaseline(first.legacy.baseline, second.legacy.baseline), + ).toBe(false); + await expect(assertGreenfieldInitializationPreflight(root)).rejects.toThrow( + 'MANCODE_LEGACY_AUTHORITY_PRESENT', + ); + }); + + it('does not mistake a V3 root for legacy authority, but never overwrites it', async () => { + await mkdir(path.join(root, '.mancode', 'shared'), { recursive: true }); + await writeFile(path.join(root, '.mancode', 'schema.json'), '{}'); + const inspection = await inspectMancodeLayout(root); + expect(inspection.legacy.authorityPresent).toBe(false); + expect(inspection.v3AuthorityPathsPresent).toEqual([ + 'schema.json', + 'shared', + ]); + await expect(assertGreenfieldInitializationPreflight(root)).rejects.toThrow( + 'MANCODE_V3_TARGET_EXISTS', + ); + }); + + it('treats a legacy symlink as unsafe authority and never follows it', async () => { + const external = path.join(root, 'outside.json'); + await writeFile(external, 'outside-secret'); + await mkdir(path.join(root, '.mancode'), { recursive: true }); + await symlink(external, path.join(root, '.mancode', 'state.json')); + const inspection = await inspectMancodeLayout(root); + expect(inspection.legacy.authorityPresent).toBe(true); + expect(inspection.legacy.unsafePaths).toEqual(['state.json']); + expect(JSON.stringify(inspection.legacy)).not.toContain('outside-secret'); + }); +}); diff --git a/tests/manifest-session-contracts.test.ts b/tests/manifest-session-contracts.test.ts new file mode 100644 index 0000000..3baabc5 --- /dev/null +++ b/tests/manifest-session-contracts.test.ts @@ -0,0 +1,283 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + type SchemaManifestV1, + assertSchemaManifestTransition, + parseSchemaManifest, +} from '../src/context/manifest.js'; +import { createSessionIdentityProvider } from '../src/runtime/session-identity.js'; +import { + attachSessionExecution, + closeSession, + createBootstrapSession, + createSession, + readSession, + resolveSessionCandidate, + resumeSession, +} from '../src/runtime/session.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const EXPLICIT_SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const OTHER_SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('schema manifest contract', () => { + it('keeps greenfield and legacy activation paths distinct', () => { + const greenfield = parseSchemaManifest( + manifest({ + activationState: 'initializing', + legacyBaseline: null, + }), + ); + const legacy = parseSchemaManifest( + manifest({ + activationState: 'dual_read', + legacyBaseline: { stateDigest: DIGEST, workflowIndexDigest: DIGEST }, + }), + ); + expect(greenfield.activationState).toBe('initializing'); + expect(legacy.activationState).toBe('dual_read'); + expect(() => + parseSchemaManifest( + manifest({ + activationState: 'dual_read', + legacyBaseline: null, + }), + ), + ).toThrow(/require a legacy baseline/); + expect(() => + parseSchemaManifest( + manifest({ + activationState: 'initializing', + legacyBaseline: { stateDigest: DIGEST, workflowIndexDigest: DIGEST }, + }), + ), + ).toThrow(/must not have a legacy baseline/); + }); + + it('only permits journal repair or the documented activation transitions', () => { + const dualRead = parseSchemaManifest( + manifest({ + activationState: 'dual_read', + legacyBaseline: { stateDigest: DIGEST, workflowIndexDigest: DIGEST }, + }), + ); + const activating = parseSchemaManifest( + manifest({ + activationState: 'activating', + legacyBaseline: { stateDigest: DIGEST, workflowIndexDigest: DIGEST }, + }), + ); + const active = parseSchemaManifest( + manifest({ + activationState: 'v3_active', + legacyBaseline: { stateDigest: DIGEST, workflowIndexDigest: DIGEST }, + activatedAt: '2026-07-17T10:00:00.000Z', + }), + ); + expect(() => + assertSchemaManifestTransition(dualRead, activating), + ).not.toThrow(); + expect(() => + assertSchemaManifestTransition(activating, active), + ).not.toThrow(); + expect(() => assertSchemaManifestTransition(dualRead, active)).toThrow( + /invalid schema manifest transition/, + ); + }); +}); + +describe('session identity contract', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'mancode-session-contract-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('uses explicit, then environment, then verified host identity without client fallbacks', () => { + const provider = createSessionIdentityProvider(WORKSPACE_ID); + const explicit = provider.resolveCandidate({ + explicitSessionId: EXPLICIT_SESSION_ID, + environment: { MANCODE_SESSION_ID: OTHER_SESSION_ID }, + trustedHostInput: { + externalSessionKey: 'window-a', + propagatesToCommands: false, + }, + client: 'codex', + }); + expect(explicit).toMatchObject({ + internalSessionId: EXPLICIT_SESSION_ID, + source: 'explicit', + }); + const environment = provider.resolveCandidate({ + environment: { MANCODE_SESSION_ID: OTHER_SESSION_ID }, + trustedHostInput: { + externalSessionKey: 'window-a', + propagatesToCommands: false, + }, + client: 'codex', + }); + expect(environment).toMatchObject({ + internalSessionId: OTHER_SESSION_ID, + source: 'env', + }); + const noIdentity = provider.resolveCandidate({ + environment: {}, + client: 'codex', + }); + expect(noIdentity).toBeNull(); + expect(() => + provider.resolveCandidate({ + environment: { MANCODE_SESSION_ID: 'codex' }, + client: 'codex', + }), + ).toThrow(/MANCODE_SESSION_ID/); + }); + + it('keeps two host windows separate and rejects closed or mismatched sessions', async () => { + const provider = createSessionIdentityProvider(WORKSPACE_ID, { + hostIdentityCapability: 'host_verified', + }); + const firstCandidate = provider.resolveCandidate({ + environment: {}, + trustedHostInput: { + externalSessionKey: 'window-a', + propagatesToCommands: true, + }, + client: 'codex', + }); + const secondCandidate = provider.resolveCandidate({ + environment: {}, + trustedHostInput: { + externalSessionKey: 'window-b', + propagatesToCommands: true, + }, + client: 'codex', + }); + const first = await resolveSessionCandidate(root, firstCandidate, ACTOR_ID); + const firstAgain = await resolveSessionCandidate( + root, + firstCandidate, + ACTOR_ID, + ); + const second = await resolveSessionCandidate( + root, + secondCandidate, + ACTOR_ID, + ); + expect(first?.sessionId).toBe(firstAgain?.sessionId); + expect(first?.sessionId).not.toBe(second?.sessionId); + + const explicit = await createSession(root, { + actorId: ACTOR_ID, + client: 'codex', + identitySource: 'env', + sessionId: EXPLICIT_SESSION_ID, + }); + await closeSession(root, explicit.sessionId); + const closedCandidate = provider.resolveCandidate({ + environment: { MANCODE_SESSION_ID: explicit.sessionId }, + client: 'codex', + }); + await expect( + resolveSessionCandidate(root, closedCandidate), + ).rejects.toThrow('MANCODE_SESSION_NOT_FOUND'); + const crossClient = provider.resolveCandidate({ + environment: { MANCODE_SESSION_ID: first?.sessionId }, + client: 'claude-code', + }); + await expect(resolveSessionCandidate(root, crossClient)).rejects.toThrow( + 'MANCODE_SESSION_NOT_FOUND', + ); + }); + + it('only bootstraps explicitly and updates the resumed session without touching another window', async () => { + const first = await createBootstrapSession(root, { + actorId: ACTOR_ID, + client: 'codex', + now: new Date('2026-07-17T10:00:00.000Z'), + }); + const second = await createSession(root, { + actorId: ACTOR_ID, + client: 'codex', + identitySource: 'host', + identityLookupKeyHash: DIGEST, + }); + expect(first.environment.MANCODE_SESSION_ID).toBe(first.session.sessionId); + expect(first.hint).toContain(first.session.sessionId); + + const resumed = await resumeSession(root, first.session.sessionId, { + taskRef: { namespace: 'shared', taskId: WORKSPACE_ID }, + workflowMode: 'manteam', + taskRevision: 7, + now: new Date('2026-07-17T10:01:00.000Z'), + }); + expect(resumed.activeTaskRef).toEqual({ + namespace: 'shared', + taskId: WORKSPACE_ID, + }); + await expect(readSession(root, second.sessionId)).resolves.toMatchObject({ + activeTaskRef: null, + }); + await expect( + attachSessionExecution(root, first.session.sessionId, OTHER_SESSION_ID), + ).resolves.toMatchObject({ executionIds: [OTHER_SESSION_ID] }); + }); + + it('does not overwrite a session while a same-session mutation lock is held', async () => { + const session = await createSession(root, { + actorId: ACTOR_ID, + client: 'codex', + identitySource: 'explicit', + sessionId: EXPLICIT_SESSION_ID, + }); + await mkdir( + path.join( + root, + '.mancode', + 'local', + 'sessions', + `.${session.sessionId}.lock`, + ), + ); + await expect( + resumeSession(root, session.sessionId, { + taskRef: { namespace: 'shared', taskId: WORKSPACE_ID }, + workflowMode: 'manteam', + taskRevision: 1, + }), + ).rejects.toThrow('MANCODE_SESSION_LOCK_HELD'); + await expect(readSession(root, session.sessionId)).resolves.toMatchObject({ + activeTaskRef: null, + }); + }); +}); + +function manifest(overrides: Partial): SchemaManifestV1 { + return { + manifestVersion: 1, + layoutVersion: 3, + epoch: WORKSPACE_ID, + activationState: 'initializing', + minReaderVersion: '0.4.0', + minWriterVersion: '0.4.0', + activatedAt: null, + legacyBaseline: null, + managedAdapters: { + 'claude-code': '3', + codex: '3', + cursor: '3', + copilot: '3', + zcode: '3', + }, + lastOperationId: null, + ...overrides, + }; +} diff --git a/tests/migrate-command-contracts.test.ts b/tests/migrate-command-contracts.test.ts new file mode 100644 index 0000000..00d0f76 --- /dev/null +++ b/tests/migrate-command-contracts.test.ts @@ -0,0 +1,289 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const engine = vi.hoisted(() => ({ + activate: vi.fn(), + dryRun: vi.fn(), + list: vi.fn(), + resolve: vi.fn(), + rollback: vi.fn(), + stage: vi.fn(), +})); + +vi.mock('../src/context/migrate.js', () => ({ + activateLegacyMigration: engine.activate, + dryRunLegacyMigration: engine.dryRun, + listMigrationStages: engine.list, + resolveLegacyMigration: engine.resolve, + rollbackLegacyMigration: engine.rollback, + stageLegacyMigration: engine.stage, +})); + +import { + EXIT_INVALID_ARG, + EXIT_MIGRATION_BLOCKED, + EXIT_OK, + migrateContext, + migrateContextResolve, +} from '../src/commands/migrate.js'; + +const STAGE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const OWNER_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; + +describe('migration command contract', () => { + let root: string; + + beforeEach(async () => { + vi.clearAllMocks(); + root = path.join( + tmpdir(), + `mancode-migrate-command-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`, + ); + await mkdir(root, { recursive: true }); + engine.dryRun.mockResolvedValue({ schemaVersion: 1, tasks: [] }); + engine.stage.mockResolvedValue(stage('staged', 1)); + engine.list.mockResolvedValue([stage('staged', 1)]); + engine.activate.mockResolvedValue({ + manifest: { activationState: 'v3_active' }, + stage: stage('activated', 2), + operation: { operationId: OPERATION_ID, state: 'committed' }, + }); + engine.rollback.mockResolvedValue({ + manifest: { activationState: 'dual_read' }, + stage: stage('rolled_back', 3), + }); + engine.resolve.mockResolvedValue(stage('staged', 2)); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('returns stable JSON errors for invalid operation and resolution inputs', async () => { + await expectJson( + () => migrateContext(root, { json: true }), + EXIT_INVALID_ARG, + 'MANCODE_MIGRATION_ARGUMENT_INVALID', + ); + await expectJson( + () => + migrateContext(root, { + dryRun: true, + status: true, + json: true, + }), + EXIT_INVALID_ARG, + 'MANCODE_MIGRATION_ARGUMENT_INVALID', + ); + await expectJson( + () => migrateContext(root, { activate: true, json: true }), + EXIT_MIGRATION_BLOCKED, + 'MANCODE_SESSION_REQUIRED', + ); + await expectJson( + () => + migrateContext(root, { + activate: true, + session: SESSION_ID, + expectedStageRevision: '0', + json: true, + }), + EXIT_MIGRATION_BLOCKED, + 'MANCODE_MIGRATION_STAGE_REVISION_INVALID', + ); + await expectJson( + () => + migrateContextResolve(root, 'legacy-task', { + stageId: STAGE_ID, + expectedStageRevision: '1', + scopeFile: '../outside.json', + json: true, + }), + EXIT_MIGRATION_BLOCKED, + 'MANCODE_MIGRATION_SCOPE_FILE_INVALID', + ); + engine.list.mockResolvedValueOnce([]); + await expectJson( + () => + migrateContext(root, { + activate: true, + session: SESSION_ID, + expectedStageRevision: '1', + json: true, + }), + EXIT_MIGRATION_BLOCKED, + 'MANCODE_MIGRATION_STAGE_NOT_FOUND', + ); + }); + + it('routes dry-run, stage, status, activation, rollback, and resolution', async () => { + await expect( + captureJson(() => migrateContext(root, { dryRun: true, json: true })), + ).resolves.toMatchObject({ exitCode: EXIT_OK, value: { tasks: [] } }); + expect(engine.dryRun).toHaveBeenCalledWith(root); + + await expect( + captureJson(() => migrateContext(root, { stage: true, json: true })), + ).resolves.toMatchObject({ + exitCode: EXIT_OK, + value: { stageId: STAGE_ID, state: 'staged' }, + }); + expect(engine.stage).toHaveBeenCalledWith({ projectRoot: root }); + + await expect( + captureJson(() => migrateContext(root, { status: true, json: true })), + ).resolves.toMatchObject({ + exitCode: EXIT_OK, + value: { schemaVersion: 1, stages: [{ stageId: STAGE_ID }] }, + }); + + await expect( + captureJson(() => + migrateContext(root, { + activate: true, + expectedStageRevision: '1', + session: SESSION_ID, + confirm: true, + confirmShared: true, + json: true, + }), + ), + ).resolves.toMatchObject({ + exitCode: EXIT_OK, + value: { manifest: { activationState: 'v3_active' } }, + }); + expect(engine.activate).toHaveBeenCalledWith({ + projectRoot: root, + stageId: STAGE_ID, + expectedStageRevision: 1, + sessionId: SESSION_ID, + explicitConfirmation: true, + sharedPrivacyConfirmed: true, + }); + + await expect( + captureJson(() => + migrateContext(root, { + rollback: OPERATION_ID, + session: SESSION_ID, + confirm: true, + json: true, + }), + ), + ).resolves.toMatchObject({ + exitCode: EXIT_OK, + value: { manifest: { activationState: 'dual_read' } }, + }); + expect(engine.rollback).toHaveBeenCalledWith({ + projectRoot: root, + operationId: OPERATION_ID, + sessionId: SESSION_ID, + explicitConfirmation: true, + }); + + await writeFile( + path.join(root, 'scope.json'), + JSON.stringify({ + include: ['src/**'], + exclude: [], + modules: ['runtime'], + }), + ); + await expect( + captureJson(() => + migrateContextResolve(root, 'legacy-task', { + stageId: STAGE_ID, + expectedStageRevision: '1', + owner: OWNER_ID, + scopeFile: 'scope.json', + json: true, + }), + ), + ).resolves.toMatchObject({ + exitCode: EXIT_OK, + value: { stageId: STAGE_ID, revision: 2 }, + }); + expect(engine.resolve).toHaveBeenCalledWith({ + projectRoot: root, + stageId: STAGE_ID, + legacyTaskId: 'legacy-task', + expectedStageRevision: 1, + ownerActorId: OWNER_ID, + implementationScope: { + include: ['src/**'], + exclude: [], + modules: ['runtime'], + }, + }); + }); + + it('normalizes engine failures into the documented error envelope', async () => { + engine.dryRun.mockRejectedValueOnce( + new Error('MANCODE_MIGRATION_LEGACY_PATH_UNSAFE:details'), + ); + await expectJson( + () => migrateContext(root, { dryRun: true, json: true }), + EXIT_MIGRATION_BLOCKED, + 'MANCODE_MIGRATION_LEGACY_PATH_UNSAFE', + ); + engine.resolve.mockRejectedValueOnce(new Error('unexpected failure')); + await expectJson( + () => + migrateContextResolve(root, 'legacy-task', { + stageId: STAGE_ID, + expectedStageRevision: '1', + json: true, + }), + EXIT_MIGRATION_BLOCKED, + 'MANCODE_MIGRATION_FAILED', + ); + }); +}); + +function stage(state: string, revision: number) { + return { + schemaVersion: 1, + stageId: STAGE_ID, + revision, + state, + tasks: [], + }; +} + +async function expectJson( + operation: () => Promise, + exitCode: number, + errorCode: string, +): Promise { + await expect(captureJson(operation)).resolves.toMatchObject({ + exitCode, + value: { schemaVersion: 1, error: { code: errorCode } }, + }); +} + +async function captureJson(operation: () => Promise): Promise<{ + exitCode: number; + value: unknown; +}> { + const output: string[] = []; + const log = vi.spyOn(console, 'log').mockImplementation((value) => { + output.push(String(value)); + }); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const exitCode = await operation(); + const serialized = output.at(-1); + if (serialized === undefined) + throw new Error('missing migration JSON output'); + return { exitCode, value: JSON.parse(serialized) }; + } finally { + log.mockRestore(); + error.mockRestore(); + } +} diff --git a/tests/migrate-contracts.test.ts b/tests/migrate-contracts.test.ts new file mode 100644 index 0000000..1965337 --- /dev/null +++ b/tests/migrate-contracts.test.ts @@ -0,0 +1,561 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { parseSchemaManifest } from '../src/context/manifest.js'; +import { + activateLegacyMigration, + dryRunLegacyMigration, + migrationStagePath, + resolveLegacyMigration, + rollbackLegacyMigration, + stageLegacyMigration, +} from '../src/context/migrate.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; +import { executeOperationRecovery } from '../src/runtime/operation-recovery-executor.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, +} from '../src/team/actor.js'; + +const OWNER_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const LEGACY_TASK_ID = '20260717-120000-login-rate-limit'; +const ACTIVATION_OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; + +describe('legacy migration stage contract', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-migrate-contract-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await writeLegacyFixture(root); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('dry-runs without creating V3 paths and makes missing active ownership explicit', async () => { + const before = await readLegacyAuthorityBytes(root); + + const report = await dryRunLegacyMigration(root); + + expect(report.tasks).toHaveLength(1); + expect(report.tasks[0]).toMatchObject({ + legacyTaskId: LEGACY_TASK_ID, + state: 'blocked', + blockers: ['MANCODE_MIGRATION_OWNER_REQUIRED'], + }); + await expect( + readFile(path.join(root, '.mancode', 'schema.json')), + ).rejects.toThrow(); + expect(await readLegacyAuthorityBytes(root)).toEqual(before); + }); + + it('writes only a dual-read shell and local quarantine, then rebuilds a candidate after explicit resolution', async () => { + const before = await readLegacyAuthorityBytes(root); + const staged = await stageLegacyMigration({ + projectRoot: root, + now: new Date('2026-07-17T12:00:00.000Z'), + }); + const blocked = staged.tasks[0]; + expect(blocked).toMatchObject({ + legacyTaskId: LEGACY_TASK_ID, + state: 'blocked', + blockers: ['MANCODE_MIGRATION_OWNER_REQUIRED'], + }); + expect(await readLegacyAuthorityBytes(root)).toEqual(before); + expect( + parseSchemaManifest( + JSON.parse( + await readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), + ), + ).activationState, + ).toBe('dual_read'); + await expect( + readFile(migrationStagePath(root, staged.stageId), 'utf8'), + ).resolves.toContain(LEGACY_TASK_ID); + await expect( + readFile( + path.join( + root, + '.mancode', + 'local', + 'quarantine', + blocked?.quarantineId ?? '', + 'candidate.json', + ), + 'utf8', + ), + ).resolves.toContain('legacy_migration'); + + const resolved = await resolveLegacyMigration({ + projectRoot: root, + stageId: staged.stageId, + legacyTaskId: LEGACY_TASK_ID, + expectedStageRevision: staged.revision, + ownerActorId: OWNER_ID, + now: new Date('2026-07-17T12:01:00.000Z'), + }); + + expect(resolved.revision).toBe(staged.revision + 1); + expect(resolved.tasks[0]).toMatchObject({ + legacyTaskId: LEGACY_TASK_ID, + state: 'ready', + blockers: [], + privacyStatus: 'passed', + }); + expect(await readLegacyAuthorityBytes(root)).toEqual(before); + }); + + it('activates a clean local stage through a durable operation without changing legacy authority', async () => { + const before = await readLegacyAuthorityBytes(root); + const staged = await stageLegacyMigration({ projectRoot: root }); + const resolved = await resolveLegacyMigration({ + projectRoot: root, + stageId: staged.stageId, + legacyTaskId: LEGACY_TASK_ID, + expectedStageRevision: staged.revision, + ownerActorId: OWNER_ID, + }); + const actor = await createLocalActor(root, { + actorId: OWNER_ID, + displayName: 'Migration owner', + }); + await publishSharedActorProfile(root, createSharedActorProfile(actor)); + const session = await createSession(root, { + actorId: actor.actorId, + client: 'test', + identitySource: 'explicit', + }); + + const activated = await activateLegacyMigration({ + projectRoot: root, + stageId: resolved.stageId, + expectedStageRevision: resolved.revision, + sessionId: session.sessionId, + explicitConfirmation: true, + sharedPrivacyConfirmed: false, + }); + + expect(activated.manifest.activationState).toBe('v3_active'); + expect(activated.stage.state).toBe('activated'); + expect(activated.operation.state).toBe('committed'); + await expect( + readFile( + path.join( + root, + '.mancode', + 'local', + 'workflows', + resolved.tasks[0]?.taskRef.taskId ?? '', + 'metadata.json', + ), + 'utf8', + ), + ).resolves.toContain('legacy_migration'); + expect(await readLegacyAuthorityBytes(root)).toEqual(before); + }); + + it('rolls back only an untouched activation', async () => { + const staged = await stageLegacyMigration({ projectRoot: root }); + const resolved = await resolveLegacyMigration({ + projectRoot: root, + stageId: staged.stageId, + legacyTaskId: LEGACY_TASK_ID, + expectedStageRevision: staged.revision, + ownerActorId: OWNER_ID, + }); + const actor = await createLocalActor(root, { + actorId: OWNER_ID, + displayName: 'Migration owner', + }); + await publishSharedActorProfile(root, createSharedActorProfile(actor)); + const session = await createSession(root, { + actorId: actor.actorId, + client: 'test', + identitySource: 'explicit', + }); + const activated = await activateLegacyMigration({ + projectRoot: root, + stageId: resolved.stageId, + expectedStageRevision: resolved.revision, + sessionId: session.sessionId, + explicitConfirmation: true, + sharedPrivacyConfirmed: false, + }); + + const rolledBack = await rollbackLegacyMigration({ + projectRoot: root, + operationId: activated.operation.operationId, + sessionId: session.sessionId, + explicitConfirmation: true, + }); + + expect(rolledBack.manifest.activationState).toBe('dual_read'); + expect(rolledBack.stage.state).toBe('rolled_back'); + await expect( + readFile( + path.join( + root, + '.mancode', + 'local', + 'workflows', + resolved.tasks[0]?.taskRef.taskId ?? '', + 'metadata.json', + ), + ), + ).rejects.toThrow(); + }); + + it('refuses rollback after any migrated authority drift', async () => { + const staged = await stageLegacyMigration({ projectRoot: root }); + const resolved = await resolveLegacyMigration({ + projectRoot: root, + stageId: staged.stageId, + legacyTaskId: LEGACY_TASK_ID, + expectedStageRevision: staged.revision, + ownerActorId: OWNER_ID, + }); + const actor = await createLocalActor(root, { + actorId: OWNER_ID, + displayName: 'Migration owner', + }); + await publishSharedActorProfile(root, createSharedActorProfile(actor)); + const session = await createSession(root, { + actorId: actor.actorId, + client: 'test', + identitySource: 'explicit', + }); + const activated = await activateLegacyMigration({ + projectRoot: root, + stageId: resolved.stageId, + expectedStageRevision: resolved.revision, + sessionId: session.sessionId, + explicitConfirmation: true, + sharedPrivacyConfirmed: false, + }); + await writeFile( + path.join( + root, + '.mancode', + 'local', + 'workflows', + resolved.tasks[0]?.taskRef.taskId ?? '', + 'plan.md', + ), + 'unexpected V3 write\n', + { encoding: 'utf8', flag: 'w' }, + ); + + await expect( + rollbackLegacyMigration({ + projectRoot: root, + operationId: activated.operation.operationId, + sessionId: session.sessionId, + explicitConfirmation: true, + }), + ).rejects.toThrow('MANCODE_MIGRATION_ROLLBACK_FORBIDDEN'); + }); + + it('runs the real V3 activation and recovery at every declared crash point', async () => { + for (const [ + index, + fixture, + ] of OPERATION_CRASH_FIXTURES.v3_activate.entries()) { + const caseRoot = path.join(root, `activation-case-${index}`); + const prepared = await prepareActivationCase(caseRoot); + + await expect( + withOperationCrashInjectionForTesting(fixture, () => + activateLegacyMigration({ + projectRoot: caseRoot, + stageId: prepared.stageId, + expectedStageRevision: prepared.stageRevision, + sessionId: prepared.sessionId, + explicitConfirmation: true, + sharedPrivacyConfirmed: false, + operationId: ACTIVATION_OPERATION_ID, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const recovered = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId: ACTIVATION_OPERATION_ID, + actorId: OWNER_ID, + sessionId: prepared.sessionId, + mode: fixture.expectedRecovery === 'safe_abort' ? 'abort' : 'repair', + }); + if (fixture.expectedRecovery === 'safe_abort') { + expect(recovered).toMatchObject({ + state: 'aborted', + journal: { state: 'aborted' }, + }); + } else if (fixture.crashAfter === 'commit') { + expect(recovered).toMatchObject({ + state: 'already_terminal', + journal: { state: 'committed' }, + }); + } else { + expect(recovered).toMatchObject({ + state: 'repaired', + journal: { state: 'committed' }, + }); + } + + const terminal = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId: ACTIVATION_OPERATION_ID, + actorId: OWNER_ID, + sessionId: prepared.sessionId, + }); + expect(terminal).toMatchObject({ + state: 'already_terminal', + journal: { + state: + fixture.expectedRecovery === 'safe_abort' ? 'aborted' : 'committed', + }, + }); + expect( + parseSchemaManifest( + JSON.parse( + await readFile( + path.join(caseRoot, '.mancode', 'schema.json'), + 'utf8', + ), + ), + ).activationState, + ).toBe( + fixture.expectedRecovery === 'safe_abort' ? 'dual_read' : 'v3_active', + ); + expect( + JSON.parse( + await readFile( + migrationStagePath(caseRoot, prepared.stageId), + 'utf8', + ), + ).state, + ).toBe( + fixture.expectedRecovery === 'safe_abort' ? 'staged' : 'activated', + ); + } + }, 20_000); +}); + +async function prepareActivationCase(projectRoot: string): Promise<{ + stageId: string; + stageRevision: number; + sessionId: string; +}> { + await mkdir(projectRoot, { recursive: true }); + await writeLegacyFixture(projectRoot); + const staged = await stageLegacyMigration({ projectRoot }); + const resolved = await resolveLegacyMigration({ + projectRoot, + stageId: staged.stageId, + legacyTaskId: LEGACY_TASK_ID, + expectedStageRevision: staged.revision, + ownerActorId: OWNER_ID, + }); + const actor = await createLocalActor(projectRoot, { + actorId: OWNER_ID, + displayName: 'Migration crash owner', + }); + await publishSharedActorProfile(projectRoot, createSharedActorProfile(actor)); + const session = await createSession(projectRoot, { + actorId: actor.actorId, + client: 'crash-test', + identitySource: 'explicit', + }); + return { + stageId: resolved.stageId, + stageRevision: resolved.revision, + sessionId: session.sessionId, + }; +} + +async function writeLegacyFixture(root: string): Promise { + const workflowRoot = path.join(root, '.mancode', 'workflows', LEGACY_TASK_ID); + await mkdir(path.join(workflowRoot, 'reports'), { recursive: true }); + await writeFile( + path.join(root, '.mancode', 'state.json'), + `${JSON.stringify( + { + version: '0.3.9', + currentMode: 'man', + lastMode: 'solo', + platform: 'claude-code', + initializedAt: '2026-07-17T09:00:00.000Z', + techStack: 'TypeScript', + uiLibrary: 'None', + currentTask: LEGACY_TASK_ID, + currentWorkflowMode: 'man', + skippedSteps: [], + activeSoloPlan: null, + teamModeAutoDetected: false, + contributors: 1, + projectMode: 'detected', + }, + null, + 2, + )}\n`, + ); + await writeFile( + path.join(workflowRoot, 'metadata.json'), + `${JSON.stringify( + { + taskId: LEGACY_TASK_ID, + task: 'Add login rate limits.', + mode: 'man', + currentStep: 9, + skippedSteps: [], + startedAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T11:00:00.000Z', + status: 'in_progress', + planVersion: 2, + planningPolicyVersion: 2, + reviewPolicyVersion: 2, + verificationPolicyVersion: 1, + requirementsStatus: 'ready', + requirementsDigest: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + planDecision: 'governed_execution', + verificationStatus: 'passed', + }, + null, + 2, + )}\n`, + ); + await writeFile( + path.join(workflowRoot, 'requirements.json'), + `${JSON.stringify( + { + version: 1, + goal: 'Protect the login endpoint from repeated failed attempts.', + confirmedScope: ['Protect the login endpoint.'], + excludedScope: ['Change account recovery.'], + technicalDecisions: ['Use the existing Redis client.'], + defaults: ['Use the project test runner.'], + blockingUnknowns: [], + coverage: [ + 'platform', + 'core_scope', + 'technical_stack', + 'data_and_persistence', + 'performance', + 'compatibility', + 'security', + ].map((dimension) => ({ + dimension, + status: 'confirmed', + rationale: `${dimension} is covered.`, + })), + acceptanceCriteria: [ + { + id: 'AC-1', + description: 'Repeated failures receive a rate-limit response.', + required: true, + method: 'hybrid', + }, + ], + }, + null, + 2, + )}\n`, + ); + await writeFile( + path.join(workflowRoot, 'review-ledger.json'), + `${JSON.stringify( + { + version: '1.0', + depth: 'full', + requiredDomains: ['quality', 'security'], + completedDomains: ['quality', 'security'], + reports: { + quality: 'reports/quality.md', + security: 'reports/security.md', + }, + blockers: [], + remediationRounds: 0, + }, + null, + 2, + )}\n`, + ); + await writeFile( + path.join(workflowRoot, 'reports', 'quality.md'), + '# Quality\n', + ); + await writeFile( + path.join(workflowRoot, 'reports', 'security.md'), + '# Security\n', + ); + await writeFile( + path.join(workflowRoot, 'reports', 'evidence.md'), + '# Evidence\n', + ); + await writeFile( + path.join(workflowRoot, 'verification-ledger.json'), + `${JSON.stringify( + { + version: 1, + planVersion: 2, + requirementsDigest: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + remediationRound: 0, + status: 'passed', + checks: [ + { + acceptanceId: 'AC-1', + required: true, + automated: { + status: 'passed', + evidence: 'Automated checks passed.', + updatedAt: '2026-07-17T11:00:00.000Z', + command: 'npm test', + exitCode: 0, + evidenceFile: 'reports/evidence.md', + }, + manual: { + status: 'passed', + evidence: 'A legacy reviewer confirmed the behavior.', + updatedAt: '2026-07-17T11:00:00.000Z', + }, + }, + ], + }, + null, + 2, + )}\n`, + ); +} + +async function readLegacyAuthorityBytes( + root: string, +): Promise> { + const taskRoot = path.join(root, '.mancode', 'workflows', LEGACY_TASK_ID); + const files = [ + path.join(root, '.mancode', 'state.json'), + path.join(taskRoot, 'metadata.json'), + path.join(taskRoot, 'requirements.json'), + path.join(taskRoot, 'review-ledger.json'), + path.join(taskRoot, 'verification-ledger.json'), + path.join(taskRoot, 'reports', 'quality.md'), + path.join(taskRoot, 'reports', 'security.md'), + path.join(taskRoot, 'reports', 'evidence.md'), + ]; + const entries = await Promise.all( + files.map( + async (file) => + [path.relative(root, file), await readFile(file, 'utf8')] as const, + ), + ); + return Object.fromEntries(entries); +} diff --git a/tests/migration-parity-contracts.test.ts b/tests/migration-parity-contracts.test.ts new file mode 100644 index 0000000..fff7a1a --- /dev/null +++ b/tests/migration-parity-contracts.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest'; +import type { MancodeState } from '../src/commands/init.js'; +import { + type LegacyTaskMigrationInput, + assertMigrationParity, + createDeterministicMigrationIdAllocator, + createMigrationParityReport, + migrateLegacyTaskToV3, +} from '../src/context/migration-parity.js'; +import { + type WorkflowMetadataV3, + assertParentWorkflowRelation, +} from '../src/context/workflow-metadata.js'; + +const TASK_ID = '20260717-120000-login-rate-limit'; +const PARENT_TASK_ID = '20260717-120001-team-rate-limit'; +const CHILD_TASK_ID = '20260717-120002-diagnose-rate-limit'; +const TASK_ULID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const PARENT_ULID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const CHILD_ULID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const LEGACY_REQUIREMENTS_DIGEST = 'a'.repeat(64); +const SOURCE_DIGESTS = { + metadata: `sha256:${'b'.repeat(64)}`, + requirements: `sha256:${'c'.repeat(64)}`, + review: `sha256:${'d'.repeat(64)}`, + verification: `sha256:${'e'.repeat(64)}`, +}; + +describe('legacy to V3 migration parity contract', () => { + it('maps all governed legacy fields into a parser-valid V3 candidate', () => { + const input = migrationInput(); + const candidate = migrateLegacyTaskToV3(input); + const report = createMigrationParityReport(input, candidate, input.aliases); + + expect(candidate.metadata.workflowMode).toBe('man'); + expect(candidate.verification.checks[0]?.manual?.confirmationSource).toBe( + 'legacy_migration', + ); + expect(candidate.artifactAliases).toHaveLength(3); + expect(report.unmappedFields).toEqual([]); + expect(report.legacyGate).toEqual(report.v3Gate); + expect(report.contextPackShadow.comparisons).toHaveLength(6); + expect(report.contextPackShadow.comparisons).toEqual( + expect.arrayContaining([ + expect.objectContaining({ purpose: 'plan', matched: true }), + expect.objectContaining({ purpose: 'handoff', matched: true }), + ]), + ); + expect(() => assertMigrationParity(report)).not.toThrow(); + }); + + it('turns any semantic drift into an activation blocker', () => { + const input = migrationInput(); + const candidate = migrateLegacyTaskToV3(input); + const changed: WorkflowMetadataV3 = { + ...candidate.metadata, + governance: { + ...candidate.metadata.governance, + planVersion: candidate.metadata.governance.planVersion + 1, + }, + }; + const report = createMigrationParityReport( + input, + { ...candidate, metadata: changed }, + input.aliases, + ); + + expect(report.unmappedFields).toContain('workflow.planVersion'); + expect(report.activationBlockers).toContain('completionGate'); + expect(report.activationBlockers).toContain('contextPackShadow:plan'); + expect(() => assertMigrationParity(report)).toThrow( + /MANCODE_MIGRATION_PARITY_FAILED/, + ); + }); + + it('rejects unsafe legacy artifact paths instead of promoting them', () => { + const input = migrationInput(); + input.review.reports.quality = '/Users/alice/private-review.md'; + + expect(() => migrateLegacyTaskToV3(input)).toThrow( + /MANCODE_MIGRATION_ARTIFACT_PATH_UNSAFE/, + ); + }); + + it('stages a shared manteam parent and manba child with all three governed ledgers', () => { + const allocator = createDeterministicMigrationIdAllocator( + 'parent-child-migration-fixture', + ); + const aliases = { + [PARENT_TASK_ID]: { namespace: 'shared' as const, taskId: PARENT_ULID }, + [CHILD_TASK_ID]: { namespace: 'shared' as const, taskId: CHILD_ULID }, + }; + const parentInput = migrationInput(); + parentInput.workflow = { + ...parentInput.workflow, + taskId: PARENT_TASK_ID, + task: 'Coordinate shared rate-limit implementation.', + mode: 'manteam', + }; + parentInput.aliases = aliases; + parentInput.idAllocator = allocator; + parentInput.owner = { actorId: ACTOR_ID, participants: [ACTOR_ID] }; + parentInput.state = null; + const parent = migrateLegacyTaskToV3(parentInput); + + const childInput = migrationInput(); + childInput.workflow = { + ...childInput.workflow, + taskId: CHILD_TASK_ID, + task: 'Diagnose the rate-limit behavior.', + mode: 'mamba', + currentStep: 5, + parentTaskId: PARENT_TASK_ID, + }; + childInput.aliases = aliases; + childInput.idAllocator = allocator; + childInput.owner = { actorId: ACTOR_ID, participants: [ACTOR_ID] }; + childInput.parent = { + legacyTaskId: PARENT_TASK_ID, + metadata: parent.metadata, + }; + childInput.state = null; + const child = migrateLegacyTaskToV3(childInput); + + expect(child.metadata.taskRef).toEqual({ + namespace: 'shared', + taskId: CHILD_ULID, + }); + expect(child.metadata.parent?.taskRef).toEqual(parent.metadata.taskRef); + expect(child.requirements.taskRef).toEqual(child.metadata.taskRef); + expect(child.review.taskRef).toEqual(child.metadata.taskRef); + expect(child.verification.taskRef).toEqual(child.metadata.taskRef); + expect(() => + assertParentWorkflowRelation(child.metadata, parent.metadata), + ).not.toThrow(); + }); +}); + +function migrationInput(): LegacyTaskMigrationInput { + return { + workflow: { + taskId: TASK_ID, + task: 'Add login rate limits.', + mode: 'man', + currentStep: 9, + skippedSteps: [], + startedAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T11:00:00.000Z', + status: 'in_progress', + planVersion: 2, + planningPolicyVersion: 2, + reviewPolicyVersion: 2, + verificationPolicyVersion: 1, + requirementsStatus: 'ready', + requirementsDigest: LEGACY_REQUIREMENTS_DIGEST, + planDecision: 'governed_execution', + verificationStatus: 'passed', + }, + requirements: { + version: 1, + goal: 'Protect the login endpoint from repeated failed attempts.', + confirmedScope: ['Protect the login endpoint.'], + excludedScope: ['Change account recovery.'], + technicalDecisions: ['Use the existing Redis client.'], + defaults: ['Use the project test runner.'], + blockingUnknowns: [], + coverage: [ + 'platform', + 'core_scope', + 'technical_stack', + 'data_and_persistence', + 'performance', + 'compatibility', + 'security', + ].map((dimension) => ({ + dimension: + dimension as LegacyTaskMigrationInput['requirements']['coverage'][number]['dimension'], + status: 'confirmed' as const, + rationale: `${dimension} is covered.`, + })), + acceptanceCriteria: [ + { + id: 'AC-1', + description: 'Repeated failures receive a rate-limit response.', + required: true, + method: 'hybrid', + }, + ], + }, + review: { + version: '1.0', + depth: 'full', + requiredDomains: ['quality', 'security'], + completedDomains: ['quality', 'security'], + reports: { + quality: 'reports/quality.md', + security: 'reports/security.md', + }, + blockers: [], + remediationRounds: 0, + }, + verification: { + version: 1, + planVersion: 2, + requirementsDigest: LEGACY_REQUIREMENTS_DIGEST, + remediationRound: 0, + status: 'passed', + checks: [ + { + acceptanceId: 'AC-1', + required: true, + automated: { + status: 'passed', + evidence: 'Automated checks passed.', + updatedAt: '2026-07-17T11:00:00.000Z', + command: 'npm test', + exitCode: 0, + evidenceFile: 'reports/evidence.md', + }, + manual: { + status: 'passed', + evidence: 'A legacy reviewer confirmed the behavior.', + updatedAt: '2026-07-17T11:00:00.000Z', + }, + }, + ], + }, + state: legacyState(), + sourceDigests: SOURCE_DIGESTS, + aliases: { [TASK_ID]: { namespace: 'local', taskId: TASK_ULID } }, + idAllocator: createDeterministicMigrationIdAllocator('migration-fixture'), + owner: { actorId: ACTOR_ID }, + parent: null, + }; +} + +function legacyState(): Partial { + return { + version: '0.3.9', + currentMode: 'man', + lastMode: 'solo', + platform: 'claude-code', + initializedAt: '2026-07-17T09:00:00.000Z', + techStack: 'TypeScript', + uiLibrary: 'none', + currentTask: TASK_ID, + currentWorkflowMode: 'man', + skippedSteps: [], + activeSoloPlan: null, + teamModeAutoDetected: false, + contributors: 1, + projectMode: 'detected', + }; +} diff --git a/tests/operation-crash-matrix-contracts.test.ts b/tests/operation-crash-matrix-contracts.test.ts new file mode 100644 index 0000000..b6906df --- /dev/null +++ b/tests/operation-crash-matrix-contracts.test.ts @@ -0,0 +1,311 @@ +import { mkdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { taskRootPath } from '../src/context/task-locator.js'; +import type { TaskRef } from '../src/context/task-ref.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { + throwIfOperationCrashInjected, + withOperationCrashInjectionForTesting, +} from '../src/runtime/operation-crash-injection.js'; +import { + OPERATION_CRASH_FIXTURES, + OPERATION_DEFINITIONS, + assertOperationJournalMatchesDefinition, +} from '../src/runtime/operation-definition.js'; +import type { + OperationJournalV1, + OperationType, +} from '../src/runtime/operation-journal.js'; +import { executeOperationRecovery } from '../src/runtime/operation-recovery-executor.js'; +import { + assertOperationRecoveryPayloadCoversJournal, + createTaskAuthorityFileRecoveryAction, + operationRecoveryPayloadDigest, + parseOperationRecoveryPayload, +} from '../src/runtime/operation-recovery-payload.js'; +import { writeOperationRecoveryPayload } from '../src/runtime/operation-recovery-store.js'; +import { createPreparedOperationJournal } from '../src/runtime/operation-store.js'; +import { createSession } from '../src/runtime/session.js'; +import { + advanceTaskOperation, + commitTaskOperation, + openV3TaskOperation, + taskEntityKey, + writeTaskAuthorityFile, +} from '../src/runtime/task-operation.js'; +import { createLocalActor } from '../src/team/actor.js'; + +const NOW = new Date('2026-07-18T08:00:00.000Z'); + +describe('operation crash recovery matrix', () => { + let root: string; + let actorId: Ulid; + let sessionId: Ulid; + let taskRef: TaskRef; + let nextIdOffset: number; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-operation-crash-matrix-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await initializeV3Project({ + projectRoot: root, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + actorId = id(4); + sessionId = id(5); + taskRef = { namespace: 'local', taskId: id(6) }; + nextIdOffset = 20; + await createLocalActor(root, { + actorId, + displayName: 'Crash Matrix User', + now: NOW, + }); + await createSession(root, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + await createV3Workflow({ + projectRoot: root, + task: 'Exercise every durable operation crash boundary.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: taskRef.taskId, + operationId: id(7), + now: NOW, + }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('executes safe abort or forward repair at every declared crash point', async () => { + let exercised = 0; + for (const definition of Object.values(OPERATION_DEFINITIONS)) { + for (const fixture of OPERATION_CRASH_FIXTURES[definition.type]) { + exercised += 1; + const operationId = nextId(); + const initialPlan = await readOptionalPlan(root, taskRef); + const businessSteps = definition.steps.filter( + (step) => step.visibility === 'business_write', + ); + let beforeContent = initialPlan; + const actions = businessSteps.map((step, index) => { + const targetContent = `# Crash matrix\n\n${definition.type}:${fixture.crashAfter}:${index}\n`; + const action = createTaskAuthorityFileRecoveryAction({ + stepId: step.id, + taskRef, + fileName: 'plan.md', + beforeContent, + targetContent, + }); + beforeContent = targetContent; + return action; + }); + const actionByStep = new Map( + actions.map((action) => [action.stepId, action]), + ); + const requiredLocks = requiredKeys(definition.type, taskRef, 'locks'); + const expectedRevisions = Object.fromEntries( + requiredKeys(definition.type, taskRef, 'revisions').map((key) => [ + key, + key === taskEntityKey(taskRef) ? 1 : 0, + ]), + ); + const context = await openV3TaskOperation({ + projectRoot: root, + taskRef, + sessionId, + expectedTaskRevision: 1, + operationId, + extraEntityLocks: requiredLocks.filter( + (key) => key !== taskEntityKey(taskRef), + ), + now: new Date(NOW.getTime() + nextIdOffset), + }); + try { + const payload = parseOperationRecoveryPayload({ + schemaVersion: 1, + operationId, + type: definition.type, + primaryStoreId: context.homeStore.storeId, + actions, + noOpStepIds: [], + }); + const prepared: OperationJournalV1 = { + schemaVersion: 1, + operationId, + type: definition.type, + state: 'prepared', + primaryStoreId: context.homeStore.storeId, + checkoutId: context.runtime.checkoutId, + secondaryReservations: [], + actorId, + sessionId, + authorizationBasis: { + schemaVersion: 1, + action: definition.authorizationActions[0], + actorId, + sessionId, + trustBoundary: 'repo-collaborators', + decisionDigest: `sha256:${'a'.repeat(64)}`, + authorizedAt: NOW.toISOString(), + }, + recoveryPayloadDigest: operationRecoveryPayloadDigest(payload), + entityLocks: context.entityLocks, + expectedRevisions, + steps: definition.steps.map((step) => ({ + id: step.id, + state: 'pending', + })), + startedAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }; + assertOperationJournalMatchesDefinition(prepared); + assertOperationRecoveryPayloadCoversJournal(prepared, payload); + + await expect( + withOperationCrashInjectionForTesting(fixture, async () => { + await writeOperationRecoveryPayload(context.homeStore, payload); + let journal = await createPreparedOperationJournal( + context.homeStore, + prepared, + ); + if (fixture.crashAfter === 'prepared') { + throwIfOperationCrashInjected(definition.type, 'prepared'); + } + for (const step of definition.steps) { + if (step.id === 'commit') { + journal = await commitTaskOperation(context, journal); + continue; + } + journal = await advanceTaskOperation( + context, + journal, + step.id, + step.visibility === 'preparation', + ); + if (step.visibility === 'business_write') { + const action = actionByStep.get(step.id); + if (action === undefined) { + throw new Error('missing crash matrix recovery action'); + } + await writeTaskAuthorityFile( + context, + 'plan.md', + action.targetContent, + ); + } + } + return journal; + }), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + } finally { + await context.release(); + } + + const recovered = await executeOperationRecovery({ + projectRoot: root, + operationId, + actorId, + sessionId, + mode: fixture.expectedRecovery === 'safe_abort' ? 'abort' : 'repair', + }); + if (fixture.crashAfter === 'commit') { + expect(recovered).toMatchObject({ + state: 'already_terminal', + journal: { state: 'committed' }, + }); + } else if (fixture.expectedRecovery === 'safe_abort') { + expect(recovered).toMatchObject({ + state: 'aborted', + journal: { state: 'aborted' }, + }); + expect(await readOptionalPlan(root, taskRef)).toBe(initialPlan); + } else { + expect(recovered).toMatchObject({ + state: 'repaired', + journal: { state: 'committed' }, + }); + expect(await readOptionalPlan(root, taskRef)).toBe( + actions.at(-1)?.targetContent, + ); + } + } + } + + expect(exercised).toBeGreaterThan( + Object.keys(OPERATION_DEFINITIONS).length, + ); + }, 45_000); + + function nextId(): Ulid { + const value = id(nextIdOffset); + nextIdOffset += 1; + return value; + } +}); + +function requiredKeys( + type: OperationType, + taskRef: TaskRef, + family: 'locks' | 'revisions', +): string[] { + const definition = OPERATION_DEFINITIONS[type]; + const prefixes = new Set( + definition.steps.flatMap((step) => + family === 'locks' + ? step.requiredLockPrefixes + : step.expectedRevisionPrefixes, + ), + ); + prefixes.add('task:'); + return [...prefixes].map((prefix) => + prefix === 'task:' ? taskEntityKey(taskRef) : `${prefix}${taskRef.taskId}`, + ); +} + +async function readOptionalPlan( + root: string, + taskRef: TaskRef, +): Promise { + try { + return await readFile( + path.join(taskRootPath(root, taskRef), 'plan.md'), + 'utf8', + ); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) { + return null; + } + throw error; + } +} + +function id(offset: number): Ulid { + return createUlid( + NOW.getTime() + offset, + new Uint8Array(10).fill((offset % 251) + 1), + ); +} diff --git a/tests/operation-definition-contracts.test.ts b/tests/operation-definition-contracts.test.ts new file mode 100644 index 0000000..b74c339 --- /dev/null +++ b/tests/operation-definition-contracts.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { + OPERATION_CRASH_FIXTURES, + OPERATION_DEFINITIONS, + assertOperationJournalMatchesDefinition, + getOperationDefinition, +} from '../src/runtime/operation-definition.js'; +import type { OperationJournalV1 } from '../src/runtime/operation-journal.js'; + +const ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; + +describe('machine-readable operation definitions', () => { + it('gives every hard-consistency operation steps, a visible commit point, and crash recovery fixtures', () => { + expect(Object.keys(OPERATION_DEFINITIONS)).toHaveLength(22); + for (const definition of Object.values(OPERATION_DEFINITIONS)) { + expect(definition.steps.at(-1)).toMatchObject({ + id: 'commit', + visibility: 'commit', + }); + expect( + definition.steps.some( + (step) => step.id === definition.primaryCommitStep, + ), + ).toBe(true); + const fixtures = OPERATION_CRASH_FIXTURES[definition.type]; + expect(fixtures[0]).toMatchObject({ + crashAfter: 'prepared', + expectedRecovery: 'safe_abort', + }); + expect(fixtures).toHaveLength(definition.steps.length + 1); + expect( + fixtures + .slice(1) + .every( + (fixture) => + fixture.expectedRecovery === 'forward_repair' || + fixture.expectedRecovery === 'safe_abort', + ), + ).toBe(true); + } + }); + + it('requires journals to carry the revision and lock families declared by their operation', () => { + const definition = getOperationDefinition('verification_record'); + const journal = journalForDefinition(definition.type); + expect(() => + assertOperationJournalMatchesDefinition(journal), + ).not.toThrow(); + expect(() => + assertOperationJournalMatchesDefinition({ + ...journal, + expectedRevisions: { [`task:${ID}`]: 1 }, + }), + ).toThrow(/missing verification:/); + expect(() => + assertOperationJournalMatchesDefinition({ + ...journal, + authorizationBasis: { + ...journal.authorizationBasis, + action: 'claim_create', + }, + }), + ).toThrow('MANCODE_OPERATION_AUTHORIZATION_ACTION_MISMATCH'); + }); + + it('requires the task-head fence only when a fenced shared task is mutated', () => { + const definition = getOperationDefinition('verification_record'); + const localJournal = journalForDefinition(definition.type); + const { [`task_head:${ID}`]: _localFence, ...localExpectedRevisions } = + localJournal.expectedRevisions; + + expect(() => + assertOperationJournalMatchesDefinition({ + ...localJournal, + expectedRevisions: localExpectedRevisions, + }), + ).not.toThrow(); + + const sharedJournal = journalForDefinition(definition.type, 'shared'); + expect(() => + assertOperationJournalMatchesDefinition(sharedJournal), + ).not.toThrow(); + + const { [`task_head:${ID}`]: _sharedFence, ...sharedExpectedRevisions } = + sharedJournal.expectedRevisions; + expect(() => + assertOperationJournalMatchesDefinition({ + ...sharedJournal, + expectedRevisions: sharedExpectedRevisions, + }), + ).toThrow(/missing task_head:/); + expect(() => + assertOperationJournalMatchesDefinition({ + ...sharedJournal, + entityLocks: sharedJournal.entityLocks.filter( + (key) => !key.startsWith('task_head:'), + ), + }), + ).toThrow(/entity locks are missing task_head:/); + }); + + it('allows task completion with no claims but pairs every claimed revision with its lock', () => { + const journal = journalForDefinition('task_complete'); + expect(() => + assertOperationJournalMatchesDefinition(journal), + ).not.toThrow(); + + const claimKey = `claim:${ID}`; + const withClaim: OperationJournalV1 = { + ...journal, + expectedRevisions: { ...journal.expectedRevisions, [claimKey]: 1 }, + entityLocks: [...journal.entityLocks, claimKey], + }; + expect(() => + assertOperationJournalMatchesDefinition(withClaim), + ).not.toThrow(); + expect(() => + assertOperationJournalMatchesDefinition({ + ...withClaim, + entityLocks: withClaim.entityLocks.filter((key) => key !== claimKey), + }), + ).toThrow(/entity locks are missing optional claim:/); + expect(() => + assertOperationJournalMatchesDefinition({ + ...withClaim, + expectedRevisions: journal.expectedRevisions, + }), + ).toThrow(/expected revisions are missing optional claim:/); + }); +}); + +function journalForDefinition( + type: keyof typeof OPERATION_DEFINITIONS, + namespace: 'local' | 'shared' = 'local', +): OperationJournalV1 { + const definition = getOperationDefinition(type); + const revisionPrefixes = [ + ...new Set( + definition.steps.flatMap((step) => step.expectedRevisionPrefixes), + ), + ]; + const lockPrefixes = [ + ...new Set(definition.steps.flatMap((step) => step.requiredLockPrefixes)), + ]; + return { + schemaVersion: 1, + operationId: ID, + type, + state: 'prepared', + primaryStoreId: `workspace:${ID}`, + checkoutId: ID, + secondaryReservations: [], + actorId: ID, + sessionId: ID, + authorizationBasis: { + schemaVersion: 1, + action: definition.authorizationActions[0] ?? 'local_workflow_mutation', + actorId: ID, + sessionId: ID, + trustBoundary: 'repo-collaborators', + decisionDigest: `sha256:${'a'.repeat(64)}`, + authorizedAt: '2026-07-17T10:00:00.000Z', + }, + entityLocks: [ + ...lockPrefixes.map((prefix) => + prefix === 'task:' ? `${prefix}${namespace}:${ID}` : `${prefix}${ID}`, + ), + ...(namespace === 'shared' ? [`task_head:${ID}`] : []), + ], + expectedRevisions: Object.fromEntries( + revisionPrefixes.map((prefix) => [`${prefix}${ID}`, 1]), + ), + steps: definition.steps.map((step) => ({ id: step.id, state: 'pending' })), + startedAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} diff --git a/tests/operation-journal-contracts.test.ts b/tests/operation-journal-contracts.test.ts new file mode 100644 index 0000000..019e364 --- /dev/null +++ b/tests/operation-journal-contracts.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { + type OperationJournalV1, + assertOperationJournalTransition, + parseOperationJournal, +} from '../src/runtime/operation-journal.js'; + +const ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('operation journal contract', () => { + it('rejects malformed entity locks, reservations, and steps before they can become durable', () => { + expect(() => + parseOperationJournal({ + ...journal(), + entityLocks: ['task:shared:one', 'task:shared:one'], + }), + ).toThrow(/duplicates/); + expect(() => + parseOperationJournal({ + ...journal(), + secondaryReservations: [ + { + storeId: 'local:one', + entityKeys: ['task:shared:one'], + journalDigest: DIGEST, + }, + { + storeId: 'local:one', + entityKeys: ['task:shared:two'], + journalDigest: DIGEST, + }, + ], + }), + ).toThrow(/repeat a store/); + expect(() => + parseOperationJournal({ + ...journal(), + steps: [], + }), + ).toThrow(/non-empty/); + expect(() => + parseOperationJournal({ + ...journal(), + state: 'committed', + }), + ).toThrow(/every step/); + expect(() => + parseOperationJournal({ + ...journal(), + state: 'applying', + steps: [ + { id: 'validate', state: 'pending' }, + { id: 'write', state: 'completed' }, + ], + }), + ).toThrow(/contiguous prefix/); + }); + + it('only moves forward through prepared, applying, repair, and committed states', () => { + const prepared = parseOperationJournal(journal()); + const applying = parseOperationJournal({ ...prepared, state: 'applying' }); + const repair = parseOperationJournal({ + ...applying, + state: 'repair_required', + }); + const committed = parseOperationJournal({ + ...repair, + state: 'committed', + steps: repair.steps.map((step) => ({ + ...step, + state: 'completed' as const, + })), + }); + expect(() => + assertOperationJournalTransition(prepared, applying, { canAbort: false }), + ).not.toThrow(); + expect(() => + assertOperationJournalTransition(applying, repair, { canAbort: false }), + ).not.toThrow(); + expect(() => + assertOperationJournalTransition(repair, committed, { canAbort: false }), + ).not.toThrow(); + expect(() => + assertOperationJournalTransition(prepared, committed, { + canAbort: false, + }), + ).toThrow(/invalid operation state transition/); + }); + + it('cannot commit incomplete work, regress a completed step, or abort after an external write', () => { + const applying = parseOperationJournal({ ...journal(), state: 'applying' }); + const completedStep = parseOperationJournal({ + ...applying, + steps: [ + { id: 'validate', state: 'completed' }, + { id: 'write', state: 'pending' }, + ], + }); + const regressedStep = parseOperationJournal({ + ...completedStep, + steps: [ + { id: 'validate', state: 'pending' }, + { id: 'write', state: 'pending' }, + ], + }); + expect(() => + assertOperationJournalTransition(completedStep, regressedStep, { + canAbort: false, + }), + ).toThrow(/cannot become pending/); + const aborted = parseOperationJournal({ ...applying, state: 'aborted' }); + expect(() => + assertOperationJournalTransition(applying, aborted, { canAbort: false }), + ).toThrow(/cannot abort/); + }); +}); + +function journal(): OperationJournalV1 { + return { + schemaVersion: 1, + operationId: ID, + type: 'handoff_accept', + state: 'prepared', + primaryStoreId: 'workspace:01JZ', + checkoutId: ID, + secondaryReservations: [], + actorId: ID, + sessionId: ID, + authorizationBasis: { + schemaVersion: 1, + action: 'handoff_accept_reject', + actorId: ID, + sessionId: ID, + trustBoundary: 'repo-collaborators', + decisionDigest: DIGEST, + authorizedAt: '2026-07-17T10:00:00.000Z', + }, + entityLocks: ['task:shared:01JZ', 'handoff:01JY'], + expectedRevisions: { 'task:shared:01JZ': 7, 'handoff:01JY': 2 }, + steps: [ + { id: 'validate', state: 'pending' }, + { id: 'write', state: 'pending' }, + ], + startedAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} diff --git a/tests/operation-recovery-executor-contracts.test.ts b/tests/operation-recovery-executor-contracts.test.ts new file mode 100644 index 0000000..5e6fa43 --- /dev/null +++ b/tests/operation-recovery-executor-contracts.test.ts @@ -0,0 +1,353 @@ +import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { taskRootPath } from '../src/context/task-locator.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { readLocalDiagnostics } from '../src/runtime/diagnostics.js'; +import { resolveLocalEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { getOperationDefinition } from '../src/runtime/operation-definition.js'; +import type { OperationJournalV1 } from '../src/runtime/operation-journal.js'; +import { + executeOperationRecovery, + inspectOperationRecovery, +} from '../src/runtime/operation-recovery-executor.js'; +import { + createTaskAuthorityFileRecoveryAction, + createWorkflowTaskDirectoryRecoveryAction, + operationRecoveryPayloadDigest, + parseOperationRecoveryPayload, +} from '../src/runtime/operation-recovery-payload.js'; +import { writeOperationRecoveryPayload } from '../src/runtime/operation-recovery-store.js'; +import { createPreparedOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { + advanceTaskOperation, + createTaskOperationJournal, + openV3TaskOperation, + serializeTaskAuthority, + taskEntityKey, + writeTaskAuthorityFile, +} from '../src/runtime/task-operation.js'; +import { createLocalActor } from '../src/team/actor.js'; +import { createAuthorizationBasis } from '../src/team/authorization.js'; + +const NOW = new Date('2026-07-17T13:00:00.000Z'); + +describe('operation recovery executor', () => { + let root: string; + let actorId: Ulid; + let sessionId: Ulid; + let taskId: Ulid; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-operation-recovery-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await initializeV3Project({ + projectRoot: root, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + actorId = id(4); + sessionId = id(5); + taskId = id(6); + await createLocalActor(root, { + actorId, + displayName: 'Recovery User', + now: NOW, + }); + await createSession(root, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + await createV3Workflow({ + projectRoot: root, + task: 'Exercise exact operation recovery targets.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId, + operationId: id(7), + now: NOW, + }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('replays only durable targets after a visible write and commits idempotently', async () => { + const operationId = id(8); + const plan = + '# Recovery plan\n\nResume from the durable write-ahead target.\n'; + const journal = await prepareInterruptedPlan(operationId, plan, true); + + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId, + sessionId, + mode: 'abort', + }), + ).rejects.toThrow('MANCODE_OPERATION_ABORT_UNSAFE'); + + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId, + sessionId, + }), + ).resolves.toMatchObject({ + state: 'repaired', + journal: { operationId, state: 'committed' }, + }); + await expect( + readFile( + path.join( + taskRootPath(root, { namespace: 'local', taskId }), + 'plan.md', + ), + 'utf8', + ), + ).resolves.toBe(plan); + await expect( + inspectOperationRecovery(root, operationId), + ).resolves.toMatchObject({ + journal: { state: 'committed' }, + payloadBound: true, + }); + await expect(readLocalDiagnostics(root)).resolves.toMatchObject({ + repairOperationCount: 1, + }); + expect(journal.recoveryPayloadDigest).toMatch(/^sha256:/); + }); + + it('aborts a durable write intent only when every target remains at its initial state', async () => { + const operationId = id(9); + await prepareInterruptedPlan(operationId, '# Unwritten plan\n', false); + + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId, + sessionId, + mode: 'abort', + }), + ).resolves.toMatchObject({ + state: 'aborted', + journal: { operationId, state: 'aborted' }, + }); + }); + + it('removes only the abandoned private workflow staging directory before a safe abort', async () => { + const operationId = id(10); + const recoveryTaskId = id(11); + const taskRef = { namespace: 'local' as const, taskId: recoveryTaskId }; + const runtime = await readProjectRuntimeContext(root); + const store = resolveLocalEntityHomeStore(runtime.entityHomeStoreContext); + const sourceRoot = taskRootPath(root, { + namespace: 'local', + taskId, + }); + const files = await Promise.all( + [ + 'metadata.json', + 'requirements.json', + 'review-ledger.json', + 'verification-ledger.json', + ].map(async (fileName) => ({ + fileName, + content: await readFile(path.join(sourceRoot, fileName), 'utf8'), + })), + ); + const payload = parseOperationRecoveryPayload({ + schemaVersion: 1, + operationId, + type: 'workflow_create', + primaryStoreId: store.storeId, + actions: [ + createWorkflowTaskDirectoryRecoveryAction({ + stepId: 'publish-task-directory', + taskRef, + files: files as Array<{ + fileName: + | 'metadata.json' + | 'requirements.json' + | 'review-ledger.json' + | 'verification-ledger.json'; + content: string; + }>, + }), + ], + noOpStepIds: ['publish-locator'], + }); + await writeOperationRecoveryPayload(store, payload); + const definition = getOperationDefinition('workflow_create'); + const journal: OperationJournalV1 = { + schemaVersion: 1, + operationId, + type: 'workflow_create', + state: 'prepared', + primaryStoreId: store.storeId, + checkoutId: runtime.checkoutId, + secondaryReservations: [], + actorId, + sessionId, + authorizationBasis: createAuthorizationBasis( + { + action: 'local_workflow_mutation', + actorId, + session: { sessionId, actorId, status: 'active' }, + joined: false, + sharedWriteGuard: 'enforced', + task: null, + claim: null, + handoff: null, + evidence: null, + profileActorId: null, + conditions: { expectedRevisionMatches: true }, + }, + NOW, + ), + recoveryPayloadDigest: operationRecoveryPayloadDigest(payload), + entityLocks: [ + `task:local:${recoveryTaskId}`, + `locator:local:${recoveryTaskId}`, + ], + expectedRevisions: { + [`task:local:${recoveryTaskId}`]: 0, + [`locator:local:${recoveryTaskId}`]: 0, + }, + steps: definition.steps.map((step) => ({ + id: step.id, + state: 'pending', + })), + startedAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }; + await createPreparedOperationJournal(store, journal); + const staging = path.join( + path.dirname(taskRootPath(root, taskRef)), + `.${recoveryTaskId}.${operationId}.staging`, + ); + await mkdir(staging); + await writeFile(path.join(staging, '.partial'), 'private staging\n'); + + await expect( + executeOperationRecovery({ + projectRoot: root, + operationId, + actorId, + sessionId, + mode: 'abort', + }), + ).resolves.toMatchObject({ + state: 'aborted', + journal: { operationId, state: 'aborted' }, + }); + await expect(lstat(staging)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(lstat(taskRootPath(root, taskRef))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + async function prepareInterruptedPlan( + operationId: Ulid, + plan: string, + writePlan: boolean, + ) { + const taskRef = { namespace: 'local' as const, taskId }; + const context = await openV3TaskOperation({ + projectRoot: root, + taskRef, + sessionId, + expectedTaskRevision: 1, + operationId, + now: NOW, + }); + try { + const journal = await createTaskOperationJournal(context, { + type: 'plan_revision', + action: 'local_workflow_mutation', + expectedRevisions: { + [taskEntityKey(taskRef)]: context.task.metadata.revision, + [`plan:local:${taskId}`]: 0, + [`review:local:${taskId}`]: context.task.review.revision, + [`verification:local:${taskId}`]: context.task.verification.revision, + }, + recovery: { + actions: [ + createTaskAuthorityFileRecoveryAction({ + stepId: 'write-plan', + taskRef, + fileName: 'plan.md', + beforeContent: null, + targetContent: plan, + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'update-metadata', + taskRef, + fileName: 'metadata.json', + beforeContent: serializeTaskAuthority(context.task.metadata), + targetContent: serializeTaskAuthority(context.task.metadata), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-review-verification-stale', + taskRef, + fileName: 'review-ledger.json', + beforeContent: serializeTaskAuthority(context.task.review), + targetContent: serializeTaskAuthority(context.task.review), + }), + createTaskAuthorityFileRecoveryAction({ + stepId: 'mark-review-verification-stale', + taskRef, + fileName: 'verification-ledger.json', + beforeContent: serializeTaskAuthority(context.task.verification), + targetContent: serializeTaskAuthority(context.task.verification), + }), + ], + noOpStepIds: ['update-task-head-fence'], + }, + }); + const validated = await advanceTaskOperation( + context, + journal, + 'validate', + true, + ); + const intended = await advanceTaskOperation( + context, + validated, + 'write-plan', + false, + ); + if (writePlan) { + await writeTaskAuthorityFile(context, 'plan.md', plan); + } + return intended; + } finally { + await context.release(); + } + } +}); + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/operation-store-contracts.test.ts b/tests/operation-store-contracts.test.ts new file mode 100644 index 0000000..6756012 --- /dev/null +++ b/tests/operation-store-contracts.test.ts @@ -0,0 +1,156 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + resolveCoordinationEntityHomeStore, + resolveTaskEntityHomeStore, +} from '../src/runtime/entity-home-store.js'; +import { + type OperationJournalV1, + assertOperationReservationTopology, + withOperationReservationDigests, +} from '../src/runtime/operation-journal.js'; +import { readOperationReservation } from '../src/runtime/operation-reservation.js'; +import { + listUnfinishedOperationJournals, + prepareOperationStores, + readOperationJournal, + updateOperationJournal, +} from '../src/runtime/operation-store.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const CHECKOUT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7P'; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); +}); + +describe('operation primary journal and secondary reservation stores', () => { + it('persists a prepared primary journal before durable secondary reservations', async () => { + const { primary, secondary } = await stores(); + const journal = preparedJournal(primary.storeId, secondary.storeId); + const reservations = await prepareOperationStores({ + primaryStore: primary, + journal, + secondaryStores: [secondary], + now: new Date('2026-07-17T10:00:00.000Z'), + }); + expect(reservations).toHaveLength(1); + await expect(readOperationJournal(primary, OPERATION_ID)).resolves.toEqual( + journal, + ); + await expect( + readOperationReservation(secondary, OPERATION_ID), + ).resolves.toMatchObject({ + operationId: OPERATION_ID, + primaryStoreId: primary.storeId, + entityKeys: [`task:shared:${TASK_ID}`], + }); + + const applying: OperationJournalV1 = { + ...journal, + state: 'applying', + updatedAt: '2026-07-17T10:01:00.000Z', + }; + await expect( + updateOperationJournal(primary, applying, { canAbort: false }), + ).resolves.toEqual(applying); + await expect(listUnfinishedOperationJournals(primary)).resolves.toEqual([ + applying, + ]); + }); + + it('requires each reservation to point to the stable prepared journal identity', async () => { + const { primary, secondary } = await stores(); + const journal = preparedJournal(primary.storeId, secondary.storeId); + expect(() => assertOperationReservationTopology(journal)).not.toThrow(); + expect(() => + assertOperationReservationTopology({ + ...journal, + secondaryReservations: journal.secondaryReservations.map( + (reservation) => ({ + ...reservation, + journalDigest: `sha256:${'0'.repeat(64)}`, + }), + ), + }), + ).toThrow(/does not match the prepared journal identity/); + }); +}); + +async function stores() { + const projectRoot = await temporaryRoot(); + const context = { + projectRoot, + workspaceId: WORKSPACE_ID, + checkoutId: CHECKOUT_ID, + gitCommonDir: null, + repositoryBindingId: null, + }; + return { + primary: resolveTaskEntityHomeStore(context, { + namespace: 'local', + taskId: TASK_ID, + }), + secondary: resolveCoordinationEntityHomeStore(context), + }; +} + +function preparedJournal( + primaryStoreId: string, + secondaryStoreId: string, +): OperationJournalV1 { + return withOperationReservationDigests({ + schemaVersion: 1, + operationId: OPERATION_ID, + type: 'publish_promote', + state: 'prepared', + primaryStoreId, + checkoutId: CHECKOUT_ID, + secondaryReservations: [ + { + storeId: secondaryStoreId, + entityKeys: [`task:shared:${TASK_ID}`], + journalDigest: `sha256:${'a'.repeat(64)}`, + }, + ], + actorId: ACTOR_ID, + sessionId: SESSION_ID, + authorizationBasis: { + schemaVersion: 1, + action: 'shared_create_publish_promote', + actorId: ACTOR_ID, + sessionId: SESSION_ID, + trustBoundary: 'repo-collaborators', + decisionDigest: `sha256:${'b'.repeat(64)}`, + authorizedAt: '2026-07-17T10:00:00.000Z', + }, + entityLocks: [`task:local:${TASK_ID}`, `task:shared:${TASK_ID}`], + expectedRevisions: { + [`task:local:${TASK_ID}`]: 1, + [`task:shared:${TASK_ID}`]: 0, + }, + steps: [ + { id: 'validate', state: 'pending' }, + { id: 'publish', state: 'pending' }, + ], + startedAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }); +} + +async function temporaryRoot(): Promise { + const root = await mkdtemp( + path.join(os.tmpdir(), 'mancode-operation-store-'), + ); + roots.push(root); + return root; +} diff --git a/tests/platform-spike-contracts.test.ts b/tests/platform-spike-contracts.test.ts new file mode 100644 index 0000000..11790fa --- /dev/null +++ b/tests/platform-spike-contracts.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createPlatformSessionSpike, + evaluatePlatformSessionCapability, + platformSpikeFreezeStatus, + probeSessionEnvironmentPropagation, +} from '../src/runtime/platform-spike.js'; +import { createSessionIdentityProvider } from '../src/runtime/session-identity.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; + +describe('platform session identity spike contract', () => { + it('never persists raw host keys and defaults unverified platforms to explicit sessions', () => { + const spike = createPlatformSessionSpike({ + platform: 'codex', + observedAt: '2026-07-17T12:00:00.000Z', + hostSessionSource: 'api', + firstWindowHostSessionKey: 'desktop-window-a-private-key', + secondWindowHostSessionKey: 'desktop-window-b-private-key', + commandPropagation: 'proven', + subagentInheritance: 'not_tested', + hookApproval: 'not_applicable', + }); + expect(JSON.stringify(spike)).not.toContain('private-key'); + expect(evaluatePlatformSessionCapability(spike)).toMatchObject({ + hostIdentity: 'host_verified', + }); + const provider = createSessionIdentityProvider(WORKSPACE_ID); + expect( + provider.resolveCandidate({ + environment: {}, + trustedHostInput: { + externalSessionKey: 'desktop-window-a-private-key', + propagatesToCommands: true, + }, + client: 'codex', + }), + ).toBeNull(); + }); + + it('requires explicit sessions when same-client windows collide or propagation is unproven', () => { + const collision = createPlatformSessionSpike({ + platform: 'cursor', + observedAt: '2026-07-17T12:00:00.000Z', + hostSessionSource: 'environment', + firstWindowHostSessionKey: 'same-window-key', + secondWindowHostSessionKey: 'same-window-key', + commandPropagation: 'not_proven', + subagentInheritance: 'not_applicable', + hookApproval: 'not_applicable', + }); + expect(evaluatePlatformSessionCapability(collision).hostIdentity).toBe( + 'explicit_required', + ); + expect(platformSpikeFreezeStatus([collision])).toMatchObject({ + ready: false, + missingPlatforms: expect.arrayContaining(['codex']), + explicitRequiredPlatforms: expect.arrayContaining(['cursor']), + }); + }); + + it('executes the command propagation leg without invoking a shell', async () => { + vi.stubEnv('MANCODE_SPIKE_HOST_SESSION_KEY', SESSION_ID); + try { + await expect( + probeSessionEnvironmentPropagation(SESSION_ID), + ).resolves.toBe('proven'); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('does not claim propagation when the parent environment lacks the host key', async () => { + vi.stubEnv('MANCODE_SPIKE_HOST_SESSION_KEY', 'different-host-session-key'); + try { + await expect( + probeSessionEnvironmentPropagation('actual-host-session-key'), + ).resolves.toBe('not_proven'); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/tests/privacy-contracts.test.ts b/tests/privacy-contracts.test.ts new file mode 100644 index 0000000..671b188 --- /dev/null +++ b/tests/privacy-contracts.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + assertSafeSharedRelativePath, + assertSharedTextSafe, + redactSharedText, + scanSharedText, +} from '../src/context/privacy.js'; + +describe('shared privacy contract', () => { + it('deterministically redacts known sensitive text and preserves provenance categories', () => { + const raw = + 'Authorization: Bearer super-secret-token; see /Users/alice/project and contact alice@example.com'; + expect(scanSharedText(raw).map((finding) => finding.kind)).toEqual([ + 'authorization', + 'absolute_path', + 'email', + ]); + const redacted = redactSharedText(raw); + expect(redacted.text).toContain('[REDACTED:authorization]'); + expect(redacted.text).toContain('[REDACTED:absolute_path]'); + expect(redacted.text).toContain('[REDACTED:email]'); + expect(redacted.redactions).toEqual([ + { kind: 'absolute_path', count: 1 }, + { kind: 'authorization', count: 1 }, + { kind: 'email', count: 1 }, + ]); + expect(() => + assertSharedTextSafe(redacted.text, 'redacted summary'), + ).not.toThrow(); + expect(() => assertSharedTextSafe(raw, 'raw summary')).toThrow( + 'MANCODE_PRIVACY_BLOCKED', + ); + }); + + it('only accepts controlled relative artifact paths', () => { + expect(assertSafeSharedRelativePath('reports/login-rate-limit.md')).toBe( + 'reports/login-rate-limit.md', + ); + for (const unsafe of [ + '../secret.md', + '/Users/alice/secret.md', + 'C:\\Users\\alice\\secret.md', + 'file:///tmp/secret.md', + 'reports/../secret.md', + 'reports//secret.md', + ]) { + expect(() => assertSafeSharedRelativePath(unsafe)).toThrow( + 'MANCODE_ARTIFACT_PATH_UNSAFE', + ); + } + }); +}); diff --git a/tests/project-runtime-contracts.test.ts b/tests/project-runtime-contracts.test.ts new file mode 100644 index 0000000..aeb8894 --- /dev/null +++ b/tests/project-runtime-contracts.test.ts @@ -0,0 +1,163 @@ +import { execFile } from 'node:child_process'; +import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + ensureProjectRuntimeContext, + readProjectRuntimeContext, + repositoryRuntimeBindingPath, + runtimeCheckoutBindingPath, + runtimeCheckoutRecordPath, + workspaceRuntimeBindingPath, +} from '../src/runtime/project-runtime.js'; + +const run = promisify(execFile); +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; + +describe('project runtime binding contract', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-project-runtime-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await writeV3Config(root, WORKSPACE_ID); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('keeps a non-Git checkout local and read-only resolution does not bootstrap it', async () => { + await expect(readProjectRuntimeContext(root)).rejects.toThrow( + 'MANCODE_WORKSPACE_BINDING_MISMATCH', + ); + const runtime = await ensureProjectRuntimeContext( + root, + new Date('2026-07-17T12:00:00.000Z'), + ); + + expect(runtime).toMatchObject({ + workspaceId: WORKSPACE_ID, + repositoryBindingId: null, + gitCommonDir: null, + }); + await expect( + readFile(runtimeCheckoutRecordPath(root), 'utf8'), + ).resolves.toContain(runtime.checkoutId); + await expect(readProjectRuntimeContext(root)).resolves.toMatchObject({ + checkoutId: runtime.checkoutId, + gitCommonDir: null, + }); + }); + + it('binds one Git worktree to a common-dir workspace without storing raw paths in records', async () => { + await run('git', ['init'], { cwd: root }); + const runtime = await ensureProjectRuntimeContext( + root, + new Date('2026-07-17T12:00:00.000Z'), + ); + + expect(runtime.gitCommonDir).not.toBeNull(); + expect(runtime.repositoryBindingId).not.toBeNull(); + const commonDir = runtime.gitCommonDir as string; + await expect( + readFile(repositoryRuntimeBindingPath(commonDir), 'utf8'), + ).resolves.toContain('commonDirHash'); + await expect( + readFile(workspaceRuntimeBindingPath(commonDir, WORKSPACE_ID), 'utf8'), + ).resolves.not.toContain(root); + await expect( + readFile(runtimeCheckoutBindingPath(root), 'utf8'), + ).resolves.not.toContain(root); + await expect(readProjectRuntimeContext(root)).resolves.toMatchObject({ + checkoutId: runtime.checkoutId, + repositoryBindingId: runtime.repositoryBindingId, + }); + }); + + it('refuses a runtime record when the project config switches workspace identity', async () => { + await ensureProjectRuntimeContext(root); + await writeV3Config(root, '01JZ4B6W5Z0A1B2C3D4E5F6G7J'); + + await expect(readProjectRuntimeContext(root)).rejects.toThrow( + 'MANCODE_WORKSPACE_BINDING_MISMATCH', + ); + }); + + it('requires an explicit registration for a linked worktree and then gives it an independent checkout identity', async () => { + await run('git', ['init'], { cwd: root }); + await run('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: root, + }); + await run('git', ['config', 'user.name', 'Vitest'], { cwd: root }); + await writeFile(path.join(root, 'README.md'), '# fixture\n'); + await run('git', ['add', 'README.md'], { cwd: root }); + await run('git', ['commit', '-m', 'fixture'], { cwd: root }); + const primary = await ensureProjectRuntimeContext(root); + const linked = path.join( + tmpdir(), + `mancode-linked-worktree-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + try { + await run('git', ['worktree', 'add', '-b', 'linked-v3', linked], { + cwd: root, + }); + const linkedConfig = path.join( + linked, + '.mancode', + 'shared', + 'config.json', + ); + await mkdir(path.dirname(linkedConfig), { recursive: true }); + await copyFile( + path.join(root, '.mancode', 'shared', 'config.json'), + linkedConfig, + ); + + await expect(readProjectRuntimeContext(linked)).rejects.toThrow( + 'MANCODE_WORKSPACE_BINDING_MISMATCH', + ); + const registered = await ensureProjectRuntimeContext(linked); + expect(registered).toMatchObject({ + workspaceId: primary.workspaceId, + repositoryBindingId: primary.repositoryBindingId, + gitCommonDir: primary.gitCommonDir, + }); + expect(registered.checkoutId).not.toBe(primary.checkoutId); + await expect(readProjectRuntimeContext(linked)).resolves.toMatchObject({ + checkoutId: registered.checkoutId, + workspaceId: primary.workspaceId, + }); + } finally { + await run('git', ['worktree', 'remove', '--force', linked], { + cwd: root, + }); + await rm(linked, { recursive: true, force: true }); + } + }); +}); + +async function writeV3Config(root: string, workspaceId: string): Promise { + const target = path.join(root, '.mancode', 'shared', 'config.json'); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile( + target, + `${JSON.stringify( + { + schemaVersion: 1, + revision: 1, + workspaceId, + transport: { mode: 'local', remote: null }, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }, + null, + 2, + )}\n`, + ); +} diff --git a/tests/projection-eventual-consistency-e2e.test.ts b/tests/projection-eventual-consistency-e2e.test.ts new file mode 100644 index 0000000..3bafece --- /dev/null +++ b/tests/projection-eventual-consistency-e2e.test.ts @@ -0,0 +1,393 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { contextDoctor } from '../src/commands/context.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { createV3Checkpoint } from '../src/context/checkpoint-create.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { ContextResolver } from '../src/context/resolver.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { taskRootPath } from '../src/context/task-locator.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { + type CacheInvalidationProjectionTargetV1, + listProjectionIntents, + projectionCachePath, +} from '../src/runtime/projection-outbox.js'; +import { + closeSession, + createSession, + readSession, +} from '../src/runtime/session.js'; +import { createLocalActor, readSharedActorProfile } from '../src/team/actor.js'; +import { listTeamEvents, teamEventDirectory } from '../src/team/events.js'; +import { joinTeam } from '../src/team/join.js'; + +const NOW = new Date('2026-07-18T10:00:00.000Z'); +const ACTOR_ID = id(4); +const SESSION_ID = id(5); + +describe('eventual projection filesystem E2E', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp( + path.join(os.tmpdir(), 'mancode-projection-eventual-'), + ); + await initializeV3Project({ + projectRoot: root, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + await createLocalActor(root, { + actorId: ACTOR_ID, + displayName: 'Projection User', + now: NOW, + }); + await createSession(root, { + actorId: ACTOR_ID, + sessionId: SESSION_ID, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('keeps a joined profile committed and lets doctor emit its failed audit event exactly once', async () => { + const store = new V3ContextStore(root); + const project = await store.readProjectSnapshot(); + const operationId = id(10); + const input = { + actor: { + schemaVersion: 1 as const, + actorId: ACTOR_ID, + displayName: 'Projection User', + createdAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }, + projectConfig: project.config, + teamPolicy: project.policy, + operationId, + eventId: id(11), + confirmed: true, + sync: false, + now: NOW, + projectRoot: root, + }; + await rm(teamEventDirectory(root), { recursive: true }); + await writeFile(teamEventDirectory(root), 'blocks event directory\n'); + + await expect(joinTeam(input)).rejects.toThrow(); + await expect( + readSharedActorProfile(root, ACTOR_ID), + ).resolves.not.toBeNull(); + await expect(listProjectionIntents(root)).resolves.toMatchObject([ + { + operationId, + state: 'pending', + target: { kind: 'audit_event' }, + }, + ]); + + await rm(teamEventDirectory(root)); + await expect(runDoctorRepair(root, operationId)).resolves.toBe(0); + await expect(listTeamEvents(root)).resolves.toHaveLength(1); + await expect(listProjectionIntents(root)).resolves.toEqual([]); + + const retried = await joinTeam(input); + expect(retried.event.operationId).toBe(operationId); + await expect(listTeamEvents(root)).resolves.toHaveLength(1); + }); + + it('does not roll back a committed workflow when its session pointer fails and doctor later resumes it', async () => { + const operationId = id(20); + const sessionLock = path.join( + root, + '.mancode', + 'local', + 'sessions', + `.${SESSION_ID}.lock`, + ); + await mkdir(sessionLock); + + const created = await createV3Workflow({ + projectRoot: root, + task: 'Keep workflow authority independent from its session pointer.', + workflowMode: 'man', + sessionId: SESSION_ID, + client: 'vitest', + taskId: id(21), + operationId, + now: NOW, + }); + expect(created).toMatchObject({ + sessionResumed: false, + operation: { state: 'committed' }, + metadata: { transitionState: 'stable' }, + }); + const metadataPath = path.join( + taskRootPath(root, created.taskRef), + 'metadata.json', + ); + const committedMetadata = await readFile(metadataPath, 'utf8'); + await expect(readSession(root, SESSION_ID)).resolves.toMatchObject({ + activeTaskRef: null, + }); + await expect(listProjectionIntents(root)).resolves.toMatchObject([ + { + operationId, + state: 'pending', + target: { kind: 'session_pointer', action: 'resume' }, + }, + ]); + + await rm(sessionLock, { recursive: true }); + await expect(runDoctorRepair(root, operationId)).resolves.toBe(0); + await expect(readSession(root, SESSION_ID)).resolves.toMatchObject({ + activeTaskRef: created.taskRef, + activeMode: 'man', + lastSeenRevision: created.metadata.revision, + }); + await expect(readFile(metadataPath, 'utf8')).resolves.toBe( + committedMetadata, + ); + await expect(listProjectionIntents(root)).resolves.toEqual([]); + }); + + it.each(['closed', 'missing'] as const)( + 'supersedes a session projection when its target session is %s', + async (targetState) => { + const operationId = id(24); + const sessionLock = path.join( + root, + '.mancode', + 'local', + 'sessions', + `.${SESSION_ID}.lock`, + ); + await mkdir(sessionLock); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Keep terminal session state outside workflow authority.', + workflowMode: 'man', + sessionId: SESSION_ID, + client: 'vitest', + taskId: id(25), + operationId, + now: NOW, + }); + expect(created.sessionResumed).toBe(false); + const metadataPath = path.join( + taskRootPath(root, created.taskRef), + 'metadata.json', + ); + const committedMetadata = await readFile(metadataPath, 'utf8'); + await rm(sessionLock, { recursive: true }); + if (targetState === 'closed') { + await closeSession(root, SESSION_ID, NOW); + } else { + await rm( + path.join( + root, + '.mancode', + 'local', + 'sessions', + `${SESSION_ID}.json`, + ), + ); + } + const repairSessionId = id(26); + await createSession(root, { + actorId: ACTOR_ID, + sessionId: repairSessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + + await expect( + runDoctorRepair(root, operationId, repairSessionId), + ).resolves.toBe(0); + await expect(readFile(metadataPath, 'utf8')).resolves.toBe( + committedMetadata, + ); + await expect( + listProjectionIntents(root, { + operationId, + includeTerminal: true, + }), + ).resolves.toMatchObject([{ state: 'superseded' }]); + await expect(listProjectionIntents(root)).resolves.toEqual([]); + }, + ); + + it('supersedes projection intents after the authority journal safely aborts', async () => { + const operationId = id(27); + const fixture = OPERATION_CRASH_FIXTURES.workflow_create.find( + (candidate) => candidate.crashAfter === 'prepared', + ); + if (fixture === undefined) + throw new Error('missing prepared crash fixture'); + const taskRef = { namespace: 'local' as const, taskId: id(28) }; + + await expect( + withOperationCrashInjectionForTesting(fixture, () => + createV3Workflow({ + projectRoot: root, + task: 'Abort before publishing workflow authority.', + workflowMode: 'man', + sessionId: SESSION_ID, + client: 'vitest', + taskId: taskRef.taskId, + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + await expect(listProjectionIntents(root)).resolves.toHaveLength(1); + + await expect(runDoctorRepair(root, operationId)).resolves.toBe(0); + await expect( + readFile(path.join(taskRootPath(root, taskRef), 'metadata.json'), 'utf8'), + ).rejects.toThrow(); + await expect( + listProjectionIntents(root, { + operationId, + includeTerminal: true, + }), + ).resolves.toMatchObject([{ state: 'superseded' }]); + await expect(listProjectionIntents(root)).resolves.toEqual([]); + }); + + it('reads fresh authority despite stale cache files and lets doctor finish failed invalidation', async () => { + const created = await createV3Workflow({ + projectRoot: root, + task: 'Regenerate derived context after a checkpoint.', + workflowMode: 'man', + sessionId: SESSION_ID, + client: 'vitest', + taskId: id(30), + operationId: id(31), + now: NOW, + }); + const resolver = await createResolver(root); + const before = await resolveTask(resolver, root, created.taskRef); + expect(before.pack.snapshot.taskRevision).toBe(1); + expect(before.pack.latestCheckpoint).toBeNull(); + + const operationId = id(32); + const contextPackTarget: CacheInvalidationProjectionTargetV1 = { + kind: 'cache_invalidation', + cacheKind: 'context_pack', + taskRef: created.taskRef, + }; + const statusIndexTarget: CacheInvalidationProjectionTargetV1 = { + kind: 'cache_invalidation', + cacheKind: 'status_index', + taskRef: created.taskRef, + }; + const contextPackCache = projectionCachePath(root, contextPackTarget); + const statusIndexCache = projectionCachePath(root, statusIndexTarget); + await mkdir(contextPackCache, { recursive: true }); + await mkdir(path.dirname(statusIndexCache), { recursive: true }); + await writeFile(statusIndexCache, '{"taskRevision":1}\n'); + + const checkpoint = await createV3Checkpoint({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: SESSION_ID, + expectedTaskRevision: 1, + kind: 'diagnostic_started', + summary: 'Advance task authority while cache invalidation is blocked.', + checkpointId: id(33), + operationId, + now: new Date('2026-07-18T10:01:00.000Z'), + }); + expect(checkpoint.operation.state).toBe('committed'); + await expect(listProjectionIntents(root)).resolves.toHaveLength(2); + + const after = await resolveTask(resolver, root, created.taskRef); + expect(after.pack.snapshot.taskRevision).toBe(checkpoint.metadata.revision); + expect(after.pack.latestCheckpoint).toMatchObject({ + checkpointId: checkpoint.checkpoint.checkpointId, + }); + expect(after.pack.packDigest).not.toBe(before.pack.packDigest); + + await rm(contextPackCache, { recursive: true }); + await writeFile(contextPackCache, '{"taskRevision":1}\n'); + await expect(runDoctorRepair(root, operationId)).resolves.toBe(0); + await expect(readFile(contextPackCache, 'utf8')).rejects.toThrow(); + await expect(readFile(statusIndexCache, 'utf8')).rejects.toThrow(); + await expect(listProjectionIntents(root)).resolves.toEqual([]); + }); +}); + +async function createResolver(projectRoot: string): Promise { + const runtime = await readProjectRuntimeContext(projectRoot); + return new ContextResolver({ + projectRoot, + entityHomeStoreContext: runtime.entityHomeStoreContext, + }); +} + +async function resolveTask( + resolver: ContextResolver, + projectRoot: string, + taskRef: { namespace: 'local' | 'shared'; taskId: Ulid }, +) { + const [project, session] = await Promise.all([ + new V3ContextStore(projectRoot).readProjectSnapshot(), + readSession(projectRoot, SESSION_ID), + ]); + if (session === null) throw new Error('missing projection test session'); + return resolver.resolve({ + session, + taskRef, + level: 'task', + purpose: 'implement', + compatibility: { + expectedSchemaEpoch: project.manifest.epoch, + readerVersion: project.manifest.minReaderVersion, + writerVersion: project.manifest.minWriterVersion, + adapterVersions: project.manifest.managedAdapters, + }, + generatedAt: NOW, + }); +} + +async function runDoctorRepair( + projectRoot: string, + operationId: Ulid, + sessionId: Ulid = SESSION_ID, +): Promise { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + return await contextDoctor(projectRoot, { + repair: operationId, + session: sessionId, + client: 'vitest', + json: true, + }); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-18T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/projection-outbox-contracts.test.ts b/tests/projection-outbox-contracts.test.ts new file mode 100644 index 0000000..39993e2 --- /dev/null +++ b/tests/projection-outbox-contracts.test.ts @@ -0,0 +1,110 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + enqueueAuditEventProjection, + listProjectionIntents, + reconcileProjectionIntents, +} from '../src/runtime/projection-outbox.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, +} from '../src/team/actor.js'; +import { type TeamEventV1, listTeamEvents } from '../src/team/events.js'; + +const EVENT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const OTHER_EVENT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const OTHER_ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; +const NOW = new Date('2026-07-18T10:00:00.000Z'); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('durable projection outbox contract', () => { + it('does not guess an event before authority exists and repairs the exact intent afterwards', async () => { + const root = await temporaryRoot(); + const event = actorJoinedEvent(); + await enqueueAuditEventProjection(root, event, NOW); + + await expect( + reconcileProjectionIntents(root, OPERATION_ID, NOW), + ).resolves.toMatchObject({ + state: 'repair_required', + projections: [ + { + kind: 'audit_event', + availability: 'conflict', + state: 'pending', + }, + ], + }); + await expect(listTeamEvents(root)).resolves.toEqual([]); + await expect(listProjectionIntents(root)).resolves.toHaveLength(1); + + const actor = await createLocalActor(root, { + actorId: ACTOR_ID, + displayName: 'Projection User', + now: NOW, + }); + await publishSharedActorProfile(root, createSharedActorProfile(actor, NOW)); + await expect( + reconcileProjectionIntents(root, OPERATION_ID, NOW), + ).resolves.toMatchObject({ + state: 'converged', + projections: [ + { + kind: 'audit_event', + availability: 'present', + state: 'completed', + }, + ], + }); + await expect(listTeamEvents(root)).resolves.toEqual([event]); + await expect(listProjectionIntents(root)).resolves.toEqual([]); + }); + + it('rejects a different target behind the same operation projection key', async () => { + const root = await temporaryRoot(); + await enqueueAuditEventProjection(root, actorJoinedEvent(), NOW); + await expect( + enqueueAuditEventProjection( + root, + { + ...actorJoinedEvent(), + eventId: OTHER_EVENT_ID, + entityRef: { kind: 'actor', id: OTHER_ACTOR_ID }, + actorId: OTHER_ACTOR_ID, + }, + NOW, + ), + ).rejects.toThrow('MANCODE_PROJECTION_INTENT_CONFLICT'); + }); +}); + +function actorJoinedEvent(): TeamEventV1 { + return { + schemaVersion: 1, + eventId: EVENT_ID, + eventType: 'actor_joined', + operationId: OPERATION_ID, + entityRef: { kind: 'actor', id: ACTOR_ID }, + taskRef: null, + actorId: ACTOR_ID, + taskRevision: null, + createdAt: NOW.toISOString(), + }; +} + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'mancode-projection-')); + roots.push(root); + return root; +} diff --git a/tests/quarantine-contracts.test.ts b/tests/quarantine-contracts.test.ts new file mode 100644 index 0000000..1c40b26 --- /dev/null +++ b/tests/quarantine-contracts.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { + confirmQuarantineCandidate, + createQuarantineCandidate, + preparePromotionFromQuarantine, + previewQuarantineCandidate, + scanQuarantineCandidate, + validateQuarantinePaths, +} from '../src/context/quarantine.js'; + +const QUARANTINE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const LOCAL_TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const SHARED_TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; + +describe('quarantine and promotion contract', () => { + it('forces legacy migration through local quarantine, scan, preview, and confirmation', () => { + const staged = candidate(); + expect(staged.candidateTaskRef.namespace).toBe('local'); + expect(() => + preparePromotionFromQuarantine( + staged, + { namespace: 'shared', taskId: SHARED_TASK_ID }, + OPERATION_ID, + ), + ).toThrow('MANCODE_QUARANTINE_STAGE_INVALID'); + + const scanned = scanQuarantineCandidate(validateQuarantinePaths(staged), [ + 'safe, reviewed content', + ]); + const confirmed = confirmQuarantineCandidate( + previewQuarantineCandidate(scanned), + ACTOR_ID, + ); + const plan = preparePromotionFromQuarantine( + confirmed, + { namespace: 'shared', taskId: SHARED_TASK_ID }, + OPERATION_ID, + ); + expect(plan.sourceTaskRef).toEqual({ + namespace: 'local', + taskId: LOCAL_TASK_ID, + }); + expect(plan.destinationTaskRef.namespace).toBe('shared'); + expect(plan.omittedArtifacts.map((item) => item.classification)).toEqual([ + 'raw_evidence', + ]); + }); + + it('does not silently redact a blocked scan or promote raw evidence', () => { + const blocked = scanQuarantineCandidate( + validateQuarantinePaths(candidate()), + ['Authorization: Bearer super-secret'], + ); + expect(blocked.stage).toBe('privacy_blocked'); + expect(JSON.stringify(blocked)).not.toContain('super-secret'); + expect(() => previewQuarantineCandidate(blocked)).toThrow( + 'MANCODE_PRIVACY_BLOCKED', + ); + expect(() => + createQuarantineCandidate({ + ...candidateInput(), + artifacts: [ + { + relativePath: 'artifacts/private/raw.log', + classification: 'raw_evidence', + includeInPromotion: true, + contentDigest: null, + }, + ], + }), + ).toThrow('MANCODE_RAW_ARTIFACT_CANNOT_BE_PROMOTED'); + }); + + it('rejects direct shared candidates and unsafe artifact paths', () => { + expect(() => + createQuarantineCandidate({ + ...candidateInput(), + candidateTaskRef: { namespace: 'shared', taskId: SHARED_TASK_ID }, + }), + ).toThrow('MANCODE_QUARANTINE_CANDIDATE_MUST_BE_LOCAL'); + expect(() => + createQuarantineCandidate({ + ...candidateInput(), + artifacts: [ + { + relativePath: '../metadata.json', + classification: 'authority', + includeInPromotion: true, + contentDigest: null, + }, + ], + }), + ).toThrow('MANCODE_ARTIFACT_PATH_UNSAFE'); + }); +}); + +function candidate() { + return createQuarantineCandidate(candidateInput()); +} + +function candidateInput() { + return { + quarantineId: QUARANTINE_ID, + purpose: 'legacy_migration' as const, + sourceTaskRef: null, + candidateTaskRef: { namespace: 'local' as const, taskId: LOCAL_TASK_ID }, + artifacts: [ + { + relativePath: 'metadata.json', + classification: 'authority' as const, + includeInPromotion: true, + contentDigest: null, + }, + { + relativePath: 'reports/raw.log', + classification: 'raw_evidence' as const, + includeInPromotion: false, + contentDigest: null, + }, + ], + now: new Date('2026-07-17T10:00:00.000Z'), + }; +} diff --git a/tests/reconciler-contracts.test.ts b/tests/reconciler-contracts.test.ts new file mode 100644 index 0000000..68440ae --- /dev/null +++ b/tests/reconciler-contracts.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import type { OperationJournalV1 } from '../src/runtime/operation-journal.js'; +import { + assertOperationCrashFixtureCoverage, + planOperationRecovery, +} from '../src/runtime/reconciler.js'; + +const ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const OTHER_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('operation reconciler contract', () => { + it('maps all declared crash points to one explicit recovery result', () => { + expect(() => assertOperationCrashFixtureCoverage()).not.toThrow(); + }); + + it('aborts before a business write and forward-repairs after one', () => { + const beforeWrite = journal(1); + expect(planOperationRecovery({ journal: beforeWrite })).toMatchObject({ + action: 'safe_abort', + nextJournalState: 'aborted', + reason: 'no_external_write', + }); + const afterWrite = journal(2); + expect(planOperationRecovery({ journal: afterWrite })).toMatchObject({ + action: 'forward_repair', + nextJournalState: 'repair_required', + reason: 'business_write_visible', + pendingEntityLocks: expect.arrayContaining([`task:shared:${ID}`]), + }); + }); + + it('keeps a committed task committed when only event/session/cache projections need retry', () => { + const committed = journal(9, 'committed'); + expect( + planOperationRecovery({ + journal: committed, + projections: { auditEvent: 'missing', cache: 'missing' }, + }), + ).toMatchObject({ + action: 'projection_retry', + nextJournalState: 'committed', + retryProjections: ['auditEvent', 'cache'], + pendingEntityLocks: [], + }); + }); +}); + +function journal( + completedCount: number, + state: OperationJournalV1['state'] = 'applying', +): OperationJournalV1 { + const stepIds = [ + 'validate', + 'mark-task-operation-pending', + 'create-pending-successor-claims', + 'transfer-old-claims', + 'update-owner-and-checkpoint', + 'activate-successor-claims', + 'accept-handoff', + 'update-task-head-fence', + 'commit', + ]; + return { + schemaVersion: 1, + operationId: ID, + type: 'handoff_accept', + state, + primaryStoreId: `workspace:${ID}`, + checkoutId: ID, + secondaryReservations: [], + actorId: ID, + sessionId: OTHER_ID, + authorizationBasis: { + schemaVersion: 1, + action: 'handoff_accept_reject', + actorId: ID, + sessionId: OTHER_ID, + trustBoundary: 'repo-collaborators', + decisionDigest: DIGEST, + authorizedAt: '2026-07-17T10:00:00.000Z', + }, + entityLocks: [ + `task:shared:${ID}`, + `task_head:${ID}`, + `handoff:${ID}`, + `claim:${ID}`, + `checkpoint:${ID}`, + ], + expectedRevisions: { + [`task:shared:${ID}`]: 7, + [`handoff:${ID}`]: 2, + [`claim:${ID}`]: 1, + [`checkpoint:${ID}`]: 0, + [`task_head:${ID}`]: 7, + }, + steps: stepIds.map((id, index) => ({ + id, + state: + index < completedCount ? ('completed' as const) : ('pending' as const), + })), + startedAt: '2026-07-17T10:00:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} diff --git a/tests/refresh-style.test.ts b/tests/refresh-style.test.ts index 5984beb..15bdfa6 100644 --- a/tests/refresh-style.test.ts +++ b/tests/refresh-style.test.ts @@ -229,6 +229,41 @@ describe('mancode refresh-style', () => { expect(tokens.uiLibrary).toBeNull(); expect(tokens.cssVariables.surface).toBe('#ffffff'); }); + + it('refreshes V3 project facts and keeps V3 style tokens checkout-local', async () => { + await writeFile( + path.join(dir, 'package.json'), + JSON.stringify({ + name: 'test-v3-frontend', + dependencies: { react: '^18.0.0', tailwindcss: '^3.4.0' }, + }), + 'utf-8', + ); + await writeFile(path.join(dir, 'tailwind.config.js'), TAILWIND_CONFIG); + await silentV3Init(dir); + + expect(await refreshStyle(dir)).toBe(EXIT_OK); + const facts = JSON.parse( + await readFile( + path.join(dir, '.mancode', 'shared', 'context', 'project.json'), + 'utf8', + ), + ); + const tokens = JSON.parse( + await readFile( + path.join(dir, '.mancode', 'local', 'cache', 'style-tokens.json'), + 'utf8', + ), + ); + expect(facts).toMatchObject({ + revision: 2, + profile: { frameworks: ['React', 'Tailwind CSS'] }, + }); + expect(tokens.colors).toHaveProperty('primary', '#3b82f6'); + await expect( + readFile(path.join(dir, '.mancode', 'state.json'), 'utf8'), + ).rejects.toThrow(); + }); }); const TAILWIND_CONFIG = `module.exports = { @@ -282,6 +317,22 @@ async function silentInit(dir: string): Promise { } } +async function silentV3Init(dir: string): Promise { + const originalLog = console.log; + const originalError = console.error; + console.log = () => {}; + console.error = () => {}; + try { + const code = await init(dir, { v3: true }); + if (code !== 0) { + throw new Error(`silentV3Init failed: init exited with ${code}`); + } + } finally { + console.log = originalLog; + console.error = originalError; + } +} + async function captureLog(fn: () => Promise): Promise { const originalLog = console.log; const logs: string[] = []; diff --git a/tests/requirements-ledger-v3-contracts.test.ts b/tests/requirements-ledger-v3-contracts.test.ts new file mode 100644 index 0000000..a540308 --- /dev/null +++ b/tests/requirements-ledger-v3-contracts.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { + type RequirementsLedgerV1, + parseRequirementsLedger, + requirementsAreReady, + requirementsLedgerDigest, +} from '../src/context/requirements-ledger.js'; + +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const DECISION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const REQUIREMENT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const CRITERION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const COVERAGE_IDS = [ + '01JZ4B6W5Z0A1B2C3D4E5F6G7N', + '01JZ4B6W5Z0A1B2C3D4E5F6G7P', + '01JZ4B6W5Z0A1B2C3D4E5F6G7Q', + '01JZ4B6W5Z0A1B2C3D4E5F6G7R', + '01JZ4B6W5Z0A1B2C3D4E5F6G7S', + '01JZ4B6W5Z0A1B2C3D4E5F6G7T', + '01JZ4B6W5Z0A1B2C3D4E5F6G7V', +]; + +describe('requirements ledger V3 contract', () => { + it('preserves stable item identities and computes a canonical authoritative digest', () => { + const parsed = parseRequirementsLedger(ledger()); + expect(parsed.status).toBe('confirmed'); + expect(requirementsAreReady(parsed)).toBe(true); + expect(parsed.acceptanceCriteria[0]?.requirementIds).toEqual([ + REQUIREMENT_ID, + ]); + }); + + it('rejects invalid ready ledgers, dangling requirement references, and digest drift', () => { + expect(() => + parseRequirementsLedger( + withDigest({ + ...ledger(), + blockingUnknowns: [ + { + displayId: 'U-1', + legacyId: null, + unknownId: '01JZ4B6W5Z0A1B2C3D4E5F6G7W', + statement: 'Unknown deployment target.', + status: 'open', + }, + ], + }), + ), + ).toThrow(/unresolved blocking unknowns/); + expect(() => + parseRequirementsLedger( + withDigest({ + ...ledger(), + acceptanceCriteria: ledger().acceptanceCriteria.map((criterion) => ({ + ...criterion, + requirementIds: ['01JZ4B6W5Z0A1B2C3D4E5F6G7W'], + })), + }), + ), + ).toThrow(/unknown requirementId/); + expect(() => + parseRequirementsLedger({ + ...ledger(), + contentDigest: `sha256:${'0'.repeat(64)}`, + }), + ).toThrow(/does not match canonical content/); + expect(() => + parseRequirementsLedger( + withDigest({ + ...ledger(), + goal: 'Send the token=super-secret-value to the deployment service.', + }), + ), + ).toThrow(/MANCODE_PRIVACY_BLOCKED/); + }); +}); + +function ledger(): RequirementsLedgerV1 { + const draft: RequirementsLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: { namespace: 'shared', taskId: TASK_ID }, + revision: 1, + status: 'confirmed', + goal: 'Add login rate limits.', + functionalScope: { + inScope: ['Limit repeated login attempts.'], + outOfScope: ['Change account recovery.'], + }, + technicalDecisions: [ + { + displayId: 'TD-1', + legacyId: null, + decisionId: DECISION_ID, + statement: 'Use the existing Redis client.', + }, + ], + defaults: [], + coverage: [ + 'platform', + 'core_scope', + 'technical_stack', + 'data_and_persistence', + 'performance', + 'compatibility', + 'security', + ].map((dimension, index) => ({ + coverageId: + COVERAGE_IDS[index] ?? + (() => { + throw new Error(`missing coverage id at index ${index}`); + })(), + dimension: + dimension as RequirementsLedgerV1['coverage'][number]['dimension'], + status: 'confirmed' as const, + rationale: `${dimension} is covered.`, + })), + requirements: [ + { + displayId: 'R-1', + legacyId: 'REQ-1', + requirementId: REQUIREMENT_ID, + statement: 'Throttle repeated failed login attempts.', + priority: 'must', + }, + ], + acceptanceCriteria: [ + { + displayId: 'AC-1', + legacyId: 'AC-1', + criterionId: CRITERION_ID, + requirementIds: [REQUIREMENT_ID], + statement: 'A repeated failed login receives a rate-limit response.', + required: true, + verificationRequirement: 'hybrid', + }, + ], + blockingUnknowns: [], + legacySource: null, + contentDigest: '', + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; + return { ...draft, contentDigest: requirementsLedgerDigest(draft) }; +} + +function withDigest(draft: RequirementsLedgerV1): RequirementsLedgerV1 { + return { ...draft, contentDigest: requirementsLedgerDigest(draft) }; +} diff --git a/tests/retention-contracts.test.ts b/tests/retention-contracts.test.ts new file mode 100644 index 0000000..c04bf2b --- /dev/null +++ b/tests/retention-contracts.test.ts @@ -0,0 +1,460 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { contextCompact } from '../src/commands/context.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { taskRootPath } from '../src/context/task-locator.js'; +import type { TaskRef } from '../src/context/task-ref.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { + operationDirectory, + resolveLocalEntityHomeStore, +} from '../src/runtime/entity-home-store.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { + applyContextCompaction, + planContextCompaction, +} from '../src/runtime/retention.js'; +import { closeSession, createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-18T12:00:00.000Z'); + +describe('V3 retention and compaction', () => { + let root: string; + let actorId: Ulid; + let sessionId: Ulid; + let taskId: Ulid; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-retention-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await initializeGitRepository(root); + await initializeV3Project({ + projectRoot: root, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + actorId = id(4); + sessionId = id(5); + taskId = id(6); + await createLocalActor(root, { + actorId, + displayName: 'Retention User', + now: NOW, + }); + await createSession(root, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: new Date('2026-05-01T00:00:00.000Z'), + }); + await createV3Workflow({ + projectRoot: root, + task: 'Retain only the latest diagnostic checkpoints.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId, + operationId: id(7), + now: NOW, + }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('deletes only aged closed sessions and unreferenced excess checkpoints', async () => { + const taskRoot = taskRootPath(root, { namespace: 'local', taskId }); + const metadataPath = path.join(taskRoot, 'metadata.json'); + const metadata = JSON.parse(await readFile(metadataPath, 'utf8')); + const checkpointIds = Array.from({ length: 12 }, (_, index) => + id(20 + index), + ); + await mkdir(path.join(taskRoot, 'checkpoints')); + await Promise.all( + checkpointIds.map(async (checkpointId, index) => { + const checkpoint = { + schemaVersion: 1, + checkpointId, + operationId: id(40 + index), + taskRef: { namespace: 'local', taskId }, + taskRevision: 1, + ownershipEpochAtOffer: 0, + kind: 'diagnostic_started', + git: { branch: null, head: null, base: null }, + summary: `Diagnostic checkpoint ${index}.`, + governance: { + requirementsDigest: metadata.governance.requirementsDigest, + planVersion: metadata.governance.planVersion, + reviewLedgerDigest: metadata.governance.reviewLedgerDigest, + verificationLedgerDigest: + metadata.governance.verificationLedgerDigest, + }, + nextAction: 'Continue the diagnostic workflow.', + createdBy: { actorId, client: 'vitest' }, + createdAt: new Date( + Date.parse('2026-01-01T00:00:00.000Z') + index * 1_000, + ).toISOString(), + }; + await writeFile( + path.join(taskRoot, 'checkpoints', `${checkpointId}.json`), + `${JSON.stringify(checkpoint, null, 2)}\n`, + ); + }), + ); + await writeFile( + metadataPath, + `${JSON.stringify( + { + ...metadata, + status: 'completed', + latestCheckpointRef: { + taskRef: { namespace: 'local', taskId }, + kind: 'checkpoint', + artifactId: checkpointIds[0], + }, + updatedAt: NOW.toISOString(), + }, + null, + 2, + )}\n`, + ); + await closeSession(root, sessionId, new Date('2026-05-01T01:00:00.000Z')); + + const plan = await planContextCompaction({ projectRoot: root, now: NOW }); + const checkpointCandidates = plan.candidates.filter( + (candidate) => candidate.kind === 'checkpoint', + ); + expect(checkpointCandidates).toHaveLength(1); + expect(checkpointCandidates[0]?.target).toContain(checkpointIds[1]); + expect(plan.skippedReferencedCheckpoints).toEqual([ + { + taskRef: { namespace: 'local', taskId }, + checkpointId: checkpointIds[0], + }, + ]); + expect(plan.candidates).toContainEqual( + expect.objectContaining({ kind: 'completed_session' }), + ); + + const applied = await applyContextCompaction(plan); + expect(applied.deleted).toContain(checkpointCandidates[0]?.target); + await expect( + readFile(checkpointCandidates[0]?.target ?? '', 'utf8'), + ).rejects.toThrow(); + await expect( + readFile( + path.join(root, '.mancode', 'local', 'sessions', `${sessionId}.json`), + 'utf8', + ), + ).rejects.toThrow(); + await expect( + readFile( + path.join(taskRoot, 'checkpoints', `${checkpointIds[0]}.json`), + 'utf8', + ), + ).resolves.toContain(checkpointIds[0]); + }); + + it('does not compact checkpoints from an active task', async () => { + const taskRoot = taskRootPath(root, { namespace: 'local', taskId }); + await mkdir(path.join(taskRoot, 'checkpoints')); + await writeFile( + path.join(taskRoot, 'checkpoints', `${id(80)}.json`), + `${JSON.stringify(await checkpointForCurrentTask(id(80), id(81)))}\n`, + ); + + await expect( + planContextCompaction({ projectRoot: root, now: NOW }), + ).resolves.toMatchObject({ + candidates: expect.not.arrayContaining([ + expect.objectContaining({ kind: 'checkpoint' }), + ]), + }); + }); + + it('keeps a repair-required journal and the session and task artifacts it protects', async () => { + const taskRef = { namespace: 'local' as const, taskId }; + const checkpointPaths = await completeTaskWithDiagnosticCheckpoints( + root, + taskRef, + actorId, + 90, + ); + await closeSession(root, sessionId, new Date('2026-05-01T01:00:00.000Z')); + + const runtime = await readProjectRuntimeContext(root); + const localStore = resolveLocalEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const journal = await readOperationJournal(localStore, id(7)); + if (journal === null) throw new Error('missing workflow operation journal'); + const journalTarget = path.join( + operationDirectory(localStore), + `${journal.operationId}.json`, + ); + await writeFile( + journalTarget, + `${JSON.stringify( + { + ...journal, + state: 'repair_required', + startedAt: '2026-05-01T00:00:00.000Z', + updatedAt: '2026-05-01T01:00:00.000Z', + }, + null, + 2, + )}\n`, + ); + + const sessionTarget = path.join( + root, + '.mancode', + 'local', + 'sessions', + `${sessionId}.json`, + ); + const plan = await planContextCompaction({ projectRoot: root, now: NOW }); + expect(plan.candidates.map((candidate) => candidate.target)).not.toContain( + journalTarget, + ); + expect(plan.candidates.map((candidate) => candidate.target)).not.toContain( + sessionTarget, + ); + expect( + plan.candidates.some( + (candidate) => + candidate.taskRef?.namespace === taskRef.namespace && + candidate.taskRef.taskId === taskRef.taskId, + ), + ).toBe(false); + + await applyContextCompaction(plan); + await expect(readFile(journalTarget, 'utf8')).resolves.toContain( + 'repair_required', + ); + await expect(readFile(sessionTarget, 'utf8')).resolves.toContain(sessionId); + await expect(readFile(checkpointPaths[0] ?? '', 'utf8')).resolves.toContain( + taskId, + ); + }); + + it('defaults shared CLI compaction to dry-run and deletes only with apply-shared', async () => { + const localActor = await readLocalActor(root); + if (localActor === null) throw new Error('missing retention actor'); + await publishSharedActorProfile( + root, + createSharedActorProfile(localActor, NOW), + ); + const sharedTaskId = id(150); + const shared = await createV3Workflow({ + projectRoot: root, + task: 'Compact completed shared checkpoints only after confirmation.', + workflowMode: 'manteam', + sessionId, + client: 'vitest', + taskId: sharedTaskId, + operationId: id(151), + sharedPrivacyConfirmed: true, + now: NOW, + }); + const checkpointPaths = await completeTaskWithDiagnosticCheckpoints( + root, + shared.taskRef, + actorId, + 160, + ); + + const task = `shared:${sharedTaskId}`; + const preview = await captureCompact(() => + contextCompact(root, { task, json: true }), + ); + expect(preview.exitCode).toBe(0); + expect(preview.value.deleted).toEqual([]); + const previewTargets = preview.value.candidates + .filter((candidate) => candidate.taskRef?.namespace === 'shared') + .map((candidate) => candidate.target); + expect(previewTargets).toHaveLength(2); + await Promise.all( + previewTargets.map((target) => + expect(readFile(target, 'utf8')).resolves.toContain(sharedTaskId), + ), + ); + + const applied = await captureCompact(() => + contextCompact(root, { task, applyShared: true, json: true }), + ); + expect(applied.exitCode).toBe(0); + expect(applied.value.deleted).toEqual( + expect.arrayContaining(previewTargets), + ); + await Promise.all( + previewTargets.map((target) => + expect(readFile(target, 'utf8')).rejects.toThrow(), + ), + ); + expect(checkpointPaths).toHaveLength(12); + }); + + async function checkpointForCurrentTask( + checkpointId: Ulid, + operationId: Ulid, + ) { + const metadata = JSON.parse( + await readFile( + path.join( + taskRootPath(root, { namespace: 'local', taskId }), + 'metadata.json', + ), + 'utf8', + ), + ); + return { + schemaVersion: 1, + checkpointId, + operationId, + taskRef: { namespace: 'local', taskId }, + taskRevision: 1, + ownershipEpochAtOffer: 0, + kind: 'diagnostic_started', + git: { branch: null, head: null, base: null }, + summary: 'Active task checkpoint.', + governance: { + requirementsDigest: metadata.governance.requirementsDigest, + planVersion: metadata.governance.planVersion, + reviewLedgerDigest: metadata.governance.reviewLedgerDigest, + verificationLedgerDigest: metadata.governance.verificationLedgerDigest, + }, + nextAction: 'Continue the active task.', + createdBy: { actorId, client: 'vitest' }, + createdAt: NOW.toISOString(), + }; + } +}); + +interface CompactJsonValue { + candidates: Array<{ target: string; taskRef: TaskRef | null }>; + deleted: string[]; +} + +async function captureCompact( + action: () => Promise, +): Promise<{ exitCode: number; value: CompactJsonValue }> { + const writes: string[] = []; + const previousLog = console.log; + console.log = (value: unknown) => writes.push(String(value)); + try { + const exitCode = await action(); + return { + exitCode, + value: JSON.parse(writes.at(-1) ?? '{}') as CompactJsonValue, + }; + } finally { + console.log = previousLog; + } +} + +async function completeTaskWithDiagnosticCheckpoints( + projectRoot: string, + taskRef: TaskRef, + actorId: Ulid, + firstIdOffset: number, +): Promise { + const taskRoot = taskRootPath(projectRoot, taskRef); + const metadataPath = path.join(taskRoot, 'metadata.json'); + const metadata = JSON.parse(await readFile(metadataPath, 'utf8')); + const checkpointIds = Array.from({ length: 12 }, (_, index) => + id(firstIdOffset + index), + ); + const checkpointDirectory = path.join(taskRoot, 'checkpoints'); + await mkdir(checkpointDirectory, { recursive: true }); + const targets = checkpointIds.map((checkpointId) => + path.join(checkpointDirectory, `${checkpointId}.json`), + ); + await Promise.all( + checkpointIds.map((checkpointId, index) => + writeFile( + targets[index] ?? '', + `${JSON.stringify( + { + schemaVersion: 1, + checkpointId, + operationId: id(firstIdOffset + 20 + index), + taskRef, + taskRevision: metadata.revision, + ownershipEpochAtOffer: metadata.ownershipEpoch, + kind: 'diagnostic_started', + git: { branch: null, head: null, base: null }, + summary: `Retention checkpoint ${index}.`, + governance: { + requirementsDigest: metadata.governance.requirementsDigest, + planVersion: metadata.governance.planVersion, + reviewLedgerDigest: metadata.governance.reviewLedgerDigest, + verificationLedgerDigest: + metadata.governance.verificationLedgerDigest, + }, + nextAction: 'Retain or compact this checkpoint.', + createdBy: { actorId, client: 'vitest' }, + createdAt: new Date( + Date.parse('2026-01-01T00:00:00.000Z') + index * 1_000, + ).toISOString(), + }, + null, + 2, + )}\n`, + ), + ), + ); + await writeFile( + metadataPath, + `${JSON.stringify( + { + ...metadata, + status: 'completed', + updatedAt: NOW.toISOString(), + }, + null, + 2, + )}\n`, + ); + return targets; +} + +async function initializeGitRepository(projectRoot: string): Promise { + await execFile('git', ['init'], { cwd: projectRoot }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: projectRoot, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { + cwd: projectRoot, + }); + await writeFile(path.join(projectRoot, 'README.md'), '# retention fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: projectRoot }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: projectRoot }); +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-01-01T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/review-ledger-contracts.test.ts b/tests/review-ledger-contracts.test.ts new file mode 100644 index 0000000..85f9049 --- /dev/null +++ b/tests/review-ledger-contracts.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import { + type ReviewLedgerV1, + assertReviewLedgerAgainstContext, + assertReviewLedgerTransition, + deriveReviewLedgerStatus, + parseReviewLedger, + reviewLedgerDigest, +} from '../src/context/review-ledger.js'; + +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const REPORT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const BLOCKER_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const REQUIREMENTS_DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('review ledger V3 contract', () => { + it('requires complete targeted/full coverage and derives passed from content', () => { + const parsed = parseReviewLedger(reviewLedger()); + expect(deriveReviewLedgerStatus(parsed)).toBe('passed'); + expect(() => + assertReviewLedgerAgainstContext(parsed, { + requirementsDigest: REQUIREMENTS_DIGEST, + planVersion: 2, + }), + ).not.toThrow(); + }); + + it('requires stale status when the aggregate version changes', () => { + const stale = withDigest({ + ...reviewLedger(), + revision: 2, + status: 'stale', + }); + const parsed = parseReviewLedger(stale); + expect(() => + assertReviewLedgerAgainstContext(parsed, { + requirementsDigest: `sha256:${'b'.repeat(64)}`, + planVersion: 2, + }), + ).not.toThrow(); + expect(() => + assertReviewLedgerAgainstContext(parsed, { + requirementsDigest: REQUIREMENTS_DIGEST, + planVersion: 2, + }), + ).toThrow(/must be passed/); + }); + + it('rejects illegal waivers and skips, and enforces revision transitions', () => { + expect(() => + parseReviewLedger( + withDigest({ + ...reviewLedger(), + blockers: [ + { + displayId: 'B-1', + legacyId: null, + blockerId: BLOCKER_ID, + domain: 'quality', + severity: 'p0', + status: 'waived', + summary: 'Critical weakness.', + waiver: { + reason: 'Not allowed.', + approvedByActorId: ACTOR_ID, + approvedAt: '2026-07-17T10:01:00.000Z', + }, + }, + ], + }), + ), + ).toThrow(/cannot be waived/); + expect(() => + parseReviewLedger( + withDigest({ + ...reviewLedger(), + status: 'skipped', + skip: { + reason: 'No review is applicable.', + approvedByActorId: ACTOR_ID, + approvedAt: '2026-07-17T10:01:00.000Z', + source: 'actor', + }, + }), + ), + ).toThrow(/cannot have required domains/); + + const previous = parseReviewLedger(reviewLedger()); + const next = parseReviewLedger( + withDigest({ ...reviewLedger(), revision: 2, status: 'stale' }), + ); + expect(() => assertReviewLedgerTransition(previous, next)).not.toThrow(); + expect(() => + assertReviewLedgerTransition(previous, { ...next, revision: 3 }), + ).toThrow(/increase exactly once/); + expect(() => + parseReviewLedger( + withDigest({ + ...reviewLedger(), + status: 'skipped', + requiredDomains: [], + domains: [], + blockers: [], + remediationRound: 0, + skip: { + reason: 'Use Authorization: Bearer secret-token.', + approvedByActorId: null, + approvedAt: '2026-07-17T10:01:00.000Z', + source: 'legacy_migration', + }, + }), + ), + ).toThrow(/MANCODE_PRIVACY_BLOCKED/); + }); +}); + +function reviewLedger(): ReviewLedgerV1 { + const draft: ReviewLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: { namespace: 'shared', taskId: TASK_ID }, + revision: 1, + status: 'passed', + depth: 'targeted', + requirementsDigest: REQUIREMENTS_DIGEST, + planVersion: 2, + requiredDomains: ['quality'], + domains: [ + { + domain: 'quality', + status: 'passed', + reportRef: { + taskRef: { namespace: 'shared', taskId: TASK_ID }, + kind: 'review_report', + artifactId: REPORT_ID, + }, + }, + ], + blockers: [], + remediationRound: 0, + skip: null, + legacySource: null, + contentDigest: '', + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; + return withDigest(draft); +} + +function withDigest(draft: ReviewLedgerV1): ReviewLedgerV1 { + return { ...draft, contentDigest: reviewLedgerDigest(draft) }; +} diff --git a/tests/task-head-fence-contracts.test.ts b/tests/task-head-fence-contracts.test.ts new file mode 100644 index 0000000..1708ba1 --- /dev/null +++ b/tests/task-head-fence-contracts.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { + type TaskAggregateManifestV1, + taskAggregateDigest, +} from '../src/context/aggregate.js'; +import { + assertTaskHeadFenceMatchesAggregate, + assertTaskHeadFenceTransition, + parseTaskHeadFence, +} from '../src/runtime/task-head-fence.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const CHECKOUT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const NEXT_OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; + +describe('shared task-head fence contract', () => { + it('binds a shared aggregate, code head, revision, and ownership epoch', () => { + const manifest = aggregateManifest(); + const fence = parseTaskHeadFence(rawFence(manifest)); + expect(() => + assertTaskHeadFenceMatchesAggregate(fence, manifest, 'abc1234'), + ).not.toThrow(); + expect(() => + assertTaskHeadFenceMatchesAggregate(fence, manifest, 'different-head'), + ).toThrow(/does not match/); + expect(() => + parseTaskHeadFence({ + ...rawFence(manifest), + taskRef: { namespace: 'local', taskId: TASK_ID }, + }), + ).toThrow(/shared TaskRefs/); + }); + + it('uses fenceRevision as a CAS and reserves same-revision adoption for reconcile', () => { + const previous = parseTaskHeadFence(rawFence(aggregateManifest())); + const next = parseTaskHeadFence({ + ...previous, + fenceRevision: 2, + taskRevision: 8, + aggregateDigest: `sha256:${'b'.repeat(64)}`, + lastOperationId: NEXT_OPERATION_ID, + updatedAt: '2026-07-17T10:01:00.000Z', + }); + expect(() => + assertTaskHeadFenceTransition(previous, next, { + expectedFenceRevision: 1, + }), + ).not.toThrow(); + expect(() => + assertTaskHeadFenceTransition(previous, next, { + expectedFenceRevision: 2, + }), + ).toThrow(/FENCE_CONFLICT/); + + const reconcile = parseTaskHeadFence({ + ...previous, + fenceRevision: 2, + aggregateDigest: `sha256:${'c'.repeat(64)}`, + lastOperationId: NEXT_OPERATION_ID, + updatedAt: '2026-07-17T10:01:00.000Z', + }); + expect(() => + assertTaskHeadFenceTransition(previous, reconcile, { + expectedFenceRevision: 1, + }), + ).toThrow(/explicit reconcile/); + expect(() => + assertTaskHeadFenceTransition(previous, reconcile, { + expectedFenceRevision: 1, + allowSameTaskRevision: true, + }), + ).not.toThrow(); + + const codeHeadReconcile = parseTaskHeadFence({ + ...previous, + fenceRevision: 2, + codeRef: { head: 'def5678' }, + lastOperationId: NEXT_OPERATION_ID, + updatedAt: '2026-07-17T10:01:00.000Z', + }); + expect(() => + assertTaskHeadFenceTransition(previous, codeHeadReconcile, { + expectedFenceRevision: 1, + allowSameTaskRevision: true, + }), + ).not.toThrow(); + }); +}); + +function aggregateManifest(): TaskAggregateManifestV1 { + return { + taskRef: { namespace: 'shared', taskId: TASK_ID }, + taskRevision: 7, + ownershipEpoch: 3, + metadataDigest: `sha256:${'a'.repeat(64)}`, + requirementsDigest: `sha256:${'b'.repeat(64)}`, + reviewDigest: `sha256:${'c'.repeat(64)}`, + verificationDigest: `sha256:${'d'.repeat(64)}`, + planVersion: 2, + planDigest: null, + latestCheckpointId: null, + latestCheckpointDigest: null, + parentSnapshotDigest: null, + }; +} + +function rawFence(manifest: TaskAggregateManifestV1) { + return { + schemaVersion: 1, + workspaceId: WORKSPACE_ID, + taskRef: manifest.taskRef, + fenceRevision: 1, + taskRevision: manifest.taskRevision, + aggregateDigest: taskAggregateDigest(manifest), + ownershipEpoch: manifest.ownershipEpoch, + codeRef: { head: 'abc1234' }, + checkoutId: CHECKOUT_ID, + remoteRevision: null, + lastOperationId: OPERATION_ID, + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} diff --git a/tests/task-locator-contracts.test.ts b/tests/task-locator-contracts.test.ts new file mode 100644 index 0000000..b1a8396 --- /dev/null +++ b/tests/task-locator-contracts.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + locateTask, + readTaskArtifact, + resolveArtifactLocation, + taskRootPath, +} from '../src/context/task-locator.js'; + +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const REPORT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); +}); + +describe('TaskLocator and ArtifactRef resolver', () => { + it('requires an explicit namespace when a bare task ID is ambiguous', async () => { + const root = await temporaryProjectRoot(); + await mkdir(taskRootPath(root, { namespace: 'local', taskId: TASK_ID }), { + recursive: true, + }); + await expect(locateTask(root, TASK_ID)).resolves.toMatchObject({ + taskRef: { namespace: 'local', taskId: TASK_ID }, + }); + + await mkdir(taskRootPath(root, { namespace: 'shared', taskId: TASK_ID }), { + recursive: true, + }); + await expect(locateTask(root, TASK_ID)).rejects.toThrow( + 'MANCODE_TASK_AMBIGUOUS', + ); + await expect(locateTask(root, `shared:${TASK_ID}`)).resolves.toMatchObject({ + taskRef: { namespace: 'shared', taskId: TASK_ID }, + }); + }); + + it('maps typed artifacts to fixed paths and rejects symlinked content', async () => { + const root = await temporaryProjectRoot(); + const taskRef = { namespace: 'shared' as const, taskId: TASK_ID }; + const taskRoot = taskRootPath(root, taskRef); + await mkdir(taskRoot, { recursive: true }); + await writeFile( + path.join(taskRoot, 'requirements.json'), + '{"goal":"safe"}\n', + ); + + const requirements = { taskRef, kind: 'requirements' as const }; + expect(resolveArtifactLocation(root, requirements).path).toBe( + path.join(taskRoot, 'requirements.json'), + ); + await expect(readTaskArtifact(root, requirements)).resolves.toBe( + '{"goal":"safe"}\n', + ); + expect(() => + resolveArtifactLocation(root, { + ...requirements, + artifactId: REPORT_ID, + }), + ).toThrow('MANCODE_ARTIFACT_PATH_UNSAFE'); + + const outside = path.join(root, 'outside.md'); + await writeFile(outside, 'private data\n'); + const report = { + taskRef, + kind: 'review_report' as const, + artifactId: REPORT_ID, + }; + const reportPath = resolveArtifactLocation(root, report).path; + await mkdir(path.dirname(reportPath), { recursive: true }); + await symlink(outside, reportPath); + await expect(readTaskArtifact(root, report)).rejects.toThrow( + 'MANCODE_ARTIFACT_PATH_UNSAFE', + ); + }); +}); + +async function temporaryProjectRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'mancode-task-locator-')); + roots.push(root); + return root; +} diff --git a/tests/team-assessment-contracts.test.ts b/tests/team-assessment-contracts.test.ts new file mode 100644 index 0000000..109ea33 --- /dev/null +++ b/tests/team-assessment-contracts.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { resolveWorkflowCreation } from '../src/context/creation-resolution.js'; +import { assessTeam } from '../src/team/assessment.js'; + +const PARENT_TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; + +describe('team assessment and workflow creation resolution', () => { + it('requires both recent collaborators and a coordination path for auto team', () => { + const single = assessTeam({ + policy: 'auto', + signals: { + isGitRepository: true, + remoteCount: 1, + contributorsAllTime: 4, + contributorsRecent: 1, + hasTrackedUpstream: true, + hasCodeowners: true, + hasPullRequestTemplate: true, + }, + evaluatedAt: '2026-07-17T12:00:00.000Z', + }); + expect(single.recommendation).toBe('single'); + expect(single.reasons).toContain( + 'historical contributors do not trigger team mode alone', + ); + + const team = assessTeam({ + policy: 'auto', + signals: { + isGitRepository: true, + remoteCount: 1, + contributorsAllTime: 4, + contributorsRecent: 2, + hasTrackedUpstream: true, + hasCodeowners: false, + hasPullRequestTemplate: false, + }, + evaluatedAt: '2026-07-17T12:00:00.000Z', + }); + expect(team).toMatchObject({ + recommendation: 'team', + source: 'auto', + confidence: 'high', + }); + }); + + it('returns assessment without letting it silently change a man workflow', () => { + const assessment = assessTeam({ + policy: 'on', + signals: null, + evaluatedAt: '2026-07-17T12:00:00.000Z', + }); + const resolution = resolveWorkflowCreation({ + workflowMode: 'man', + parent: null, + policy: { defaultVisibility: 'shared' }, + assessment, + }); + expect(resolution.descriptor).toEqual({ + workflowMode: 'man', + visibility: 'shared', + coordination: 'single', + parent: null, + }); + expect(resolution.dimensions.visibility.source).toBe('policy_default'); + expect(resolution.dimensions.coordination.source).toBe('mode_constraint'); + expect(resolution.sharedPrivacyConfirmationRequired).toBe(true); + expect(resolution.assessment?.recommendation).toBe('team'); + }); + + it('makes child dimensions inherit from the parent and rejects conflicts', () => { + const resolution = resolveWorkflowCreation({ + workflowMode: 'manba', + parent: { + taskRef: { namespace: 'shared', taskId: PARENT_TASK_ID }, + workflowMode: 'manteam', + visibility: 'shared', + coordination: 'team', + }, + policy: { defaultVisibility: 'local' }, + assessment: null, + }); + expect(resolution.descriptor).toMatchObject({ + visibility: 'shared', + coordination: 'team', + }); + expect(resolution.dimensions.visibility.source).toBe('parent'); + + expect(() => + resolveWorkflowCreation({ + workflowMode: 'manba', + parent: { + taskRef: { namespace: 'shared', taskId: PARENT_TASK_ID }, + workflowMode: 'manteam', + visibility: 'shared', + coordination: 'team', + }, + visibility: 'local', + policy: null, + assessment: null, + }), + ).toThrow(/inherit/); + }); + + it('enforces the standalone and manteam mode boundaries', () => { + expect(() => + resolveWorkflowCreation({ + workflowMode: 'manba', + parent: null, + visibility: 'shared', + policy: null, + assessment: null, + }), + ).toThrow(/standalone manba/); + expect(() => + resolveWorkflowCreation({ + workflowMode: 'manteam', + parent: null, + coordination: 'single', + policy: null, + assessment: null, + }), + ).toThrow(/manteam coordination/); + }); +}); diff --git a/tests/team-event-contracts.test.ts b/tests/team-event-contracts.test.ts new file mode 100644 index 0000000..ac5945b --- /dev/null +++ b/tests/team-event-contracts.test.ts @@ -0,0 +1,101 @@ +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + type TeamEventV1, + dedupeTeamEvents, + listTeamEvents, + parseTeamEvent, + teamEventDedupeKey, + writeTeamEvent, +} from '../src/team/events.js'; + +const EVENT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const RETRY_EVENT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); +}); + +describe('team event contract', () => { + it('accepts a compact shared audit projection without any text payload', () => { + const event = parseTeamEvent(teamEvent()); + expect(teamEventDedupeKey(event)).toBe(`${OPERATION_ID}:handoff_accepted`); + expect(event.taskRef).toEqual({ namespace: 'shared', taskId: TASK_ID }); + }); + + it('does not allow events to carry local task references or arbitrary logs', () => { + expect(() => + parseTeamEvent({ + ...teamEvent(), + taskRef: { namespace: 'local', taskId: TASK_ID }, + }), + ).toThrow(/shared TaskRefs/); + expect(() => + parseTeamEvent({ + ...teamEvent(), + rawPrompt: 'Authorization: Bearer super-secret', + }), + ).toThrow(/unknown field/); + }); + + it('deduplicates retried projection writes by operation and event type', () => { + const original = parseTeamEvent(teamEvent()); + const retry = parseTeamEvent({ + ...teamEvent(), + eventId: RETRY_EVENT_ID, + createdAt: '2026-07-17T10:01:00.000Z', + }); + expect(dedupeTeamEvents([retry, original])).toEqual([original]); + }); + + it('rejects conflicting payloads hidden behind one idempotency key', () => { + const conflicting = parseTeamEvent({ + ...teamEvent(), + eventId: RETRY_EVENT_ID, + entityRef: { kind: 'handoff', id: RETRY_EVENT_ID }, + }); + expect(() => dedupeTeamEvents([teamEvent(), conflicting])).toThrow( + 'MANCODE_TEAM_EVENT_DEDUPE_CONFLICT', + ); + }); + + it('persists one retry-safe audit projection after the business commit point', async () => { + const root = await temporaryRoot(); + const original = parseTeamEvent(teamEvent()); + const retry = parseTeamEvent({ ...teamEvent(), eventId: RETRY_EVENT_ID }); + await expect(writeTeamEvent(root, original)).resolves.toEqual(original); + await expect(writeTeamEvent(root, retry)).resolves.toEqual(original); + await expect(listTeamEvents(root)).resolves.toEqual([original]); + await expect( + readdir(path.join(root, '.mancode', 'shared', 'team', 'events')), + ).resolves.toEqual([`${EVENT_ID}.json`]); + }); +}); + +function teamEvent(): TeamEventV1 { + return { + schemaVersion: 1, + eventId: EVENT_ID, + eventType: 'handoff_accepted', + operationId: OPERATION_ID, + entityRef: { kind: 'handoff', id: EVENT_ID }, + taskRef: { namespace: 'shared', taskId: TASK_ID }, + actorId: ACTOR_ID, + taskRevision: 8, + createdAt: '2026-07-17T10:00:00.000Z', + }; +} + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'mancode-event-contract-')); + roots.push(root); + return root; +} diff --git a/tests/team-policy-contracts.test.ts b/tests/team-policy-contracts.test.ts new file mode 100644 index 0000000..59ceae2 --- /dev/null +++ b/tests/team-policy-contracts.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { + assertConfigPolicyConsistency, + assertIndependentConfigPolicyUpdate, + assertProjectConfigTransition, + assertTeamPolicyTransition, + parseProjectConfig, + parseTeamPolicy, + projectConfigIdentityDigest, +} from '../src/team/policy.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; + +describe('project config and team policy V1 contracts', () => { + it('keeps transport authority exclusively in project config', () => { + const config = parseProjectConfig(rawConfig()); + const policy = parseTeamPolicy(rawPolicy()); + expect(() => assertConfigPolicyConsistency(config, policy)).not.toThrow(); + expect(projectConfigIdentityDigest(config)).toBe( + projectConfigIdentityDigest( + parseProjectConfig({ + ...rawConfig(), + revision: 2, + transport: { mode: 'git-ref', remote: 'origin' }, + }), + ), + ); + expect(() => + parseTeamPolicy({ ...rawPolicy(), transport: { mode: 'local' } }), + ).toThrow(/unknown field/); + expect(() => + assertConfigPolicyConsistency( + config, + parseTeamPolicy({ + ...rawPolicy(), + workspaceId: '01JZ4B6W5Z0A1B2C3D4E5F6G7J', + }), + ), + ).toThrow(/workspaceId must match/); + }); + + it('uses separate revision CAS paths for config transport and team policy', () => { + const previousConfig = parseProjectConfig(rawConfig()); + const nextConfig = parseProjectConfig({ + ...rawConfig(), + revision: 2, + transport: { mode: 'git-ref', remote: 'origin', epoch: 2 }, + }); + expect(() => + assertProjectConfigTransition(previousConfig, nextConfig, 'ordinary'), + ).toThrow(/transport may only change/); + expect(() => + assertProjectConfigTransition( + previousConfig, + nextConfig, + 'transport_set', + ), + ).not.toThrow(); + + const previousPolicy = parseTeamPolicy(rawPolicy()); + const nextPolicy = parseTeamPolicy({ + ...rawPolicy(), + revision: 2, + policy: 'on', + }); + expect(() => + assertTeamPolicyTransition(previousPolicy, nextPolicy), + ).not.toThrow(); + expect(() => + assertIndependentConfigPolicyUpdate( + previousConfig, + nextConfig, + previousPolicy, + nextPolicy, + ), + ).toThrow(/cannot be updated by one ordinary patch/); + }); +}); + +function rawConfig() { + return { + schemaVersion: 1, + revision: 1, + workspaceId: WORKSPACE_ID, + transport: { mode: 'local', remote: null }, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} + +function rawPolicy() { + return { + schemaVersion: 1, + revision: 1, + workspaceId: WORKSPACE_ID, + policy: 'auto', + recentDays: 30, + defaultVisibility: 'local', + shareConfirmedDecisions: true, + retention: { + localRawArtifactDays: 30, + localCacheDays: 7, + completedSessionDays: 30, + }, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} diff --git a/tests/transport-capabilities-contracts.test.ts b/tests/transport-capabilities-contracts.test.ts new file mode 100644 index 0000000..e0ba9b1 --- /dev/null +++ b/tests/transport-capabilities-contracts.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { parseProjectConfig } from '../src/team/policy.js'; +import { + assertRemoteMutationAvailable, + capabilitiesFromProjectConfig, + localCoordinationCapabilities, + parseCoordinationCapabilities, +} from '../src/team/transport.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; + +describe('coordination capability and transport contract', () => { + it('keeps local coordination distinct from cross-clone transport', () => { + const local = localCoordinationCapabilities(); + expect(local).toMatchObject({ + claimAcquisition: 'enforced', + writeGuard: 'advisory', + transport: 'local', + transportFreshness: 'unavailable', + }); + expect(() => + assertRemoteMutationAvailable(local, { + operationId: OPERATION_ID, + expectedRemoteRevision: 0, + expectedOwnershipEpoch: 0, + }), + ).toThrow('MANCODE_TRANSPORT_UNAVAILABLE'); + expect(() => + parseCoordinationCapabilities({ + ...local, + transportFreshness: 'fresh', + }), + ).toThrow(/local transport/); + }); + + it('requires fresh git-ref state and matching remote revision for remote CAS', () => { + const fresh = parseCoordinationCapabilities({ + claimAcquisition: 'enforced', + writeGuard: 'advisory', + transport: 'git-ref', + transportFreshness: 'fresh', + lastSuccessfulSyncAt: '2026-07-17T10:00:00.000Z', + remoteRevision: 7, + }); + expect(() => + assertRemoteMutationAvailable(fresh, { + operationId: OPERATION_ID, + expectedRemoteRevision: 7, + expectedOwnershipEpoch: 3, + }), + ).not.toThrow(); + expect(() => + assertRemoteMutationAvailable(fresh, { + operationId: OPERATION_ID, + expectedRemoteRevision: 6, + expectedOwnershipEpoch: 3, + }), + ).toThrow('MANCODE_TRANSPORT_REVISION_CONFLICT'); + + const config = parseProjectConfig({ + schemaVersion: 1, + revision: 1, + workspaceId: WORKSPACE_ID, + transport: { mode: 'git-ref', remote: 'origin' }, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }); + expect(capabilitiesFromProjectConfig(config)).toMatchObject({ + transport: 'git-ref', + transportFreshness: 'unknown', + claimAcquisition: 'unavailable', + }); + }); +}); diff --git a/tests/transport-interface-contracts.test.ts b/tests/transport-interface-contracts.test.ts new file mode 100644 index 0000000..cf1795c --- /dev/null +++ b/tests/transport-interface-contracts.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import { + type CoordinationCapabilitiesV1, + type CoordinationTransport, + GitRefCoordinationTransportAdapter, + type GitRefCoordinationTransportBackend, + LocalCoordinationTransportAdapter, + type TransportMutationRequest, + parseCoordinationCapabilities, +} from '../src/team/transport.js'; + +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const SYNCED_AT = '2026-07-18T10:00:00.000Z'; + +interface TransportContractFixture { + transport: CoordinationTransport; + backend: StatefulGitRefBackend | null; + supportsRemoteMutation: boolean; +} + +interface TransportContractDefinition { + mode: CoordinationTransport['mode']; + create(): TransportContractFixture; +} + +function defineTransportContract( + name: string, + definition: TransportContractDefinition, +): void { + describe(name, () => { + it('reports a valid capability tuple and keeps inspect cache-only', async () => { + const fixture = definition.create(); + + expect(fixture.transport.mode).toBe(definition.mode); + const inspected = parseCoordinationCapabilities( + await fixture.transport.inspect(), + ); + expect(inspected.transport).toBe(definition.mode); + if (fixture.backend !== null) { + expect(fixture.backend.calls).toMatchObject({ + inspect: 1, + pull: 0, + push: 0, + }); + } + + const pulled = parseCoordinationCapabilities( + await fixture.transport.pull(), + ); + expect(pulled.transport).toBe(definition.mode); + if (fixture.supportsRemoteMutation) { + expect(pulled).toMatchObject({ + transportFreshness: 'fresh', + remoteRevision: 4, + }); + expect(fixture.backend?.calls).toMatchObject({ + inspect: 1, + pull: 1, + push: 0, + }); + } else { + expect(pulled).toMatchObject({ + transportFreshness: 'unavailable', + lastSuccessfulSyncAt: null, + remoteRevision: null, + }); + } + }); + + it('rejects an invalid mutation envelope before backend mutation', async () => { + const fixture = definition.create(); + const invalid = { + operationId: 'not-an-ulid', + expectedRemoteRevision: 0, + expectedOwnershipEpoch: 0, + } as TransportMutationRequest; + + await expect(fixture.transport.push(invalid)).rejects.toThrow(/ULID/); + expect(fixture.backend?.calls.push ?? 0).toBe(0); + }); + + it('applies the transport-specific remote mutation fence', async () => { + const fixture = definition.create(); + const request = mutationRequest(4); + + if (!fixture.supportsRemoteMutation) { + await expect(fixture.transport.push(request)).rejects.toThrow( + 'MANCODE_TRANSPORT_UNAVAILABLE', + ); + return; + } + + await expect(fixture.transport.push(request)).rejects.toThrow( + 'MANCODE_TRANSPORT_UNAVAILABLE', + ); + expect(fixture.backend?.calls.push).toBe(0); + + await fixture.transport.pull(); + await expect(fixture.transport.push(mutationRequest(3))).rejects.toThrow( + 'MANCODE_TRANSPORT_REVISION_CONFLICT', + ); + expect(fixture.backend?.calls.push).toBe(0); + + const pushed = await fixture.transport.push(request); + expect(pushed).toMatchObject({ + transport: 'git-ref', + transportFreshness: 'fresh', + remoteRevision: 5, + }); + expect(fixture.backend?.calls.push).toBe(1); + expect(fixture.backend?.lastMutation).toEqual(request); + expect(fixture.backend?.remoteRevision).toBe(5); + }); + }); +} + +defineTransportContract('local coordination transport adapter contract', { + mode: 'local', + create: () => ({ + transport: new LocalCoordinationTransportAdapter(), + backend: null, + supportsRemoteMutation: false, + }), +}); + +defineTransportContract('git-ref coordination transport adapter contract', { + mode: 'git-ref', + create: () => { + const backend = new StatefulGitRefBackend(); + return { + transport: new GitRefCoordinationTransportAdapter(backend), + backend, + supportsRemoteMutation: true, + }; + }, +}); + +describe('git-ref coordination transport freshness fence', () => { + it('degrades stale cache state and does not call the mutation backend', async () => { + const backend = new StatefulGitRefBackend(); + const transport = new GitRefCoordinationTransportAdapter(backend); + await transport.pull(); + backend.markStale(); + + await expect(transport.inspect()).resolves.toMatchObject({ + claimAcquisition: 'advisory', + transportFreshness: 'stale', + remoteRevision: 4, + }); + await expect(transport.push(mutationRequest(4))).rejects.toThrow( + 'MANCODE_TRANSPORT_UNAVAILABLE', + ); + expect(backend.calls.push).toBe(0); + }); +}); + +class StatefulGitRefBackend implements GitRefCoordinationTransportBackend { + readonly calls = { inspect: 0, pull: 0, push: 0 }; + remoteRevision = 4; + lastMutation: TransportMutationRequest | null = null; + private cached: CoordinationCapabilitiesV1 = unknownCapabilities(); + + async inspect(): Promise { + this.calls.inspect += 1; + return { ...this.cached }; + } + + async pull(): Promise { + this.calls.pull += 1; + this.cached = freshCapabilities(this.remoteRevision); + return { ...this.cached }; + } + + async push( + request: TransportMutationRequest, + ): Promise { + this.calls.push += 1; + if (request.expectedRemoteRevision !== this.remoteRevision) { + throw new Error('MANCODE_TRANSPORT_REVISION_CONFLICT'); + } + this.lastMutation = { ...request }; + this.remoteRevision += 1; + this.cached = freshCapabilities(this.remoteRevision); + return { ...this.cached }; + } + + markStale(): void { + this.cached = { + ...freshCapabilities(this.remoteRevision), + claimAcquisition: 'advisory', + transportFreshness: 'stale', + }; + } +} + +function mutationRequest(remoteRevision: number): TransportMutationRequest { + return { + operationId: OPERATION_ID, + expectedRemoteRevision: remoteRevision, + expectedOwnershipEpoch: 2, + }; +} + +function unknownCapabilities(): CoordinationCapabilitiesV1 { + return { + claimAcquisition: 'unavailable', + writeGuard: 'advisory', + transport: 'git-ref', + transportFreshness: 'unknown', + lastSuccessfulSyncAt: null, + remoteRevision: null, + }; +} + +function freshCapabilities(remoteRevision: number): CoordinationCapabilitiesV1 { + return { + claimAcquisition: 'enforced', + writeGuard: 'advisory', + transport: 'git-ref', + transportFreshness: 'fresh', + lastSuccessfulSyncAt: SYNCED_AT, + remoteRevision, + }; +} diff --git a/tests/transport-migration-adapters-contracts.test.ts b/tests/transport-migration-adapters-contracts.test.ts new file mode 100644 index 0000000..4d9fb61 --- /dev/null +++ b/tests/transport-migration-adapters-contracts.test.ts @@ -0,0 +1,522 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + teamTransportMigrate, + teamTransportRecover, +} from '../src/commands/team.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { createV3Checkpoint } from '../src/context/checkpoint-create.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import type { TaskRef } from '../src/context/task-ref.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { listClaims } from '../src/runtime/claim-store.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, +} from '../src/team/actor.js'; +import { acquireV3Claim } from '../src/team/claim-acquisition.js'; +import { GitRefTeamManifestStore } from '../src/team/git-ref-transport.js'; +import { handoffSuccessorClaimId } from '../src/team/handoff-operation.js'; +import { createTransportMigrationFileAdapters } from '../src/team/transport-migration-adapters.js'; +import { + assertLocalCoordinationWriteAllowed, + readLocalTransportAuthorityState, +} from '../src/team/transport-migration-freeze.js'; +import type { TransportMigrationConfigAdapter } from '../src/team/transport-migration.js'; +import { + executeTransportMigration, + recoverTransportMigration, + stageTransportMigration, +} from '../src/team/transport-migration.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-18T02:00:00.000Z'); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('filesystem and git-ref transport migration adapters', () => { + it('repairs a visible local-to-git-ref config CAS and then migrates back to a fresh local authority', async () => { + const fixture = await bootstrap(); + const firstOperationId = id(20); + const first = await createTransportMigrationFileAdapters({ + projectRoot: fixture.projectRoot, + actorId: fixture.actorId, + targetMode: 'git-ref', + targetRemote: 'origin', + operationId: firstOperationId, + now: () => NOW, + }); + let crashAfterConfigWrite = true; + const crashConfig: TransportMigrationConfigAdapter = { + read: () => first.config.read(), + compareAndSwap: async (input) => { + const written = await first.config.compareAndSwap(input); + if (crashAfterConfigWrite) { + throw new Error('simulated process loss after filesystem config CAS'); + } + return written; + }, + }; + + await expect( + executeTransportMigration({ + ...first, + config: crashConfig, + operationId: firstOperationId, + actorId: fixture.actorId, + sessionId: fixture.sessionId, + expectedConfigRevision: 1, + joined: true, + explicitConfirmation: true, + now: NOW, + }), + ).rejects.toThrow('simulated process loss after filesystem config CAS'); + + await expect(readProjectConfig(fixture.projectRoot)).resolves.toMatchObject( + { + revision: 2, + transport: { mode: 'git-ref', remote: 'origin', epoch: 2 }, + }, + ); + await expect( + readOperationJournal(first.operationStore, firstOperationId), + ).resolves.toMatchObject({ state: 'repair_required' }); + await expect( + readLocalTransportAuthorityState(first.operationStore), + ).resolves.toMatchObject({ + state: 'frozen', + operationId: firstOperationId, + transportEpoch: 1, + }); + + crashAfterConfigWrite = false; + const recoveredAdapters = await createTransportMigrationFileAdapters({ + projectRoot: fixture.projectRoot, + actorId: fixture.actorId, + targetMode: 'git-ref', + targetRemote: 'origin', + operationId: firstOperationId, + now: () => NOW, + }); + const recovered = await recoverTransportMigration({ + ...recoveredAdapters, + operationId: firstOperationId, + actorId: fixture.actorId, + sessionId: fixture.sessionId, + }); + expect(recovered).toMatchObject({ + state: 'repaired', + journal: { state: 'committed' }, + activatedConfig: { + revision: 2, + transport: { mode: 'git-ref', epoch: 2 }, + }, + established: { + transportEpoch: 2, + activeClaims: [{ predecessorClaimId: fixture.claimId }], + }, + }); + await expect( + assertLocalCoordinationWriteAllowed(first.operationStore, 1), + ).rejects.toThrow('MANCODE_TRANSPORT_AUTHORITY_TOMBSTONED'); + + const remoteAfterFirst = await remoteSnapshot(fixture.projectRoot); + expect(remoteAfterFirst).toMatchObject({ + authorityState: 'active', + transportEpoch: 2, + configRevision: 2, + claims: [{ predecessorClaimId: fixture.claimId, state: 'active' }], + }); + const remoteClaimId = remoteAfterFirst.claims[0]?.claimId; + if (remoteClaimId === undefined) throw new Error('missing remote claim'); + expect(remoteClaimId).toBe( + handoffSuccessorClaimId( + firstOperationId, + fixture.claimId, + NOW.toISOString(), + ), + ); + + const secondOperationId = id(21); + const second = await createTransportMigrationFileAdapters({ + projectRoot: fixture.projectRoot, + actorId: fixture.actorId, + targetMode: 'local', + operationId: secondOperationId, + now: () => new Date(NOW.getTime() + 1_000), + }); + const migratedBack = await executeTransportMigration({ + ...second, + operationId: secondOperationId, + actorId: fixture.actorId, + sessionId: fixture.sessionId, + expectedConfigRevision: 2, + joined: true, + explicitConfirmation: true, + now: new Date(NOW.getTime() + 1_000), + }); + expect(migratedBack).toMatchObject({ + journal: { state: 'committed' }, + activatedConfig: { + revision: 3, + transport: { mode: 'local', remote: null, epoch: 3 }, + }, + established: { + transportEpoch: 3, + activeClaims: [{ predecessorClaimId: remoteClaimId }], + }, + }); + await expect( + assertLocalCoordinationWriteAllowed(second.operationStore, 3), + ).resolves.toBeUndefined(); + await expect( + assertLocalCoordinationWriteAllowed(second.operationStore, 1), + ).rejects.toThrow('MANCODE_TRANSPORT_MIGRATION_SPLIT_BRAIN'); + await expect(listClaims(second.operationStore)).resolves.toMatchObject([ + { + claimId: handoffSuccessorClaimId( + secondOperationId, + remoteClaimId, + new Date(NOW.getTime() + 1_000).toISOString(), + ), + state: 'active', + predecessorClaimId: remoteClaimId, + coordinationDomainId: second.target.coordinationDomainId, + authority: { mode: 'local', remoteRevision: null }, + }, + ]); + await expect(remoteSnapshot(fixture.projectRoot)).resolves.toMatchObject({ + authorityState: 'tombstoned', + authorityTombstone: { + operationId: secondOperationId, + successorMode: 'local', + successorEpoch: 3, + }, + }); + }); + + it('discards a durable staged target and unfreezes the local write fence on abort', async () => { + const fixture = await bootstrap(); + const operationId = id(30); + const adapters = await createTransportMigrationFileAdapters({ + projectRoot: fixture.projectRoot, + actorId: fixture.actorId, + targetMode: 'git-ref', + targetRemote: 'origin', + operationId, + now: () => NOW, + }); + await stageTransportMigration({ + ...adapters, + operationId, + actorId: fixture.actorId, + sessionId: fixture.sessionId, + expectedConfigRevision: 1, + joined: true, + explicitConfirmation: true, + now: NOW, + }); + await expect( + assertLocalCoordinationWriteAllowed(adapters.operationStore, 1), + ).rejects.toThrow('MANCODE_TRANSPORT_MIGRATION_FROZEN'); + await expect( + createV3Checkpoint({ + projectRoot: fixture.projectRoot, + taskRef: fixture.taskRef, + sessionId: fixture.sessionId, + expectedTaskRevision: 1, + kind: 'diagnostic_started', + summary: 'This write must remain blocked during migration.', + operationId: id(31), + checkpointId: id(32), + now: NOW, + }), + ).rejects.toThrow('MANCODE_TRANSPORT_MIGRATION_FROZEN'); + await expect( + createV3Workflow({ + projectRoot: fixture.projectRoot, + task: 'This new shared task must not race transport migration.', + workflowMode: 'manteam', + sessionId: fixture.sessionId, + client: 'vitest', + taskId: id(33), + operationId: id(34), + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/team/**'] }, + now: NOW, + }), + ).rejects.toThrow('MANCODE_TRANSPORT_MIGRATION_FROZEN'); + + const rebuilt = await createTransportMigrationFileAdapters({ + projectRoot: fixture.projectRoot, + actorId: fixture.actorId, + targetMode: 'git-ref', + targetRemote: 'origin', + operationId, + now: () => NOW, + }); + await expect( + recoverTransportMigration({ + ...rebuilt, + operationId, + actorId: fixture.actorId, + sessionId: fixture.sessionId, + mode: 'abort', + }), + ).resolves.toMatchObject({ state: 'aborted' }); + await expect( + assertLocalCoordinationWriteAllowed(adapters.operationStore, 1), + ).resolves.toBeUndefined(); + await expect(rebuilt.target.readStaged(operationId)).resolves.toBeNull(); + await expect(readProjectConfig(fixture.projectRoot)).resolves.toMatchObject( + { + revision: 1, + transport: { mode: 'local', epoch: 1 }, + }, + ); + }); + + it('exposes dry-run, safe abort, and committed migration through the team command', async () => { + const fixture = await bootstrap(); + const dryRun = await captureJson(() => + teamTransportMigrate(fixture.projectRoot, { + to: 'git-ref', + remote: 'origin', + expectedConfigRevision: '1', + confirm: true, + dryRun: true, + session: fixture.sessionId, + client: 'vitest', + json: true, + }), + ); + expect(dryRun).toMatchObject({ + exitCode: 0, + value: { + dryRun: true, + source: { config: { transport: { mode: 'local', epoch: 1 } } }, + target: { mode: 'git-ref', remote: 'origin', transportEpoch: 2 }, + taskCount: 1, + activeClaimCount: 1, + }, + }); + await expect(readProjectConfig(fixture.projectRoot)).resolves.toMatchObject( + { revision: 1, transport: { mode: 'local', epoch: 1 } }, + ); + + const abortedOperationId = id(40); + const stagedAdapters = await createTransportMigrationFileAdapters({ + projectRoot: fixture.projectRoot, + actorId: fixture.actorId, + targetMode: 'git-ref', + targetRemote: 'origin', + operationId: abortedOperationId, + now: () => NOW, + }); + await stageTransportMigration({ + ...stagedAdapters, + operationId: abortedOperationId, + actorId: fixture.actorId, + sessionId: fixture.sessionId, + expectedConfigRevision: 1, + joined: true, + explicitConfirmation: true, + now: NOW, + }); + const aborted = await captureJson(() => + teamTransportRecover(fixture.projectRoot, abortedOperationId, { + to: 'git-ref', + remote: 'origin', + abort: true, + session: fixture.sessionId, + client: 'vitest', + json: true, + }), + ); + expect(aborted).toMatchObject({ + exitCode: 0, + value: { result: { state: 'aborted', journal: { state: 'aborted' } } }, + }); + + const migrated = await captureJson(() => + teamTransportMigrate(fixture.projectRoot, { + to: 'git-ref', + remote: 'origin', + expectedConfigRevision: '1', + confirm: true, + session: fixture.sessionId, + client: 'vitest', + json: true, + }), + ); + expect(migrated).toMatchObject({ + exitCode: 0, + value: { + dryRun: false, + target: { mode: 'git-ref', transportEpoch: 2 }, + config: { revision: 2, transport: { mode: 'git-ref', epoch: 2 } }, + authority: { transportEpoch: 2, activeClaims: [expect.any(Object)] }, + operation: { type: 'transport_migrate', state: 'committed' }, + }, + }); + }); +}); + +interface Fixture { + projectRoot: string; + actorId: Ulid; + sessionId: Ulid; + claimId: Ulid; + taskRef: TaskRef; +} + +async function bootstrap(): Promise { + const container = await mkdtemp( + path.join(tmpdir(), 'mancode-migration-adapters-'), + ); + roots.push(container); + const projectRoot = path.join(container, 'project'); + const remote = path.join(container, 'remote.git'); + await mkdir(projectRoot); + await execFile('git', ['init', '--bare', remote]); + await execFile('git', ['init', '-b', 'main'], { cwd: projectRoot }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: projectRoot, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { + cwd: projectRoot, + }); + await execFile('git', ['remote', 'add', 'origin', remote], { + cwd: projectRoot, + }); + await writeFile(path.join(projectRoot, 'README.md'), '# adapter fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: projectRoot }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: projectRoot }); + await execFile('git', ['push', '-u', 'origin', 'main'], { cwd: projectRoot }); + + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const actorId = id(4); + const sessionId = id(5); + const localActor = await createLocalActor(projectRoot, { + actorId, + displayName: 'Migration Adapter Owner', + now: NOW, + }); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(localActor, NOW), + ); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + const workflow = await createV3Workflow({ + projectRoot, + task: 'Exercise both real transport migration authorities.', + workflowMode: 'manteam', + sessionId, + client: 'vitest', + taskId: id(10), + operationId: id(11), + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/team/**'] }, + now: NOW, + }); + const claimId = id(12); + await acquireV3Claim({ + projectRoot, + taskRef: workflow.taskRef, + sessionId, + expectedTaskRevision: 1, + scope: { + paths: ['src/team/**'], + modules: [], + apis: [], + schemas: [], + }, + claimId, + operationId: id(13), + now: NOW, + }); + return { + projectRoot, + actorId, + sessionId, + claimId, + taskRef: workflow.taskRef, + }; +} + +async function remoteSnapshot(projectRoot: string) { + const config = await readProjectConfig(projectRoot); + const store = new GitRefTeamManifestStore({ + projectRoot, + remote: 'origin', + workspaceId: config.workspaceId, + }); + const snapshot = await store.pull(); + if (snapshot.manifest === null) throw new Error('missing remote manifest'); + return snapshot.manifest; +} + +async function readProjectConfig(projectRoot: string) { + return JSON.parse( + await readFile( + path.join(projectRoot, '.mancode', 'shared', 'config.json'), + 'utf8', + ), + ) as { + revision: number; + workspaceId: Ulid; + transport: { + mode: 'local' | 'git-ref'; + remote: string | null; + epoch: number; + }; + }; +} + +async function captureJson(action: () => Promise): Promise<{ + exitCode: number; + value: Record; +}> { + const writes: string[] = []; + const previous = console.log; + console.log = (value: unknown) => writes.push(String(value)); + try { + return { + exitCode: await action(), + value: JSON.parse(writes.at(-1) ?? '{}'), + }; + } finally { + console.log = previous; + } +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-18T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/transport-migration-contracts.test.ts b/tests/transport-migration-contracts.test.ts new file mode 100644 index 0000000..4156903 --- /dev/null +++ b/tests/transport-migration-contracts.test.ts @@ -0,0 +1,684 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import type { Ulid } from '../src/context/ids.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { + type EntityHomeStore, + resolveCoordinationEntityHomeStore, +} from '../src/runtime/entity-home-store.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { readTaskHeadFence } from '../src/runtime/task-head-store.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, +} from '../src/team/actor.js'; +import type { ClaimV1 } from '../src/team/claims.js'; +import { createGitRefTaskBundle } from '../src/team/git-ref-bundle.js'; +import type { GitRefTaskBundleV1 } from '../src/team/git-ref-transport.js'; +import type { + CoordinationTransport, + ProjectConfigV1, +} from '../src/team/policy.js'; +import { + type EstablishedTransportAuthorityV1, + type StagedTransportAuthorityV1, + type TransportAuthorityTombstoneV1, + type TransportMigrationAuthoritySnapshotV1, + type TransportMigrationConfigAdapter, + type TransportMigrationManifestV1, + type TransportMigrationSourceAdapter, + type TransportMigrationStartInput, + type TransportMigrationTargetAdapter, + executeTransportMigration, + previewTransportMigration, + recoverTransportMigration, + stageTransportMigration, +} from '../src/team/transport-migration.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H' as Ulid; +const CHECKOUT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J' as Ulid; +const OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K' as Ulid; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M' as Ulid; +const SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N' as Ulid; +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7P' as Ulid; +const CLAIM_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7Q' as Ulid; +const TARGET_CLAIM_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7R' as Ulid; +const INIT_OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7S' as Ulid; +const SCHEMA_EPOCH = '01JZ4B6W5Z0A1B2C3D4E5F6G7T' as Ulid; +const WORKFLOW_OPERATION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7V' as Ulid; +const NOW = new Date('2026-07-18T01:00:00.000Z'); +const execFile = promisify(execFileCallback); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); +}); + +describe('journaled transport migration', () => { + it('previews without writes and stages under a durable freeze without switching config', async () => { + const fixture = await migrationFixture('local', 'git-ref'); + + const preview = await previewTransportMigration(fixture.input); + expect(preview.manifest.target).toMatchObject({ + mode: 'git-ref', + transportEpoch: 2, + coordinationDomainId: fixture.target.coordinationDomainId, + }); + expect(fixture.events).toEqual([]); + await expect( + readOperationJournal(fixture.store, OPERATION_ID), + ).resolves.toBeNull(); + + const staged = await stageTransportMigration(fixture.input); + expect(staged.journal).toMatchObject({ + state: 'applying', + steps: [ + { id: 'freeze-shared-coordination-writes', state: 'completed' }, + { id: 'validate-old-authority', state: 'completed' }, + { id: 'stage-new-authority', state: 'completed' }, + { id: 'establish-new-epoch', state: 'pending' }, + { id: 'switch-config-authority', state: 'pending' }, + { id: 'commit', state: 'pending' }, + ], + }); + expect(fixture.config.current.transport).toEqual({ + mode: 'local', + remote: null, + epoch: 1, + }); + expect(fixture.source.frozen).toBe(true); + expect(fixture.events).toEqual(['freeze', 'stage']); + }); + + it.each([ + ['local', 'git-ref'], + ['git-ref', 'local'], + ] as const)( + 'switches %s to %s only after establishment and tombstones the old authority', + async (sourceMode, targetMode) => { + const fixture = await migrationFixture(sourceMode, targetMode); + const result = await executeTransportMigration(fixture.input); + + expect(result.journal.state).toBe('committed'); + expect(result.activatedConfig).toMatchObject({ + revision: 2, + lastOperationId: OPERATION_ID, + transport: { + mode: targetMode, + remote: targetMode === 'git-ref' ? 'origin' : null, + epoch: 2, + }, + }); + expect(result.established.activeClaims).toHaveLength(1); + expect(result.established.activeClaims[0]).toMatchObject({ + claimId: TARGET_CLAIM_ID, + predecessorClaimId: CLAIM_ID, + coordinationDomainId: fixture.target.coordinationDomainId, + authority: { mode: targetMode }, + }); + expect(fixture.events).toEqual([ + 'freeze', + 'stage', + 'establish', + 'config-cas', + 'tombstone', + ]); + expect(fixture.source.tombstone).toMatchObject({ + operationId: OPERATION_ID, + targetTransportEpoch: 2, + activatedConfigRevision: 2, + }); + }, + ); + + it('can discard a staged authority and unfreeze before config activation', async () => { + const fixture = await migrationFixture('local', 'git-ref'); + await stageTransportMigration(fixture.input); + + const recovered = await recoverTransportMigration({ + ...fixture.recovery, + mode: 'abort', + }); + + expect(recovered.state).toBe('aborted'); + expect(fixture.source.frozen).toBe(false); + expect(fixture.target.staged).toBeNull(); + expect(fixture.events).toEqual(['freeze', 'stage', 'discard', 'unfreeze']); + expect(fixture.config.current.transport.mode).toBe('local'); + }); + + it('repairs forward when config became visible before the writer crashed', async () => { + const fixture = await migrationFixture('local', 'git-ref'); + fixture.config.throwAfterWrite = true; + + await expect(executeTransportMigration(fixture.input)).rejects.toThrow( + 'simulated crash after config CAS', + ); + expect(fixture.config.current.transport).toMatchObject({ + mode: 'git-ref', + epoch: 2, + }); + await expect( + readOperationJournal(fixture.store, OPERATION_ID), + ).resolves.toMatchObject({ state: 'repair_required' }); + await expect( + recoverTransportMigration({ ...fixture.recovery, mode: 'abort' }), + ).rejects.toThrow('MANCODE_OPERATION_ABORT_UNSAFE'); + + fixture.config.throwAfterWrite = false; + const recovered = await recoverTransportMigration(fixture.recovery); + expect(recovered).toMatchObject({ + state: 'repaired', + journal: { state: 'committed' }, + activatedConfig: { transport: { mode: 'git-ref', epoch: 2 } }, + }); + expect( + fixture.events.filter((event) => event === 'config-cas'), + ).toHaveLength(1); + expect(fixture.events.at(-1)).toBe('tombstone'); + }); + + it('recovers every declared transport migration crash point', async () => { + for (const crashFixture of OPERATION_CRASH_FIXTURES.transport_migrate) { + const fixture = await migrationFixture('local', 'git-ref'); + + await expect( + withOperationCrashInjectionForTesting(crashFixture, () => + executeTransportMigration(fixture.input), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + if (crashFixture.crashAfter === 'commit') { + await expect( + recoverTransportMigration(fixture.recovery), + ).resolves.toMatchObject({ + state: 'already_committed', + journal: { state: 'committed' }, + }); + } else if (crashFixture.expectedRecovery === 'safe_abort') { + await expect( + recoverTransportMigration({ ...fixture.recovery, mode: 'abort' }), + ).resolves.toMatchObject({ + state: 'aborted', + journal: { state: 'aborted' }, + }); + expect(fixture.source.frozen).toBe(false); + expect(fixture.config.current.transport.mode).toBe('local'); + } else { + await expect( + recoverTransportMigration(fixture.recovery), + ).resolves.toMatchObject({ + state: 'repaired', + journal: { state: 'committed' }, + activatedConfig: { transport: { mode: 'git-ref', epoch: 2 } }, + }); + } + } + }); + + it('rejects pending source operations before freezing or staging', async () => { + const fixture = await migrationFixture('local', 'git-ref'); + fixture.source.snapshot.pendingOperationIds = [ + '01JZ4B6W5Z0A1B2C3D4E5F6G7S' as Ulid, + ]; + + await expect(previewTransportMigration(fixture.input)).rejects.toThrow( + 'MANCODE_OPERATION_REPAIR_REQUIRED', + ); + expect(fixture.events).toEqual([]); + }); + + it('applies authorization and confirmation gates during dry-run', async () => { + const fixture = await migrationFixture('local', 'git-ref'); + fixture.input.explicitConfirmation = false; + + await expect(previewTransportMigration(fixture.input)).rejects.toThrow( + 'MANCODE_EXPLICIT_CONFIRMATION_REQUIRED', + ); + expect(fixture.events).toEqual([]); + await expect( + readOperationJournal(fixture.store, OPERATION_ID), + ).resolves.toBeNull(); + }); +}); + +interface Fixture { + input: TransportMigrationStartInput; + recovery: Omit[0], 'mode'>; + store: EntityHomeStore; + source: MemorySource; + target: MemoryTarget; + config: MemoryConfig; + events: string[]; +} + +async function migrationFixture( + sourceMode: CoordinationTransport, + targetMode: CoordinationTransport, +): Promise { + const root = await mkdtemp( + path.join(tmpdir(), 'mancode-transport-migration-'), + ); + roots.push(root); + const events: string[] = []; + await initializeGitFixture(root); + await initializeV3Project({ + projectRoot: root, + operationId: INIT_OPERATION_ID, + workspaceId: WORKSPACE_ID, + schemaEpoch: SCHEMA_EPOCH, + now: NOW, + }); + const actor = await createLocalActor(root, { + actorId: ACTOR_ID, + displayName: 'Migration Owner', + now: NOW, + }); + const actorProfile = await publishSharedActorProfile( + root, + createSharedActorProfile(actor, NOW), + ); + await createSession(root, { + actorId: ACTOR_ID, + sessionId: SESSION_ID, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + const workflow = await createV3Workflow({ + projectRoot: root, + task: 'Migrate a stable shared coordination authority.', + workflowMode: 'manteam', + sessionId: SESSION_ID, + client: 'vitest', + taskId: TASK_ID, + operationId: WORKFLOW_OPERATION_ID, + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/team/**'] }, + now: NOW, + }); + const runtime = await readProjectRuntimeContext(root); + const coordinationStore = resolveCoordinationEntityHomeStore( + runtime.entityHomeStoreContext, + ); + const task = await new V3ContextStore(root).readTaskSnapshot( + workflow.taskRef, + ); + const taskHeadFence = await readTaskHeadFence( + coordinationStore, + workflow.taskRef, + ); + if (taskHeadFence === null) + throw new Error('missing fixture task-head fence'); + const codeHead = taskHeadFence.codeRef.head; + const taskBundle = createGitRefTaskBundle({ + task, + codeRef: { branch: 'main', head: codeHead }, + now: NOW, + }); + const store: EntityHomeStore = { + kind: 'non_git_shared', + storeId: `non-git:${WORKSPACE_ID}`, + root: path.join(root, 'migration-operation-store'), + workspaceId: WORKSPACE_ID, + checkoutId: null, + repositoryBindingId: null, + }; + const sourceRemote = sourceMode === 'git-ref' ? 'origin' : null; + const targetRemote = targetMode === 'git-ref' ? 'origin' : null; + const sourceDomain = domain(sourceMode, 1); + const targetDomain = domain(targetMode, 2); + const config = new MemoryConfig( + { + schemaVersion: 1, + revision: 1, + workspaceId: WORKSPACE_ID, + transport: { mode: sourceMode, remote: sourceRemote, epoch: 1 }, + lastOperationId: null, + updatedAt: NOW.toISOString(), + }, + events, + ); + const source = new MemorySource( + sourceMode, + sourceRemote, + sourceDomain, + authoritySnapshot( + sourceMode, + sourceDomain, + taskBundle, + taskHeadFence, + actorProfile, + ), + events, + ); + const target = new MemoryTarget( + targetMode, + targetRemote, + targetDomain, + events, + ); + const input: TransportMigrationStartInput = { + operationStore: store, + config, + source, + target, + operationId: OPERATION_ID, + checkoutId: CHECKOUT_ID, + actorId: ACTOR_ID, + sessionId: SESSION_ID, + expectedConfigRevision: 1, + joined: true, + explicitConfirmation: true, + now: NOW, + }; + return { + input, + recovery: { + operationStore: store, + config, + source, + target, + operationId: OPERATION_ID, + actorId: ACTOR_ID, + sessionId: SESSION_ID, + }, + store, + source, + target, + config, + events, + }; +} + +class MemoryConfig implements TransportMigrationConfigAdapter { + throwAfterWrite = false; + + constructor( + public current: ProjectConfigV1, + private readonly events: string[], + ) {} + + async read(): Promise { + return structuredClone(this.current); + } + + async compareAndSwap(input: { + expectedRevision: number; + expectedTransportEpoch: number; + next: ProjectConfigV1; + }): Promise { + if ( + this.current.revision !== input.expectedRevision || + this.current.transport.epoch !== input.expectedTransportEpoch + ) { + throw new Error('MANCODE_EXPECTED_REVISION_CONFLICT'); + } + this.events.push('config-cas'); + this.current = structuredClone(input.next); + if (this.throwAfterWrite) + throw new Error('simulated crash after config CAS'); + return structuredClone(this.current); + } +} + +class MemorySource implements TransportMigrationSourceAdapter { + readonly authorityId: string; + frozen = false; + frozenBy: Ulid | null = null; + tombstone: TransportAuthorityTombstoneV1 | null = null; + + constructor( + readonly mode: CoordinationTransport, + readonly remote: string | null, + coordinationDomainId: string, + public snapshot: TransportMigrationAuthoritySnapshotV1, + private readonly events: string[], + ) { + this.authorityId = `${mode}-authority`; + this.snapshot.authorityId = this.authorityId; + this.snapshot.coordinationDomainId = coordinationDomainId; + } + + async freeze(input: { operationId: Ulid }): Promise { + if ( + this.tombstone !== null && + this.tombstone.operationId === input.operationId + ) { + return; + } + if (this.frozenBy !== null && this.frozenBy !== input.operationId) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_FREEZE_CONFLICT'); + } + if (!this.frozen) this.events.push('freeze'); + this.frozen = true; + this.frozenBy = input.operationId; + } + + async assertFrozen(input: { operationId: Ulid }): Promise { + if ( + this.tombstone?.operationId !== input.operationId && + (!this.frozen || this.frozenBy !== input.operationId) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_NOT_FROZEN'); + } + } + + async inspect(): Promise { + return structuredClone(this.snapshot); + } + + async unfreeze(input: { operationId: Ulid }): Promise { + if (this.frozenBy !== null && this.frozenBy !== input.operationId) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_FREEZE_CONFLICT'); + } + if (this.frozen) this.events.push('unfreeze'); + this.frozen = false; + this.frozenBy = null; + } + + async writeTombstone( + tombstone: TransportAuthorityTombstoneV1, + ): Promise { + if ( + this.tombstone !== null && + digestCanonicalJson(this.tombstone) !== digestCanonicalJson(tombstone) + ) { + throw new Error('MANCODE_TRANSPORT_MIGRATION_TOMBSTONE_CONFLICT'); + } + if (this.tombstone === null) this.events.push('tombstone'); + this.tombstone = structuredClone(tombstone); + this.frozen = true; + this.frozenBy = tombstone.operationId; + } +} + +class MemoryTarget implements TransportMigrationTargetAdapter { + readonly authorityId: string; + staged: StagedTransportAuthorityV1 | null = null; + established: EstablishedTransportAuthorityV1 | null = null; + + constructor( + readonly mode: CoordinationTransport, + readonly remote: string | null, + readonly coordinationDomainId: string, + private readonly events: string[], + ) { + this.authorityId = `${mode}-target-authority`; + } + + async stage(manifest: TransportMigrationManifestV1): Promise { + const staged: StagedTransportAuthorityV1 = { + schemaVersion: 1, + operationId: manifest.operationId, + manifest: structuredClone(manifest), + manifestDigest: digestCanonicalJson(manifest), + transportEpoch: manifest.target.transportEpoch, + coordinationDomainId: manifest.target.coordinationDomainId, + stagedAt: manifest.createdAt, + }; + if (this.staged === null) this.events.push('stage'); + this.staged = staged; + return structuredClone(staged); + } + + async readStaged(): Promise { + return structuredClone(this.staged); + } + + async establish(manifest: TransportMigrationManifestV1): Promise { + if (this.established === null) this.events.push('establish'); + const activeClaims = manifest.sourceClaims + .filter((claim) => claim.state === 'active') + .map((claim) => reissuedClaim(claim, manifest)); + this.established = { + schemaVersion: 1, + operationId: manifest.operationId, + manifestDigest: digestCanonicalJson(manifest), + transportEpoch: manifest.target.transportEpoch, + coordinationDomainId: manifest.target.coordinationDomainId, + authorityRevision: 1, + activeClaims, + receipt: `receipt:${manifest.operationId}`, + establishedAt: manifest.createdAt, + }; + return structuredClone(this.established); + } + + async readEstablished(): Promise { + return structuredClone(this.established); + } + + async discard(): Promise { + this.events.push('discard'); + this.staged = null; + this.established = null; + } +} + +function authoritySnapshot( + mode: CoordinationTransport, + coordinationDomainId: string, + taskBundle: GitRefTaskBundleV1, + taskHeadFence: TransportMigrationAuthoritySnapshotV1['tasks'][number]['taskHeadFence'], + actorProfile: TransportMigrationAuthoritySnapshotV1['actorProfiles'][number], +): TransportMigrationAuthoritySnapshotV1 { + const aggregateDigest = taskBundle.aggregateDigest; + return { + schemaVersion: 1, + workspaceId: WORKSPACE_ID, + authorityId: `${mode}-authority`, + transportMode: mode, + transportEpoch: 1, + coordinationDomainId, + pendingOperationIds: [], + actorProfiles: [actorProfile], + tasks: [ + { + taskRef: taskBundle.taskRef, + transitionState: 'stable', + taskRevision: taskBundle.taskRevision, + ownerActorId: ACTOR_ID, + ownershipEpoch: taskBundle.ownershipEpoch, + aggregateDigest, + taskHeadFence: { + ...taskHeadFence, + aggregateDigest, + remoteRevision: mode === 'git-ref' ? 1 : null, + }, + }, + ], + taskBundles: [taskBundle], + claims: [claim(mode, coordinationDomainId, taskBundle)], + handoffs: [], + }; +} + +function claim( + mode: CoordinationTransport, + coordinationDomainId: string, + taskBundle: GitRefTaskBundleV1, +): ClaimV1 { + const scope = { paths: ['src/team/**'], modules: [], apis: [], schemas: [] }; + return { + schemaVersion: 1, + claimId: CLAIM_ID, + workspaceId: WORKSPACE_ID, + coordinationDomainId, + authority: { + mode, + remoteRevision: mode === 'git-ref' ? 'remote:1' : null, + }, + taskRef: { namespace: 'shared', taskId: TASK_ID }, + taskRevisionAtAcquire: taskBundle.taskRevision, + lastValidatedTaskRevision: taskBundle.taskRevision, + implementationScopeDigest: `sha256:${'b'.repeat(64)}`, + ownershipEpochAtAcquire: taskBundle.ownershipEpoch, + ownerActorId: ACTOR_ID, + state: 'active', + revision: 1, + scope, + scopeDigest: digestCanonicalJson(scope), + codeRefAtAcquire: { branch: 'main', head: taskBundle.codeRef.head }, + lastValidatedCodeRef: { branch: 'main', head: taskBundle.codeRef.head }, + acquisitionEnforcement: 'enforced', + writeGuard: 'advisory', + expiresAt: '2026-07-19T01:00:00.000Z', + predecessorClaimId: null, + successorClaimId: null, + lastOperationId: null, + createdAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }; +} + +function reissuedClaim( + source: ClaimV1, + manifest: TransportMigrationManifestV1, +): ClaimV1 { + return { + ...structuredClone(source), + claimId: TARGET_CLAIM_ID, + coordinationDomainId: manifest.target.coordinationDomainId, + authority: { + mode: manifest.target.mode, + remoteRevision: manifest.target.mode === 'git-ref' ? 'remote:2' : null, + }, + revision: 1, + predecessorClaimId: source.claimId, + lastOperationId: manifest.operationId, + createdAt: manifest.createdAt, + updatedAt: manifest.createdAt, + }; +} + +function domain(mode: CoordinationTransport, epoch: number): string { + return mode === 'git-ref' + ? `git-ref:remotehash:${WORKSPACE_ID}:${epoch}` + : `local:binding:${WORKSPACE_ID}`; +} + +async function initializeGitFixture(root: string): Promise { + await execFile('git', ['init', '-b', 'main'], { cwd: root }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: root, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: root }); + await writeFile(path.join(root, 'README.md'), '# migration fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: root }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: root }); +} diff --git a/tests/v3-adapter-contracts.test.ts b/tests/v3-adapter-contracts.test.ts new file mode 100644 index 0000000..1b56d14 --- /dev/null +++ b/tests/v3-adapter-contracts.test.ts @@ -0,0 +1,228 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { init } from '../src/commands/init.js'; +import { install } from '../src/commands/install.js'; +import { listPlatforms } from '../src/commands/list-platforms.js'; +import { refreshProject } from '../src/commands/refresh-project.js'; +import { type V3StatusResult, status } from '../src/commands/status.js'; +import { + EXIT_V3_AUTHORITY_PROTECTED, + uninstall, +} from '../src/commands/uninstall.js'; + +describe('V3 adapter bootstrap integration', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-adapter-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('uses V3 status and bootstrap-only adapters without creating legacy authority', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await init(root, { v3: true, platform: 'codex' })).toBe(0); + await expect( + readFile(path.join(root, '.mancode', 'state.json'), 'utf8'), + ).rejects.toThrow(); + const agents = await readFile(path.join(root, 'AGENTS.md'), 'utf8'); + expect(agents).toContain('# mancode V3 bootstrap'); + expect(agents).toContain('mancode context show --purpose orient'); + expect(agents).not.toContain('.mancode/state.json'); + expect(agents).not.toContain('currentMode'); + + logs.mockClear(); + expect(await status(root, { json: true })).toBe(0); + const result = JSON.parse( + String(logs.mock.calls.at(-1)?.[0]), + ) as V3StatusResult; + expect(result).toMatchObject({ + authority: 'v3', + runtime: { binding: 'ready' }, + adapters: { + codex: { + installed: true, + ready: true, + capabilities: { sessionIdentity: 'explicit-required' }, + }, + cursor: { installed: false }, + }, + sessionEvidence: { + ready: false, + missingPlatforms: expect.arrayContaining(['codex']), + }, + }); + expect(result.activation.managedAdapters.codex).toBe('3'); + + expect(await install(root, 'cursor')).toBe(0); + const cursorRule = await readFile( + path.join(root, '.cursor', 'rules', 'mancode-v3.mdc'), + 'utf8', + ); + expect(cursorRule).toContain('# mancode V3 bootstrap'); + await expect( + readFile(path.join(root, '.mancode', 'config.json'), 'utf8'), + ).rejects.toThrow(); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + + it('preserves user instructions outside the V3 managed block', async () => { + await init(root, { v3: true }); + await writeFile(path.join(root, 'AGENTS.md'), '# User instructions\n'); + + expect(await install(root, 'codex')).toBe(0); + expect(await install(root, 'codex')).toBe(0); + const agents = await readFile(path.join(root, 'AGENTS.md'), 'utf8'); + expect(agents).toContain('# User instructions'); + expect(agents.match(/mancode:v3:codex:start/g)).toHaveLength(1); + }); + + it('stages a dual-read adapter candidate without changing the live target', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await init(root, { v3: true })).toBe(0); + const schemaPath = path.join(root, '.mancode', 'schema.json'); + const manifest = JSON.parse(await readFile(schemaPath, 'utf8')); + await writeFile( + schemaPath, + `${JSON.stringify( + { + ...manifest, + activationState: 'dual_read', + activatedAt: null, + legacyBaseline: { + stateDigest: `sha256:${'a'.repeat(64)}`, + workflowIndexDigest: `sha256:${'b'.repeat(64)}`, + }, + }, + null, + 2, + )}\n`, + ); + const liveAgents = '# User instructions\n'; + await writeFile(path.join(root, 'AGENTS.md'), liveAgents); + + expect(await install(root, 'codex', { shadow: true })).toBe(0); + await expect( + readFile(path.join(root, 'AGENTS.md'), 'utf8'), + ).resolves.toBe(liveAgents); + await expect( + readFile( + path.join( + root, + '.mancode', + 'staging', + 'adapters', + 'v3', + 'codex', + 'AGENTS.md', + ), + 'utf8', + ), + ).resolves.toContain('# mancode V3 bootstrap'); + expect(logs.mock.calls.flat().join(' ')).toContain( + 'staged for shadow comparison', + ); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + + it('persists detected project facts and refreshes them without legacy state', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await mkdir(path.join(root, 'src')); + await writeFile( + path.join(root, 'package.json'), + JSON.stringify({ dependencies: { react: '^19.0.0' } }), + ); + expect(await init(root, { v3: true })).toBe(0); + + const initialFacts = JSON.parse( + await readFile( + path.join(root, '.mancode', 'shared', 'context', 'project.json'), + 'utf8', + ), + ); + expect(initialFacts).toMatchObject({ + schemaVersion: 1, + revision: 1, + trust: 'detected', + profile: { + projectKind: 'web', + languages: ['JavaScript/TypeScript'], + frameworks: ['React'], + sourceRoots: ['src'], + }, + }); + + await writeFile( + path.join(root, 'package.json'), + JSON.stringify({ + dependencies: { react: '^19.0.0', tailwindcss: '^4.0.0' }, + }), + ); + expect(await refreshProject(root)).toBe(0); + const refreshedFacts = JSON.parse( + await readFile( + path.join(root, '.mancode', 'shared', 'context', 'project.json'), + 'utf8', + ), + ); + expect(refreshedFacts).toMatchObject({ + revision: 2, + profile: { frameworks: ['React', 'Tailwind CSS'] }, + uiLibrary: 'Tailwind CSS', + }); + await expect( + readFile(path.join(root, '.mancode', 'state.json'), 'utf8'), + ).rejects.toThrow(); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + + it('lists and removes only V3 bootstrap files without treating V3 authority as legacy state', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await init(root, { v3: true, platform: 'codex' })).toBe(0); + expect(await listPlatforms(root)).toBe(0); + expect(logs.mock.calls.flat().join(' ')).toContain( + 'Available platforms (V3 bootstrap)', + ); + expect(logs.mock.calls.flat().join(' ')).toContain('codex'); + + expect(await uninstall(root, 'codex', { force: true })).toBe(0); + await expect( + readFile(path.join(root, 'AGENTS.md'), 'utf8'), + ).rejects.toThrow(); + await expect( + readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), + ).resolves.toContain('v3_active'); + expect(await uninstall(root, undefined, { all: true })).toBe( + EXIT_V3_AUTHORITY_PROTECTED, + ); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); +}); diff --git a/tests/v3-checkpoint-command-contracts.test.ts b/tests/v3-checkpoint-command-contracts.test.ts new file mode 100644 index 0000000..cdc5ff4 --- /dev/null +++ b/tests/v3-checkpoint-command-contracts.test.ts @@ -0,0 +1,119 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { teamCheckpoint } from '../src/commands/team.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-17T12:00:00.000Z'); + +describe('V3 team checkpoint command', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-checkpoint-command-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await execFile('git', ['init'], { cwd: root }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: root, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: root }); + await writeFile(path.join(root, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: root }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: root }); + await initializeV3Project({ + projectRoot: root, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('routes a shared checkpoint through the session, revision, and journal gates', async () => { + const actorId = id(4); + const sessionId = id(5); + await createLocalActor(root, { + actorId, + displayName: 'Checkpoint User', + now: NOW, + }); + const actor = await readLocalActor(root); + if (actor === null) throw new Error('missing local actor'); + await publishSharedActorProfile(root, createSharedActorProfile(actor, NOW)); + await createSession(root, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Create a checkpoint through the V3 command.', + workflowMode: 'manteam', + sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + taskId: id(6), + operationId: id(7), + now: NOW, + }); + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect( + await teamCheckpoint(root, { + task: `shared:${created.taskRef.taskId}`, + expectedTaskRevision: '1', + kind: 'diagnostic_started', + summary: 'Captured shared state before diagnosis.', + session: sessionId, + client: 'vitest', + json: true, + }), + ).toBe(0); + const payload = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + checkpoint: { taskRef: { namespace: string }; operationId: string }; + metadata: { revision: number; latestCheckpointRef: { kind: string } }; + operation: { type: string; state: string }; + }; + expect(payload).toMatchObject({ + checkpoint: { + taskRef: { namespace: 'shared' }, + }, + metadata: { revision: 3, latestCheckpointRef: { kind: 'checkpoint' } }, + operation: { type: 'checkpoint_create', state: 'committed' }, + }); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); +}); + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-child-result-merge-contracts.test.ts b/tests/v3-child-result-merge-contracts.test.ts new file mode 100644 index 0000000..022caf9 --- /dev/null +++ b/tests/v3-child-result-merge-contracts.test.ts @@ -0,0 +1,244 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { mergeV3ChildResult } from '../src/context/child-result-merge.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { taskRootPath } from '../src/context/task-locator.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { parseWorkflowMetadata } from '../src/context/workflow-metadata.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { createLocalActor } from '../src/team/actor.js'; + +const NOW = new Date('2026-07-17T21:00:00.000Z'); + +describe('V3 child result merge', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-child-merge-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('records a completed child result as a parent checkpoint under both task revisions', async () => { + const fixture = await createParentAndCompletedChild(root, 'verified'); + + const merged = await mergeV3ChildResult({ + projectRoot: root, + parentTaskRef: fixture.parent.taskRef, + childTaskRef: fixture.child.taskRef, + sessionId: fixture.sessionId, + expectedParentRevision: fixture.parentRevision, + expectedChildRevision: fixture.childRevision, + summary: 'The diagnostic run reproduced and verified the expected fix.', + nextAction: 'Continue the parent verification plan.', + checkpointId: id(20), + operationId: id(21), + now: NOW, + }); + + expect(merged).toMatchObject({ + metadata: { + revision: 4, + status: 'in_progress', + currentStep: 6, + transitionState: 'stable', + latestCheckpointRef: { artifactId: id(20) }, + lastOperationId: id(21), + }, + checkpoint: { + checkpointId: id(20), + operationId: id(21), + taskRef: fixture.parent.taskRef, + taskRevision: 3, + kind: 'verification_completed', + }, + operation: { type: 'child_result_merge', state: 'committed' }, + }); + expect(merged.checkpoint.summary).toContain( + `local:${fixture.child.taskRef.taskId}`, + ); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + fixture.parent.taskRef, + ); + await expect(readOperationJournal(home, id(21))).resolves.toMatchObject({ + expectedRevisions: { + [`task:local:${fixture.parent.taskRef.taskId}`]: 2, + [`task:local:${fixture.child.taskRef.taskId}`]: 2, + [`checkpoint:${id(20)}`]: 0, + }, + entityLocks: expect.arrayContaining([ + `task:local:${fixture.child.taskRef.taskId}`, + `checkpoint:${id(20)}`, + ]), + }); + }); + + it('blocks a manual-required child on the parent and rejects stale child snapshots', async () => { + const manual = await createParentAndCompletedChild( + root, + 'manual_test_required', + ); + const blocked = await mergeV3ChildResult({ + projectRoot: root, + parentTaskRef: manual.parent.taskRef, + childTaskRef: manual.child.taskRef, + sessionId: manual.sessionId, + expectedParentRevision: manual.parentRevision, + expectedChildRevision: manual.childRevision, + summary: 'An external device is required for the final diagnostic.', + nextAction: 'Run the device validation.', + checkpointId: id(30), + operationId: id(31), + now: NOW, + }); + expect(blocked).toMatchObject({ + metadata: { + status: 'blocked', + blockingReason: expect.stringContaining('requires manual testing'), + }, + checkpoint: { kind: 'blocked' }, + }); + + const staleRoot = path.join(root, 'stale'); + await mkdir(staleRoot, { recursive: true }); + const stale = await createParentAndCompletedChild(staleRoot, 'fixed', 40); + await writeMetadata(staleRoot, stale.parent.taskRef, { + ...stale.parent.metadata, + revision: 3, + currentStep: 6, + updatedAt: new Date(NOW.getTime() + 1_000).toISOString(), + }); + await expect( + mergeV3ChildResult({ + projectRoot: staleRoot, + parentTaskRef: stale.parent.taskRef, + childTaskRef: stale.child.taskRef, + sessionId: stale.sessionId, + expectedParentRevision: 3, + expectedChildRevision: stale.childRevision, + summary: 'This stale result must not be applied.', + nextAction: 'Recreate the diagnostic child.', + checkpointId: id(50), + operationId: id(51), + now: NOW, + }), + ).rejects.toThrow('MANCODE_PARENT_STALE'); + }); +}); + +async function createParentAndCompletedChild( + projectRoot: string, + outcome: 'fixed' | 'verified' | 'manual_test_required', + offset = 10, +): Promise<{ + sessionId: Ulid; + parent: Awaited>; + child: Awaited>; + parentRevision: number; + childRevision: number; +}> { + const sessionId = await bootstrap(projectRoot, offset); + const parent = await createV3Workflow({ + projectRoot, + task: `Investigate diagnostic result ${offset}.`, + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(offset), + operationId: id(offset + 1), + implementationScope: { include: ['src/**'], modules: ['core'] }, + now: NOW, + }); + const parentAtVerification = parseWorkflowMetadata({ + ...parent.metadata, + revision: 2, + currentStep: 6, + updatedAt: NOW.toISOString(), + }); + await writeMetadata(projectRoot, parent.taskRef, parentAtVerification); + const child = await createV3Workflow({ + projectRoot, + task: `Diagnose result ${offset}.`, + workflowMode: 'manba', + sessionId, + client: 'vitest', + parentTaskRef: parent.taskRef, + taskId: id(offset + 2), + operationId: id(offset + 3), + now: NOW, + }); + const completedChild = parseWorkflowMetadata({ + ...child.metadata, + status: 'completed', + currentStep: 5, + outcome, + revision: 2, + updatedAt: NOW.toISOString(), + }); + await writeMetadata(projectRoot, child.taskRef, completedChild); + return { + sessionId, + parent, + child, + parentRevision: parentAtVerification.revision, + childRevision: completedChild.revision, + }; +} + +async function bootstrap(projectRoot: string, offset: number): Promise { + await initializeV3Project({ + projectRoot, + operationId: id(offset + 60), + workspaceId: id(offset + 61), + schemaEpoch: id(offset + 62), + now: NOW, + }); + const actorId = id(offset + 63); + const sessionId = id(offset + 64); + await createLocalActor(projectRoot, { + actorId, + displayName: `Child Merge Owner ${offset}`, + now: NOW, + }); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + return sessionId; +} + +async function writeMetadata( + projectRoot: string, + taskRef: { namespace: 'local' | 'shared'; taskId: Ulid }, + metadata: unknown, +): Promise { + await writeFile( + path.join(taskRootPath(projectRoot, taskRef), 'metadata.json'), + `${JSON.stringify(metadata, null, 2)}\n`, + ); +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-claim-acquisition-contracts.test.ts b/tests/v3-claim-acquisition-contracts.test.ts new file mode 100644 index 0000000..b7d0243 --- /dev/null +++ b/tests/v3-claim-acquisition-contracts.test.ts @@ -0,0 +1,220 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { teamClaim } from '../src/commands/team.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { readLocalDiagnostics } from '../src/runtime/diagnostics.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; +import { acquireV3Claim } from '../src/team/claim-acquisition.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-17T13:00:00.000Z'); + +describe('V3 claim acquisition', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-claim-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await execFile('git', ['init'], { cwd: root }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: root, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: root }); + await writeFile(path.join(root, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: root }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: root }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('creates a shared active claim from the locked task snapshot and rejects overlap', async () => { + const { sessionId } = await bootstrap(root); + const workflow = await createV3Workflow({ + projectRoot: root, + task: 'Acquire a narrow shared implementation claim.', + workflowMode: 'manteam', + sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { + include: ['src/**'], + modules: ['auth'], + }, + taskId: id(10), + operationId: id(11), + now: NOW, + }); + + const result = await acquireV3Claim({ + projectRoot: root, + taskRef: workflow.taskRef, + sessionId, + expectedTaskRevision: 1, + scope: { + paths: ['src/auth/**'], + modules: ['auth'], + apis: [], + schemas: [], + }, + claimId: id(12), + operationId: id(13), + now: NOW, + }); + + expect(result.claim).toMatchObject({ + claimId: id(12), + taskRef: workflow.taskRef, + state: 'active', + revision: 1, + taskRevisionAtAcquire: 1, + lastOperationId: id(13), + }); + expect(result.operation).toMatchObject({ + type: 'claim_create', + state: 'committed', + }); + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + workflow.taskRef, + ); + await expect(readOperationJournal(home, id(13))).resolves.toMatchObject({ + expectedRevisions: { + [`task:shared:${workflow.taskRef.taskId}`]: 1, + [`claim:${id(12)}`]: 0, + }, + }); + const coordination = await new V3ContextStore( + root, + ).readCoordinationSnapshot(workflow.taskRef, home); + expect(coordination.claims).toEqual([result.claim]); + + await expect( + acquireV3Claim({ + projectRoot: root, + taskRef: workflow.taskRef, + sessionId, + expectedTaskRevision: 1, + scope: { + paths: ['src/auth/**'], + modules: ['auth'], + apis: [], + schemas: [], + }, + claimId: id(14), + operationId: id(15), + now: NOW, + }), + ).rejects.toThrow('MANCODE_SCOPE_CONFLICT'); + await expect(readLocalDiagnostics(root)).resolves.toMatchObject({ + claimConflictCounts: { blocker: 1 }, + }); + }); + + it('routes repeated scope fields through the team claim command', async () => { + const { sessionId } = await bootstrap(root); + const workflow = await createV3Workflow({ + projectRoot: root, + task: 'Acquire a command-level shared claim.', + workflowMode: 'manteam', + sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { + include: ['src/**', 'tests/**'], + modules: ['auth'], + }, + taskId: id(20), + operationId: id(21), + now: NOW, + }); + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect( + await teamClaim(root, { + task: `shared:${workflow.taskRef.taskId}`, + expectedTaskRevision: '1', + paths: ['tests/auth/**', 'src/auth/**'], + modules: ['auth'], + session: sessionId, + client: 'vitest', + json: true, + }), + ).toBe(0); + const payload = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + claim: { state: string; scope: { paths: string[] } }; + operation: { type: string; state: string }; + }; + expect(payload).toMatchObject({ + claim: { + state: 'active', + scope: { paths: ['src/auth/**', 'tests/auth/**'] }, + }, + operation: { type: 'claim_create', state: 'committed' }, + }); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); +}); + +async function bootstrap(projectRoot: string): Promise<{ sessionId: Ulid }> { + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const actorId = id(4); + const sessionId = id(5); + await createLocalActor(projectRoot, { + actorId, + displayName: 'Claim User', + now: NOW, + }); + const actor = await readLocalActor(projectRoot); + if (actor === null) throw new Error('missing local actor'); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(actor, NOW), + ); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + return { sessionId }; +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-claim-lifecycle-contracts.test.ts b/tests/v3-claim-lifecycle-contracts.test.ts new file mode 100644 index 0000000..0899f65 --- /dev/null +++ b/tests/v3-claim-lifecycle-contracts.test.ts @@ -0,0 +1,387 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { teamClaimRenew } from '../src/commands/team.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { createV3Checkpoint } from '../src/context/checkpoint-create.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; +import { acquireV3Claim } from '../src/team/claim-acquisition.js'; +import { + reclaimV3Claim, + releaseV3Claim, + renewV3Claim, + revalidateV3Claim, + transferV3Claim, +} from '../src/team/claim-operation.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-17T16:00:00.000Z'); + +describe('V3 local claim lifecycle operations', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-claim-lifecycle-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await execFile('git', ['init'], { cwd: root }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: root, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: root }); + await writeFile(path.join(root, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: root }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: root }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('renews and then releases an owned fresh claim under revision CAS', async () => { + const actors = await bootstrap(root); + const { workflow, claim } = await workflowWithOwnerClaim(root, actors, 10); + + const renewed = await renewV3Claim({ + projectRoot: root, + claimId: claim.claim.claimId, + sessionId: actors.ownerSessionId, + expectedClaimRevision: 1, + ttlMs: 2 * 24 * 60 * 60 * 1000, + operationId: id(14), + now: NOW, + }); + expect(renewed).toMatchObject({ + claim: { + claimId: claim.claim.claimId, + state: 'active', + revision: 2, + expiresAt: '2026-07-19T16:00:00.000Z', + }, + operation: { type: 'claim_renew_release', state: 'committed' }, + }); + + const released = await releaseV3Claim({ + projectRoot: root, + claimId: claim.claim.claimId, + sessionId: actors.ownerSessionId, + expectedClaimRevision: 2, + operationId: id(15), + now: NOW, + }); + expect(released).toMatchObject({ + claim: { state: 'released', revision: 3, lastOperationId: id(15) }, + operation: { type: 'claim_renew_release', state: 'committed' }, + }); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + workflow.taskRef, + ); + await expect(readOperationJournal(home, id(15))).resolves.toMatchObject({ + expectedRevisions: { + [`task:shared:${workflow.taskRef.taskId}`]: 1, + [`claim:${claim.claim.claimId}`]: 2, + }, + }); + }); + + it('transfers through a pending successor instead of rewriting the old owner', async () => { + const actors = await bootstrap(root); + const { workflow, claim } = await workflowWithOwnerClaim(root, actors, 20); + + const transferred = await transferV3Claim({ + projectRoot: root, + claimId: claim.claim.claimId, + sessionId: actors.ownerSessionId, + expectedClaimRevision: 1, + toActorId: actors.participantActorId, + successorClaimId: id(24), + operationId: id(25), + now: NOW, + }); + expect(transferred).toMatchObject({ + predecessorClaim: { + claimId: claim.claim.claimId, + ownerActorId: actors.ownerActorId, + state: 'transferred', + revision: 2, + successorClaimId: id(24), + }, + successorClaim: { + claimId: id(24), + state: 'active', + revision: 2, + ownerActorId: actors.participantActorId, + predecessorClaimId: claim.claim.claimId, + }, + operation: { type: 'claim_transfer', state: 'committed' }, + }); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + workflow.taskRef, + ); + await expect(readOperationJournal(home, id(25))).resolves.toMatchObject({ + expectedRevisions: { + [`task:shared:${workflow.taskRef.taskId}`]: 1, + [`claim:${claim.claim.claimId}`]: 1, + [`claim:${id(24)}`]: 0, + }, + entityLocks: expect.arrayContaining([ + `claim:${claim.claim.claimId}`, + `claim:${id(24)}`, + ]), + }); + }); + + it('revalidates a claim against the final task revision without mutating its acquisition snapshot', async () => { + const actors = await bootstrap(root); + const { workflow, claim } = await workflowWithOwnerClaim(root, actors, 30); + await createV3Checkpoint({ + projectRoot: root, + taskRef: workflow.taskRef, + sessionId: actors.ownerSessionId, + expectedTaskRevision: 1, + kind: 'diagnostic_started', + summary: 'A normal task mutation made existing claims need validation.', + checkpointId: id(34), + operationId: id(35), + now: NOW, + }); + + const revalidated = await revalidateV3Claim({ + projectRoot: root, + claimId: claim.claim.claimId, + sessionId: actors.ownerSessionId, + expectedClaimRevision: 1, + checkpointId: id(36), + operationId: id(37), + now: NOW, + }); + expect(revalidated).toMatchObject({ + metadata: { revision: 5, transitionState: 'stable' }, + claim: { + claimId: claim.claim.claimId, + state: 'active', + revision: 2, + taskRevisionAtAcquire: 1, + lastValidatedTaskRevision: 5, + lastOperationId: id(37), + }, + checkpoint: null, + taskHeadFence: { fenceRevision: 3, taskRevision: 5 }, + operation: { type: 'claim_revalidation', state: 'committed' }, + }); + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + workflow.taskRef, + ); + await expect(readOperationJournal(home, id(37))).resolves.toMatchObject({ + expectedRevisions: { + [`task:shared:${workflow.taskRef.taskId}`]: 3, + [`claim:${claim.claim.claimId}`]: 1, + [`task_head:${workflow.taskRef.taskId}`]: 2, + }, + entityLocks: expect.arrayContaining([ + `claim:${claim.claim.claimId}`, + `task_head:${workflow.taskRef.taskId}`, + ]), + }); + }); + + it('lets the task owner explicitly reclaim an expired local claim but never revive it', async () => { + const actors = await bootstrap(root); + const { claim } = await workflowWithOwnerClaim(root, actors, 40, { + ttlMs: 60_000, + }); + + const reclaimed = await reclaimV3Claim({ + projectRoot: root, + claimId: claim.claim.claimId, + sessionId: actors.ownerSessionId, + expectedClaimRevision: 1, + reason: 'The local lease has expired and must be explicitly replaced.', + operationId: id(45), + now: new Date(NOW.getTime() + 60_001), + }); + expect(reclaimed).toMatchObject({ + claim: { state: 'expired', revision: 2, lastOperationId: id(45) }, + operation: { type: 'claim_reclaim', state: 'committed' }, + }); + await expect( + renewV3Claim({ + projectRoot: root, + claimId: claim.claim.claimId, + sessionId: actors.ownerSessionId, + expectedClaimRevision: 2, + operationId: id(46), + now: new Date(NOW.getTime() + 60_001), + }), + ).rejects.toThrow('MANCODE_CLAIM_NOT_ACTIVE'); + }); + + it('routes a duration-based renew through the team command contract', async () => { + const actors = await bootstrap(root); + const { claim } = await workflowWithOwnerClaim(root, actors, 50); + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect( + await teamClaimRenew(root, { + claimId: claim.claim.claimId, + expectedRevision: '1', + ttl: '2d', + session: actors.ownerSessionId, + client: 'owner-client', + json: true, + }), + ).toBe(0); + const payload = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + claim: { revision: number; expiresAt: string }; + operation: { type: string; state: string }; + }; + expect(payload).toMatchObject({ + claim: { revision: 2 }, + operation: { type: 'claim_renew_release', state: 'committed' }, + }); + expect(Date.parse(payload.claim.expiresAt)).toBeGreaterThan( + Date.parse(claim.claim.expiresAt), + ); + + expect( + await teamClaimRenew(root, { + claimId: claim.claim.claimId, + json: true, + }), + ).toBe(2); + expect(String(logs.mock.calls.at(-1)?.[0])).toContain( + 'MANCODE_CLAIM_ARGUMENT_INVALID', + ); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); +}); + +async function workflowWithOwnerClaim( + projectRoot: string, + actors: Awaited>, + offset: number, + options: { ttlMs?: number } = {}, +) { + const workflow = await createV3Workflow({ + projectRoot, + task: `Claim lifecycle task ${offset}.`, + workflowMode: 'manteam', + sessionId: actors.ownerSessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/**'], modules: ['auth'] }, + participantActorIds: [actors.participantActorId], + taskId: id(offset), + operationId: id(offset + 1), + now: NOW, + }); + const claim = await acquireV3Claim({ + projectRoot, + taskRef: workflow.taskRef, + sessionId: actors.ownerSessionId, + expectedTaskRevision: 1, + scope: { + paths: ['src/auth/**'], + modules: ['auth'], + apis: [], + schemas: [], + }, + ttlMs: options.ttlMs, + claimId: id(offset + 2), + operationId: id(offset + 3), + now: NOW, + }); + return { workflow, claim }; +} + +async function bootstrap(projectRoot: string): Promise<{ + ownerActorId: Ulid; + ownerSessionId: Ulid; + participantActorId: Ulid; + participantSessionId: Ulid; +}> { + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const ownerActorId = id(4); + const ownerSessionId = id(5); + const participantActorId = id(6); + const participantSessionId = id(7); + await createLocalActor(projectRoot, { + actorId: ownerActorId, + displayName: 'Lifecycle Owner', + now: NOW, + }); + const owner = await readLocalActor(projectRoot); + if (owner === null) throw new Error('missing owner actor'); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(owner, NOW), + ); + await publishSharedActorProfile(projectRoot, { + schemaVersion: 1, + actorId: participantActorId, + displayName: 'Lifecycle Participant', + joinedAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }); + await createSession(projectRoot, { + actorId: ownerActorId, + sessionId: ownerSessionId, + client: 'owner-client', + identitySource: 'explicit', + now: NOW, + }); + await createSession(projectRoot, { + actorId: participantActorId, + sessionId: participantSessionId, + client: 'participant-client', + identitySource: 'explicit', + now: NOW, + }); + return { + ownerActorId, + ownerSessionId, + participantActorId, + participantSessionId, + }; +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-command-contracts.test.ts b/tests/v3-command-contracts.test.ts new file mode 100644 index 0000000..676ca44 --- /dev/null +++ b/tests/v3-command-contracts.test.ts @@ -0,0 +1,595 @@ +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + contextDoctor, + contextReconcileTaskHead, + contextResume, + contextSessionNew, + contextSessionSpike, + contextShow, + contextWorktreeRegister, +} from '../src/commands/context.js'; +import { operationRepair, operationShow } from '../src/commands/operation.js'; +import { + teamConflicts, + teamDecisionPublish, + teamIdentityCreate, + teamJoin, + teamStatus, +} from '../src/commands/team.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { + readV3CommandProject, + resolveV3CommandSession, +} from '../src/commands/v3-support.js'; +import { workflow } from '../src/commands/workflow.js'; +import { createUlid } from '../src/context/ids.js'; +import { + REQUIREMENT_DIMENSIONS, + type RequirementsLedgerV1, + requirementsLedgerDigest, +} from '../src/context/requirements-ledger.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { readSession } from '../src/runtime/session.js'; +import { readLocalActor } from '../src/team/actor.js'; + +const NOW = new Date('2026-07-17T11:00:00.000Z'); + +describe('V3 CLI command contracts', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-command-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await initializeV3Project({ + projectRoot: root, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('requires a local identity before bootstrap and exposes an explicit session handoff', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + expect( + await contextSessionNew(root, { client: 'fixture', json: true }), + ).toBe(3); + expect(logs.mock.calls.flat().join(' ')).toContain( + 'MANCODE_LOCAL_ACTOR_REQUIRED', + ); + + expect( + await teamIdentityCreate(root, { name: 'Fixture User', json: true }), + ).toBe(0); + const actor = await readLocalActor(root); + expect(actor?.displayName).toBe('Fixture User'); + + expect( + await contextSessionNew(root, { client: 'fixture', json: true }), + ).toBe(0); + const payload = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + environment: { MANCODE_SESSION_ID: string }; + }; + expect(payload.environment.MANCODE_SESSION_ID).toBe( + payload.session.sessionId, + ); + expect(await readSession(root, payload.session.sessionId)).toMatchObject({ + client: 'fixture', + status: 'active', + }); + } finally { + errors.mockRestore(); + logs.mockRestore(); + } + }); + + it('records host-session evidence without persisting host keys and only then resolves a host session', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.stubEnv('MANCODE_SPIKE_HOST_SESSION_KEY', 'codex-window-a-private-key'); + vi.stubEnv( + 'MANCODE_SPIKE_SECOND_WINDOW_HOST_SESSION_KEY', + 'codex-window-b-private-key', + ); + try { + expect( + await contextSessionSpike(root, { + platform: 'codex', + hostSessionSource: 'api', + json: true, + }), + ).toBe(0); + const evidence = String(logs.mock.calls.at(-1)?.[0]); + expect(evidence).toContain('host_verified'); + expect(evidence).not.toContain('private-key'); + const persisted = await readFile( + path.join( + root, + '.mancode', + 'local', + 'evidence', + 'platform-session', + 'codex.json', + ), + 'utf8', + ); + expect(persisted).not.toContain('private-key'); + + await teamIdentityCreate(root, { name: 'Fixture User', json: true }); + vi.stubEnv('MANCODE_HOST_SESSION_KEY', 'codex-window-a-private-key'); + const session = await resolveV3CommandSession( + await readV3CommandProject(root), + { client: 'codex' }, + ); + expect(session).toMatchObject({ + client: 'codex', + identitySource: 'host', + }); + expect(JSON.stringify(session)).not.toContain('private-key'); + } finally { + vi.unstubAllEnvs(); + errors.mockRestore(); + logs.mockRestore(); + } + }); + + it('keeps checkout runtime registration explicit and idempotent', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await contextWorktreeRegister(root, { json: true })).toBe(0); + const result = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + workspaceId: string; + checkoutId: string; + }; + expect(result.workspaceId).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + expect(result.checkoutId).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + + it('reports an empty local coordination view and rejects local conflict queries', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await teamConflicts(root, { json: true })).toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + claims: [], + handoffs: [], + capabilities: { transport: 'local' }, + }); + + expect( + await teamConflicts(root, { task: `local:${id(11)}`, json: true }), + ).toBe(3); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { code: 'MANCODE_CLAIM_REQUIRES_SHARED_TASK' }, + }); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + + it('requires explicit Git confirmation and a positive fence revision for reconciliation', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect( + await contextReconcileTaskHead(root, undefined, { json: true }), + ).toBe(2); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { code: 'MANCODE_TASK_REQUIRED' }, + }); + + expect( + await contextReconcileTaskHead(root, `shared:${id(10)}`, { + expectedFenceRevision: '0', + fromGit: true, + json: true, + }), + ).toBe(2); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { code: 'MANCODE_EXPECTED_REVISION_REQUIRED' }, + }); + + expect( + await contextReconcileTaskHead(root, `shared:${id(10)}`, { + expectedFenceRevision: '1', + json: true, + }), + ).toBe(2); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { code: 'MANCODE_GIT_SOURCE_CONFIRMATION_REQUIRED' }, + }); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + + it('exposes journal inspection, terminal repair, and doctor diagnostics', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await teamIdentityCreate(root, { name: 'Fixture User', json: true }); + await contextSessionNew(root, { client: 'fixture', json: true }); + const sessionId = ( + JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + } + ).session.sessionId; + await workflow( + root, + 'create', + ['man', 'Inspect V3 operation recovery.'], + { + session: sessionId, + client: 'fixture', + json: true, + }, + ); + const createResult = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + operation: { operationId: string; state: string }; + }; + const operationId = createResult.operation.operationId; + + expect(await operationShow(root, operationId, { json: true })).toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + journal: { operationId, state: 'committed' }, + payloadBound: true, + }); + expect( + await operationRepair(root, operationId, { + session: sessionId, + client: 'fixture', + json: true, + }), + ).toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + state: 'already_terminal', + }); + expect(await contextDoctor(root, { json: true })).toBe(0); + expect(JSON.parse(String(logs.mock.calls.at(-1)?.[0]))).toMatchObject({ + operations: [], + }); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + + it('routes workflow create, context resume/show, and join through V3 authority', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await teamIdentityCreate(root, { name: 'Fixture User', json: true }); + await contextSessionNew(root, { client: 'fixture', json: true }); + const sessionId = ( + JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + } + ).session.sessionId; + + expect( + await workflow(root, 'create', ['man', 'Create from the V3 command.'], { + session: sessionId, + client: 'fixture', + json: true, + }), + ).toBe(0); + const createPayload = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + taskRef: { namespace: string; taskId: string }; + }; + expect(createPayload.taskRef.namespace).toBe('local'); + const task = `${createPayload.taskRef.namespace}:${createPayload.taskRef.taskId}`; + + expect( + await contextResume(root, task, { + session: sessionId, + client: 'fixture', + json: true, + }), + ).toBe(0); + expect( + await contextShow(root, { + task, + session: sessionId, + client: 'fixture', + purpose: 'orient', + level: 'task', + json: true, + }), + ).toBe(0); + + expect( + await contextShow(root, { + task, + client: 'fixture', + purpose: 'orient', + level: 'task', + json: true, + }), + ).toBe(0); + const anonymousRead = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + mutatingAllowed: boolean; + writeBlockers: Array<{ code: string }>; + pack: { session: { sessionId: string | null; actorId: string | null } }; + }; + expect(anonymousRead.mutatingAllowed).toBe(false); + expect(anonymousRead.writeBlockers).toContainEqual({ + code: 'MANCODE_SESSION_REQUIRED', + operationIds: [], + }); + expect(anonymousRead.pack.session).toMatchObject({ + sessionId: null, + actorId: null, + }); + + expect( + await teamJoin(root, { + name: 'Fixture User', + session: sessionId, + client: 'fixture', + json: true, + }), + ).toBe(0); + const policyPath = path.join( + root, + '.mancode', + 'shared', + 'team', + 'policy.json', + ); + const policy = JSON.parse(await readFile(policyPath, 'utf8')) as Record< + string, + unknown + >; + await writeFile( + policyPath, + `${JSON.stringify( + { ...policy, revision: 2, shareConfirmedDecisions: true }, + null, + 2, + )}\n`, + ); + expect( + await teamDecisionPublish(root, { + title: 'Use the V3 resolver', + statement: 'Shared planning reads one stable Context Pack.', + session: sessionId, + client: 'fixture', + json: true, + }), + ).toBe(2); + expect(logs.mock.calls.flat().join(' ')).toContain( + 'MANCODE_EXPLICIT_CONFIRMATION_REQUIRED', + ); + expect( + await teamDecisionPublish(root, { + title: 'Use the V3 resolver', + statement: 'Shared planning reads one stable Context Pack.', + session: sessionId, + client: 'fixture', + confirm: true, + json: true, + }), + ).toBe(0); + const decisionPayload = JSON.parse( + String(logs.mock.calls.at(-1)?.[0]), + ) as { + decision: { decisionId: string; statement: string }; + event: { eventType: string }; + }; + expect(decisionPayload).toMatchObject({ + decision: { + statement: 'Shared planning reads one stable Context Pack.', + }, + event: { eventType: 'confirmed_decision_published' }, + }); + expect( + (await new V3ContextStore(root).readProjectSnapshot()) + .confirmedDecisions, + ).toMatchObject([{ decisionId: decisionPayload.decision.decisionId }]); + + expect( + await contextShow(root, { + task, + session: sessionId, + client: 'fixture', + purpose: 'plan', + level: 'task', + json: true, + }), + ).toBe(0); + const planPack = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + pack: { project: { confirmedDecisions: Array<{ title: string }> } }; + }; + expect(planPack.pack.project.confirmedDecisions).toMatchObject([ + { title: 'Use the V3 resolver' }, + ]); + expect(await teamStatus(root, { json: true })).toBe(0); + const sessionFiles = await readdir( + path.join(root, '.mancode', 'local', 'sessions'), + ); + expect( + sessionFiles.filter((name) => name.endsWith('.json')), + ).toHaveLength(1); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + + it('routes requirements finalization through the V3 session and revision gate', async () => { + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await teamIdentityCreate(root, { name: 'Fixture User', json: true }); + await contextSessionNew(root, { client: 'fixture', json: true }); + const sessionId = ( + JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + session: { sessionId: string }; + } + ).session.sessionId; + await workflow(root, 'create', ['man', 'Finalize V3 requirements.'], { + session: sessionId, + client: 'fixture', + json: true, + }); + const createPayload = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + taskRef: { namespace: 'local' | 'shared'; taskId: string }; + }; + const task = `${createPayload.taskRef.namespace}:${createPayload.taskRef.taskId}`; + const snapshot = await new V3ContextStore(root).readTaskSnapshot( + createPayload.taskRef, + ); + const requirementsPath = path.join(root, 'requirements.json'); + await writeFile( + requirementsPath, + `${JSON.stringify(finalizedRequirements(snapshot.requirements), null, 2)}\n`, + ); + + expect( + await workflow(root, 'requirements', [task, 'finalize'], { + session: sessionId, + client: 'fixture', + file: requirementsPath, + json: true, + }), + ).toBe(2); + expect(logs.mock.calls.flat().join(' ')).toContain( + 'MANCODE_EXPECTED_REVISION_REQUIRED', + ); + + expect( + await workflow(root, 'requirements', [task, 'finalize'], { + session: sessionId, + client: 'fixture', + expectedRevision: '1', + file: requirementsPath, + json: true, + }), + ).toBe(0); + const result = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + metadata: { + revision: number; + governance: { requirementsStatus: string }; + }; + operation: { type: string; state: string }; + }; + expect(result.metadata).toMatchObject({ + revision: 2, + governance: { requirementsStatus: 'ready' }, + }); + expect(result.operation).toMatchObject({ + type: 'requirements_finalize', + state: 'committed', + }); + + const planPath = path.join(root, 'plan.md'); + await writeFile(planPath, '# V3 plan\n\n1. Commit the operation.\n'); + expect( + await workflow(root, 'plan', [task, 'revise'], { + session: sessionId, + client: 'fixture', + expectedRevision: '2', + planDecision: 'governed_execution', + file: planPath, + json: true, + }), + ).toBe(0); + const planResult = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + metadata: { + revision: number; + currentStep: number; + governance: { planDecision: string; planVersion: number }; + }; + operation: { type: string; state: string }; + }; + expect(planResult.metadata).toMatchObject({ + revision: 3, + currentStep: 5, + governance: { planDecision: 'governed_execution', planVersion: 2 }, + }); + expect(planResult.operation).toMatchObject({ + type: 'plan_revision', + state: 'committed', + }); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); +}); + +function finalizedRequirements( + previous: RequirementsLedgerV1, +): RequirementsLedgerV1 { + const requirementId = id(50); + const draft: RequirementsLedgerV1 = { + ...previous, + revision: 99, + status: 'confirmed', + goal: 'Finalize the V3 command contract.', + functionalScope: { inScope: ['V3 command'], outOfScope: ['Legacy write'] }, + technicalDecisions: [], + defaults: [], + coverage: REQUIREMENT_DIMENSIONS.map((dimension, index) => ({ + coverageId: id(30 + index), + dimension, + status: dimension === 'technical_stack' ? 'not_applicable' : 'confirmed', + rationale: `Confirmed ${dimension}.`, + })), + requirements: [ + { + displayId: 'REQ-1', + legacyId: null, + requirementId, + statement: 'The command must use V3 authority.', + priority: 'must', + }, + ], + acceptanceCriteria: [ + { + displayId: 'AC-1', + legacyId: null, + criterionId: id(51), + requirementIds: [requirementId], + statement: 'The V3 journal commits the requirements mutation.', + required: true, + verificationRequirement: 'automated', + }, + ], + blockingUnknowns: [], + contentDigest: '', + lastOperationId: id(52), + updatedAt: NOW.toISOString(), + }; + return { + ...draft, + contentDigest: requirementsLedgerDigest(draft), + }; +} + +function id(offset: number): string { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-handoff-draft-contracts.test.ts b/tests/v3-handoff-draft-contracts.test.ts new file mode 100644 index 0000000..f29073c --- /dev/null +++ b/tests/v3-handoff-draft-contracts.test.ts @@ -0,0 +1,560 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { teamHandoffDraft } from '../src/commands/team.js'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { readHandoff } from '../src/runtime/handoff-store.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; +import { executeOperationRecovery } from '../src/runtime/operation-recovery-executor.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; +import { acquireV3Claim } from '../src/team/claim-acquisition.js'; +import { + acceptV3Handoff, + createV3HandoffDraft, + offerV3Handoff, + rejectV3Handoff, +} from '../src/team/handoff-operation.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-17T14:00:00.000Z'); + +describe('V3 local-coordination handoff draft and transition', () => { + let root: string; + let crashRoots: string[]; + + beforeEach(async () => { + crashRoots = []; + root = path.join( + tmpdir(), + `mancode-v3-handoff-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await execFile('git', ['init'], { cwd: root }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: root, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: root }); + await writeFile(path.join(root, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: root }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: root }); + }); + + afterEach(async () => { + await Promise.all( + [root, ...crashRoots].map((target) => + rm(target, { recursive: true, force: true }), + ), + ); + }); + + it('checkpoints, drafts, offers, and rejects a named shared handoff through journals', async () => { + const actors = await bootstrap(root); + const workflow = await createV3Workflow({ + projectRoot: root, + task: 'Transfer a narrow shared implementation lane.', + workflowMode: 'manteam', + sessionId: actors.ownerSessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/**'], modules: ['auth'] }, + participantActorIds: [actors.receiverActorId], + taskId: id(10), + operationId: id(11), + now: NOW, + }); + const claim = await acquireV3Claim({ + projectRoot: root, + taskRef: workflow.taskRef, + sessionId: actors.ownerSessionId, + expectedTaskRevision: 1, + scope: { + paths: ['src/auth/**'], + modules: ['auth'], + apis: [], + schemas: [], + }, + claimId: id(12), + operationId: id(13), + now: NOW, + }); + + const drafted = await createV3HandoffDraft({ + projectRoot: root, + taskRef: workflow.taskRef, + sessionId: actors.ownerSessionId, + expectedTaskRevision: 1, + toActorId: actors.receiverActorId, + handoffId: id(14), + checkpointId: id(15), + checkpointOperationId: id(16), + operationId: id(17), + now: NOW, + }); + expect(drafted).toMatchObject({ + checkpoint: { kind: 'handoff_offered', checkpointId: id(15) }, + checkpointOperation: { type: 'checkpoint_create', state: 'committed' }, + handoff: { + handoffId: id(14), + state: 'draft', + revision: 1, + taskRevision: 3, + ownershipEpochAtOffer: 1, + claimIds: [claim.claim.claimId], + checkpointRef: { artifactId: id(15) }, + }, + operation: { type: 'handoff_transition', state: 'committed' }, + }); + + const offered = await offerV3Handoff({ + projectRoot: root, + handoffId: drafted.handoff.handoffId, + sessionId: actors.ownerSessionId, + expectedHandoffRevision: 1, + operationId: id(18), + now: NOW, + }); + expect(offered).toMatchObject({ + handoff: { + state: 'offered', + revision: 2, + offeredAt: NOW.toISOString(), + lastOperationId: id(18), + }, + operation: { type: 'handoff_transition', state: 'committed' }, + }); + + const rejected = await rejectV3Handoff({ + projectRoot: root, + handoffId: drafted.handoff.handoffId, + sessionId: actors.receiverSessionId, + expectedHandoffRevision: 2, + reason: 'The receiving actor needs a narrower implementation scope.', + operationId: id(19), + now: NOW, + }); + expect(rejected.handoff).toMatchObject({ + state: 'rejected', + revision: 3, + resolution: { + state: 'rejected', + actorId: actors.receiverActorId, + }, + }); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + workflow.taskRef, + ); + expect(await readHandoff(home, drafted.handoff.handoffId)).toEqual( + rejected.handoff, + ); + await expect(readOperationJournal(home, id(17))).resolves.toMatchObject({ + expectedRevisions: { + [`task:shared:${workflow.taskRef.taskId}`]: 3, + [`handoff:${id(14)}`]: 0, + }, + }); + await expect(readOperationJournal(home, id(18))).resolves.toMatchObject({ + expectedRevisions: { + [`task:shared:${workflow.taskRef.taskId}`]: 3, + [`handoff:${id(14)}`]: 1, + }, + }); + expect( + (await new V3ContextStore(root).readTaskSnapshot(workflow.taskRef)) + .metadata, + ).toMatchObject({ revision: 3, ownerActorId: actors.ownerActorId }); + }); + + it('transfers owner and claims only after the offered handoff is accepted', async () => { + const actors = await bootstrap(root); + const workflow = await createV3Workflow({ + projectRoot: root, + task: 'Accept a narrow shared implementation lane.', + workflowMode: 'manteam', + sessionId: actors.ownerSessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/**'], modules: ['auth'] }, + participantActorIds: [actors.receiverActorId], + taskId: id(30), + operationId: id(31), + now: NOW, + }); + const claim = await acquireV3Claim({ + projectRoot: root, + taskRef: workflow.taskRef, + sessionId: actors.ownerSessionId, + expectedTaskRevision: 1, + scope: { + paths: ['src/auth/**'], + modules: ['auth'], + apis: [], + schemas: [], + }, + claimId: id(32), + operationId: id(33), + now: NOW, + }); + const drafted = await createV3HandoffDraft({ + projectRoot: root, + taskRef: workflow.taskRef, + sessionId: actors.ownerSessionId, + expectedTaskRevision: 1, + toActorId: actors.receiverActorId, + handoffId: id(34), + checkpointId: id(35), + checkpointOperationId: id(36), + operationId: id(37), + now: NOW, + }); + const offered = await offerV3Handoff({ + projectRoot: root, + handoffId: drafted.handoff.handoffId, + sessionId: actors.ownerSessionId, + expectedHandoffRevision: 1, + operationId: id(38), + now: NOW, + }); + + const accepted = await acceptV3Handoff({ + projectRoot: root, + handoffId: offered.handoff.handoffId, + sessionId: actors.receiverSessionId, + expectedHandoffRevision: 2, + successorClaimIds: [id(39)], + operationId: id(40), + now: NOW, + }); + expect(accepted).toMatchObject({ + metadata: { + revision: 5, + transitionState: 'stable', + ownerActorId: actors.receiverActorId, + ownershipEpoch: 2, + lastOperationId: id(40), + }, + handoff: { + state: 'accepted', + revision: 3, + lastOperationId: id(40), + resolution: { state: 'accepted', actorId: actors.receiverActorId }, + }, + predecessorClaims: [ + { + claimId: claim.claim.claimId, + state: 'transferred', + revision: 2, + successorClaimId: id(39), + }, + ], + successorClaims: [ + { + claimId: id(39), + state: 'active', + revision: 2, + ownerActorId: actors.receiverActorId, + taskRevisionAtAcquire: 5, + ownershipEpochAtAcquire: 2, + predecessorClaimId: claim.claim.claimId, + }, + ], + taskHeadFence: { + fenceRevision: 3, + taskRevision: 5, + ownershipEpoch: 2, + lastOperationId: id(40), + }, + operation: { type: 'handoff_accept', state: 'committed' }, + }); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + workflow.taskRef, + ); + await expect(readOperationJournal(home, id(40))).resolves.toMatchObject({ + expectedRevisions: { + [`task:shared:${workflow.taskRef.taskId}`]: 3, + [`handoff:${id(34)}`]: 2, + [`claim:${claim.claim.claimId}`]: 1, + [`claim:${id(39)}`]: 0, + [`checkpoint:${id(35)}`]: 2, + [`task_head:${workflow.taskRef.taskId}`]: 2, + }, + entityLocks: expect.arrayContaining([ + `handoff:${id(34)}`, + `claim:${claim.claim.claimId}`, + `claim:${id(39)}`, + `checkpoint:${id(35)}`, + `task_head:${workflow.taskRef.taskId}`, + ]), + }); + const coordination = await new V3ContextStore( + root, + ).readCoordinationSnapshot(workflow.taskRef, home); + expect(coordination.claims).toMatchObject([ + { claimId: claim.claim.claimId, state: 'transferred', revision: 2 }, + { + claimId: id(39), + state: 'active', + ownerActorId: actors.receiverActorId, + }, + ]); + }); + + it('routes a handoff draft through the team command contract', async () => { + const actors = await bootstrap(root); + const workflow = await createV3Workflow({ + projectRoot: root, + task: 'Create a handoff through the team command.', + workflowMode: 'manteam', + sessionId: actors.ownerSessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/**'], modules: ['auth'] }, + participantActorIds: [actors.receiverActorId], + taskId: id(50), + operationId: id(51), + now: NOW, + }); + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect( + await teamHandoffDraft(root, { + task: `shared:${workflow.taskRef.taskId}`, + expectedTaskRevision: '1', + to: actors.receiverActorId, + session: actors.ownerSessionId, + client: 'owner-client', + json: true, + }), + ).toBe(0); + const payload = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + checkpoint: { kind: string }; + handoff: { + state: string; + taskRef: { namespace: string; taskId: string }; + toActorId: string; + }; + operation: { type: string; state: string }; + }; + expect(payload).toMatchObject({ + checkpoint: { kind: 'handoff_offered' }, + handoff: { + state: 'draft', + taskRef: { namespace: 'shared', taskId: workflow.taskRef.taskId }, + toActorId: actors.receiverActorId, + }, + operation: { type: 'handoff_transition', state: 'committed' }, + }); + + expect( + await teamHandoffDraft(root, { + task: `shared:${workflow.taskRef.taskId}`, + to: actors.receiverActorId, + json: true, + }), + ).toBe(2); + expect(String(logs.mock.calls.at(-1)?.[0])).toContain( + 'MANCODE_HANDOFF_DRAFT_ARGUMENT_INVALID', + ); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); + + it('repairs or aborts handoff acceptance at every durable crash point', async () => { + for (const [ + index, + fixture, + ] of OPERATION_CRASH_FIXTURES.handoff_accept.entries()) { + const caseRoot = await mkdtemp( + path.join(tmpdir(), `mancode-v3-handoff-crash-${index}-`), + ); + crashRoots.push(caseRoot); + await initializeGitFixture(caseRoot); + const actors = await bootstrap(caseRoot); + const prepared = await createOfferedHandoff(caseRoot, actors); + const operationId = id(100 + index); + + await expect( + withOperationCrashInjectionForTesting(fixture, () => + acceptV3Handoff({ + projectRoot: caseRoot, + handoffId: prepared.handoffId, + sessionId: actors.receiverSessionId, + expectedHandoffRevision: 2, + successorClaimIds: [id(120 + index)], + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const recovered = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId, + actorId: actors.receiverActorId, + sessionId: actors.receiverSessionId, + now: NOW, + }); + if (fixture.expectedRecovery === 'safe_abort') { + expect(recovered.journal.state).toBe('aborted'); + expect(['aborted', 'already_terminal']).toContain(recovered.state); + } else if (fixture.crashAfter === 'commit') { + expect(recovered).toMatchObject({ + state: 'already_terminal', + journal: { state: 'committed' }, + }); + } else { + expect(recovered).toMatchObject({ + state: 'repaired', + journal: { state: 'committed' }, + }); + } + } + }, 20_000); +}); + +async function createOfferedHandoff( + projectRoot: string, + actors: Awaited>, +): Promise<{ handoffId: Ulid }> { + const workflow = await createV3Workflow({ + projectRoot, + task: 'Recover a shared handoff acceptance after an interrupted write.', + workflowMode: 'manteam', + sessionId: actors.ownerSessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/**'], modules: ['auth'] }, + participantActorIds: [actors.receiverActorId], + taskId: id(50), + operationId: id(51), + now: NOW, + }); + const claim = await acquireV3Claim({ + projectRoot, + taskRef: workflow.taskRef, + sessionId: actors.ownerSessionId, + expectedTaskRevision: 1, + scope: { paths: ['src/auth/**'], modules: ['auth'], apis: [], schemas: [] }, + claimId: id(52), + operationId: id(53), + now: NOW, + }); + const drafted = await createV3HandoffDraft({ + projectRoot, + taskRef: workflow.taskRef, + sessionId: actors.ownerSessionId, + expectedTaskRevision: 1, + toActorId: actors.receiverActorId, + claimIds: [claim.claim.claimId], + handoffId: id(54), + checkpointId: id(55), + checkpointOperationId: id(56), + operationId: id(57), + now: NOW, + }); + await offerV3Handoff({ + projectRoot, + handoffId: drafted.handoff.handoffId, + sessionId: actors.ownerSessionId, + expectedHandoffRevision: 1, + operationId: id(58), + now: NOW, + }); + return { handoffId: drafted.handoff.handoffId }; +} + +async function bootstrap(projectRoot: string): Promise<{ + ownerActorId: Ulid; + ownerSessionId: Ulid; + receiverActorId: Ulid; + receiverSessionId: Ulid; +}> { + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const ownerActorId = id(4); + const ownerSessionId = id(5); + const receiverActorId = id(6); + const receiverSessionId = id(7); + await createLocalActor(projectRoot, { + actorId: ownerActorId, + displayName: 'Offering User', + now: NOW, + }); + const owner = await readLocalActor(projectRoot); + if (owner === null) throw new Error('missing owner actor'); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(owner, NOW), + ); + await publishSharedActorProfile(projectRoot, { + schemaVersion: 1, + actorId: receiverActorId, + displayName: 'Receiving User', + joinedAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }); + await createSession(projectRoot, { + actorId: ownerActorId, + sessionId: ownerSessionId, + client: 'owner-client', + identitySource: 'explicit', + now: NOW, + }); + await createSession(projectRoot, { + actorId: receiverActorId, + sessionId: receiverSessionId, + client: 'receiver-client', + identitySource: 'explicit', + now: NOW, + }); + return { ownerActorId, ownerSessionId, receiverActorId, receiverSessionId }; +} + +async function initializeGitFixture(projectRoot: string): Promise { + await execFile('git', ['init'], { cwd: projectRoot }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: projectRoot, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { + cwd: projectRoot, + }); + await writeFile(path.join(projectRoot, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: projectRoot }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: projectRoot }); +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-init-command.test.ts b/tests/v3-init-command.test.ts new file mode 100644 index 0000000..79522ff --- /dev/null +++ b/tests/v3-init-command.test.ts @@ -0,0 +1,52 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { EXIT_INIT_FAILED, EXIT_OK, init } from '../src/commands/init.js'; +import { parseSchemaManifest } from '../src/context/manifest.js'; +import { runtimeCheckoutRecordPath } from '../src/runtime/project-runtime.js'; + +describe('journaled V3 init command', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-init-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('initializes a greenfield project without creating legacy state', async () => { + const result = await init(root, { v3: true }); + + expect(result).toBe(EXIT_OK); + expect( + parseSchemaManifest( + JSON.parse( + await readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), + ), + ).activationState, + ).toBe('v3_active'); + await expect( + readFile(path.join(root, '.mancode', 'state.json'), 'utf8'), + ).rejects.toThrow(); + await expect( + readFile(runtimeCheckoutRecordPath(root), 'utf8'), + ).resolves.toContain('checkoutId'); + }); + + it('refuses to reinterpret legacy authority as a greenfield project', async () => { + await mkdir(path.join(root, '.mancode'), { recursive: true }); + await writeFile(path.join(root, '.mancode', 'state.json'), '{}\n'); + + expect(await init(root, { v3: true })).toBe(EXIT_INIT_FAILED); + await expect( + readFile(path.join(root, '.mancode', 'state.json'), 'utf8'), + ).resolves.toBe('{}\n'); + }); +}); diff --git a/tests/v3-publish-promote-contracts.test.ts b/tests/v3-publish-promote-contracts.test.ts new file mode 100644 index 0000000..5906fd9 --- /dev/null +++ b/tests/v3-publish-promote-contracts.test.ts @@ -0,0 +1,520 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { + localOverlayArtifactPath, + readLocalOverlayArtifact, + writeLocalOverlayArtifact, +} from '../src/context/local-overlay.js'; +import { + previewV3TaskPromotion, + promoteV3Task, +} from '../src/context/publish-promote.js'; +import { publishStagingDirectory } from '../src/context/quarantine.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { taskRootPath } from '../src/context/task-locator.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { parseWorkflowMetadata } from '../src/context/workflow-metadata.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; +import { executeOperationRecovery } from '../src/runtime/operation-recovery-executor.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { readTaskHeadFence } from '../src/runtime/task-head-store.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-17T19:00:00.000Z'); + +describe('V3 local-to-shared publish/promote', () => { + let root: string; + let crashRoots: string[]; + + beforeEach(async () => { + crashRoots = []; + root = path.join( + tmpdir(), + `mancode-v3-promote-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await execFile('git', ['init'], { cwd: root }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: root, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: root }); + await writeFile(path.join(root, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: root }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: root }); + }); + + afterEach(async () => { + await Promise.all( + [root, ...crashRoots].map((target) => + rm(target, { recursive: true, force: true }), + ), + ); + }); + + it('previews a privacy-screened promotion without changing source authority', async () => { + const { actorId, sessionId } = await bootstrap(root); + const source = await createV3Workflow({ + projectRoot: root, + task: 'Preview a privacy-safe shared implementation plan.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(8), + operationId: id(9), + now: NOW, + }); + + const preview = await previewV3TaskPromotion({ + projectRoot: root, + sourceTaskRef: source.taskRef, + sessionActorId: actorId, + expectedSourceRevision: source.metadata.revision, + destinationWorkflowMode: 'manteam', + client: 'vitest', + now: NOW, + }); + + expect(preview).toMatchObject({ + sourceMetadata: { revision: source.metadata.revision }, + destination: { + workflowMode: 'manteam', + visibility: 'shared', + coordination: 'team', + }, + quarantine: { stage: 'previewed', privacy: { status: 'passed' } }, + }); + const snapshot = await new V3ContextStore(root).readTaskSnapshot( + source.taskRef, + ); + expect(snapshot.metadata).toMatchObject({ + revision: source.metadata.revision, + status: source.metadata.status, + successorTaskRef: null, + }); + }); + + it('publishes a new shared man successor and supersedes the local source', async () => { + const { sessionId } = await bootstrap(root); + const source = await createV3Workflow({ + projectRoot: root, + task: 'Prepare a privacy-safe shared implementation plan.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(10), + operationId: id(11), + now: NOW, + }); + + const promoted = await promoteV3Task({ + projectRoot: root, + sourceTaskRef: source.taskRef, + sessionId, + expectedSourceRevision: source.metadata.revision, + destinationWorkflowMode: 'man', + sharedPrivacyConfirmed: true, + client: 'vitest', + destinationTaskId: id(12), + operationId: id(13), + now: NOW, + }); + + expect(promoted.operation).toMatchObject({ + type: 'publish_promote', + state: 'committed', + primaryStoreId: expect.stringMatching(/^workspace:/), + }); + expect(promoted.destinationMetadata).toMatchObject({ + taskRef: { namespace: 'shared', taskId: id(12) }, + workflowMode: 'man', + visibility: 'shared', + coordination: 'single', + revision: 1, + }); + expect(promoted.sourceMetadata).toMatchObject({ + status: 'superseded', + successorTaskRef: { namespace: 'shared', taskId: id(12) }, + revision: 3, + }); + expect(promoted.quarantine).toMatchObject({ + stage: 'promoted', + promotionOperationId: id(13), + }); + + const sourceMetadata = JSON.parse( + await readFile( + path.join(taskRootPath(root, source.taskRef), 'metadata.json'), + 'utf8', + ), + ); + expect(sourceMetadata).toMatchObject({ + status: 'superseded', + successorTaskRef: { namespace: 'shared', taskId: id(12) }, + }); + await expect( + readFile( + path.join( + taskRootPath(root, promoted.destinationMetadata.taskRef), + 'metadata.json', + ), + 'utf8', + ), + ).resolves.toContain('shared implementation plan'); + + const runtime = await readProjectRuntimeContext(root); + const destinationStore = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + promoted.destinationMetadata.taskRef, + ); + const sourceStore = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + source.taskRef, + ); + await expect( + readOperationJournal(destinationStore, id(13)), + ).resolves.toMatchObject({ + recoveryPayloadDigest: expect.stringMatching(/^sha256:/), + secondaryReservations: [ + expect.objectContaining({ storeId: sourceStore.storeId }), + ], + }); + await expect( + readTaskHeadFence(destinationStore, promoted.destinationMetadata.taskRef), + ).resolves.toMatchObject({ + taskRef: promoted.destinationMetadata.taskRef, + taskRevision: 1, + lastOperationId: id(13), + }); + }); + + it('keeps shared-task raw evidence in the local overlay only', async () => { + const { sessionId } = await bootstrap(root); + const shared = await createV3Workflow({ + projectRoot: root, + task: 'Coordinate a shared task while retaining private diagnostics.', + workflowMode: 'manteam', + sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + taskId: id(16), + operationId: id(17), + now: NOW, + }); + const store = new V3ContextStore(root); + const before = await store.readTaskSnapshot(shared.taskRef); + const sharedFilesBefore = await readdir(taskRootPath(root, shared.taskRef)); + const rawEvidence = + 'Authorization: Bearer overlay-only-secret\ntrace=/Users/alice/private.log'; + + const artifact = await writeLocalOverlayArtifact({ + projectRoot: root, + taskRef: shared.taskRef, + artifactId: id(18), + content: rawEvidence, + }); + + expect(artifact).toMatchObject({ + taskRef: shared.taskRef, + artifactId: id(18), + byteLength: Buffer.byteLength(rawEvidence), + contentDigest: expect.stringMatching(/^sha256:/), + path: localOverlayArtifactPath(root, shared.taskRef, id(18)), + }); + expect(path.relative(root, artifact.path)).toBe( + path.join( + '.mancode', + 'local', + 'overlays', + shared.taskRef.taskId, + 'artifacts', + id(18), + ), + ); + await expect( + readLocalOverlayArtifact(root, shared.taskRef, id(18)), + ).resolves.toEqual(Buffer.from(rawEvidence)); + await expect( + writeLocalOverlayArtifact({ + projectRoot: root, + taskRef: shared.taskRef, + artifactId: id(18), + content: 'different raw evidence', + }), + ).rejects.toThrow('MANCODE_OVERLAY_ARTIFACT_CONFLICT'); + await expect( + writeLocalOverlayArtifact({ + projectRoot: root, + taskRef: { namespace: 'local', taskId: shared.taskRef.taskId }, + artifactId: id(19), + content: rawEvidence, + }), + ).rejects.toThrow('MANCODE_OVERLAY_REQUIRES_SHARED_TASK'); + const after = await store.readTaskSnapshot(shared.taskRef); + expect(after.fingerprint).toBe(before.fingerprint); + expect(await readdir(taskRootPath(root, shared.taskRef))).toEqual( + sharedFilesBefore, + ); + expect(JSON.stringify(after)).not.toContain('overlay-only-secret'); + }); + + it('does not publish a shared destination when the source contains private text', async () => { + const { sessionId } = await bootstrap(root); + const source = await createV3Workflow({ + projectRoot: root, + task: 'Keep a local task private until it can be safely summarized.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(20), + operationId: id(21), + now: NOW, + }); + const sourceMetadataPath = path.join( + taskRootPath(root, source.taskRef), + 'metadata.json', + ); + const sourceMetadata = JSON.parse( + await readFile(sourceMetadataPath, 'utf8'), + ); + await writeFile( + sourceMetadataPath, + `${JSON.stringify( + { ...sourceMetadata, task: 'Use token=super-secret-value locally.' }, + null, + 2, + )}\n`, + ); + const blockedSource = await readFile(sourceMetadataPath, 'utf8'); + + await expect( + promoteV3Task({ + projectRoot: root, + sourceTaskRef: source.taskRef, + sessionId, + expectedSourceRevision: source.metadata.revision, + destinationWorkflowMode: 'manteam', + sharedPrivacyConfirmed: true, + client: 'vitest', + destinationTaskId: id(22), + operationId: id(23), + now: NOW, + }), + ).rejects.toThrow('MANCODE_PRIVACY_BLOCKED'); + await expect(readFile(sourceMetadataPath, 'utf8')).resolves.toBe( + blockedSource, + ); + await expect( + readFile( + path.join(publishStagingDirectory(root, id(23)), 'candidate.json'), + 'utf8', + ), + ).rejects.toThrow(); + await expect( + readFile( + path.join( + root, + '.mancode', + 'shared', + 'workflows', + id(22), + 'metadata.json', + ), + 'utf8', + ), + ).rejects.toThrow(); + }); + + it('refuses to publish a local parent while an active diagnostic child exists', async () => { + const { sessionId } = await bootstrap(root); + const source = await createV3Workflow({ + projectRoot: root, + task: 'Diagnose a local issue before publishing team work.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(30), + operationId: id(31), + now: NOW, + }); + const sourceMetadataPath = path.join( + taskRootPath(root, source.taskRef), + 'metadata.json', + ); + const parentAtVerification = parseWorkflowMetadata({ + ...source.metadata, + revision: 2, + currentStep: 6, + updatedAt: NOW.toISOString(), + }); + await writeFile( + sourceMetadataPath, + `${JSON.stringify(parentAtVerification, null, 2)}\n`, + ); + await createV3Workflow({ + projectRoot: root, + task: 'Collect a local diagnostic result.', + workflowMode: 'manba', + parentTaskRef: source.taskRef, + sessionId, + client: 'vitest', + taskId: id(32), + operationId: id(33), + now: NOW, + }); + + await expect( + promoteV3Task({ + projectRoot: root, + sourceTaskRef: source.taskRef, + sessionId, + expectedSourceRevision: parentAtVerification.revision, + destinationWorkflowMode: 'man', + sharedPrivacyConfirmed: true, + client: 'vitest', + destinationTaskId: id(34), + operationId: id(35), + now: NOW, + }), + ).rejects.toThrow('MANCODE_PROMOTION_ACTIVE_CHILDREN'); + }); + + it('repairs or aborts a real publish/promote operation at every crash point', async () => { + for (const [ + index, + fixture, + ] of OPERATION_CRASH_FIXTURES.publish_promote.entries()) { + const caseRoot = await mkdtemp( + path.join(tmpdir(), `mancode-v3-promote-crash-${index}-`), + ); + crashRoots.push(caseRoot); + await initializeGitFixture(caseRoot); + const { actorId, sessionId } = await bootstrap(caseRoot); + const source = await createV3Workflow({ + projectRoot: caseRoot, + task: 'Exercise publish/promote recovery across every durable boundary.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(100 + index), + operationId: id(120 + index), + now: NOW, + }); + const operationId = id(140 + index); + + await expect( + withOperationCrashInjectionForTesting(fixture, () => + promoteV3Task({ + projectRoot: caseRoot, + sourceTaskRef: source.taskRef, + sessionId, + expectedSourceRevision: source.metadata.revision, + destinationWorkflowMode: 'man', + sharedPrivacyConfirmed: true, + client: 'vitest', + destinationTaskId: id(160 + index), + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const recovered = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId, + actorId, + sessionId, + now: NOW, + }); + if (fixture.expectedRecovery === 'safe_abort') { + expect(recovered.journal.state).toBe('aborted'); + expect(['aborted', 'already_terminal']).toContain(recovered.state); + } else if (fixture.crashAfter === 'commit') { + expect(recovered).toMatchObject({ + state: 'already_terminal', + journal: { state: 'committed' }, + }); + } else { + expect(recovered).toMatchObject({ + state: 'repaired', + journal: { state: 'committed' }, + }); + } + } + }); +}); + +async function bootstrap( + projectRoot: string, +): Promise<{ actorId: Ulid; sessionId: Ulid }> { + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const actorId = id(4); + const sessionId = id(5); + await createLocalActor(projectRoot, { + actorId, + displayName: 'Publish Owner', + now: NOW, + }); + const actor = await readLocalActor(projectRoot); + if (actor === null) throw new Error('missing local actor'); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(actor, NOW), + ); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + return { actorId, sessionId }; +} + +async function initializeGitFixture(projectRoot: string): Promise { + await execFile('git', ['init'], { cwd: projectRoot }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: projectRoot, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { + cwd: projectRoot, + }); + await writeFile(path.join(projectRoot, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: projectRoot }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: projectRoot }); +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-requirements-finalize-contracts.test.ts b/tests/v3-requirements-finalize-contracts.test.ts new file mode 100644 index 0000000..123f0c1 --- /dev/null +++ b/tests/v3-requirements-finalize-contracts.test.ts @@ -0,0 +1,867 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { createV3Checkpoint } from '../src/context/checkpoint-create.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { reviseV3Plan } from '../src/context/plan-revision.js'; +import { finalizeV3Requirements } from '../src/context/requirements-finalize.js'; +import { REQUIREMENT_DIMENSIONS } from '../src/context/requirements-ledger.js'; +import { + type RequirementsLedgerV1, + parseRequirementsLedger, + requirementsLedgerDigest, +} from '../src/context/requirements-ledger.js'; +import { + type ReviewLedgerV1, + parseReviewLedger, + reviewLedgerDigest, +} from '../src/context/review-ledger.js'; +import { applyV3ReviewLedger } from '../src/context/review-remediation.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { completeV3Task } from '../src/context/task-complete.js'; +import { + type VerificationLedgerV1, + parseVerificationLedger, + verificationLedgerDigest, +} from '../src/context/verification-ledger.js'; +import { recordV3Verification } from '../src/context/verification-record.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; +import { executeOperationRecovery } from '../src/runtime/operation-recovery-executor.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { readTaskHeadFence } from '../src/runtime/task-head-store.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; +import { acquireV3Claim } from '../src/team/claim-acquisition.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-17T10:00:00.000Z'); + +describe('V3 requirements finalization operation', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-requirements-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('updates the local ledger tuple atomically through a committed journal', async () => { + const { sessionId } = await bootstrap(root, false, false); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Define a durable V3 requirements contract.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(10), + operationId: id(11), + now: NOW, + }); + + const result = await finalizeV3Requirements({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: 1, + requirements: finalizedRequirements( + created.requirements, + created.taskRef, + ), + operationId: id(12), + now: NOW, + }); + + expect(result.operation.state).toBe('committed'); + expect(result.metadata).toMatchObject({ + revision: 2, + currentStep: 2, + governance: { + requirementsStatus: 'ready', + requirementsDigest: result.requirements.contentDigest, + reviewStatus: 'stale', + verificationStatus: 'stale', + }, + }); + expect(result.requirements).toMatchObject({ + revision: 2, + status: 'confirmed', + lastOperationId: id(12), + }); + expect(result.review).toMatchObject({ + revision: 2, + status: 'stale', + lastOperationId: id(12), + }); + expect(result.verification).toMatchObject({ + revision: 2, + status: 'stale', + lastOperationId: id(12), + }); + expect(result.taskHeadFence).toBeNull(); + + const store = new V3ContextStore(root); + const persisted = await store.readTaskSnapshot(created.taskRef); + expect(persisted.aggregate).toEqual(result.aggregate); + expect(persisted.aggregateError).toBeNull(); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + await expect(readOperationJournal(home, id(12))).resolves.toMatchObject({ + type: 'requirements_finalize', + state: 'committed', + expectedRevisions: { + [`task:local:${created.taskRef.taskId}`]: 1, + [`requirements:${created.taskRef.taskId}`]: 1, + [`review:${created.taskRef.taskId}`]: 1, + [`verification:${created.taskRef.taskId}`]: 1, + }, + }); + + await expect( + finalizeV3Requirements({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: 1, + requirements: finalizedRequirements( + result.requirements, + created.taskRef, + ), + operationId: id(13), + now: NOW, + }), + ).rejects.toThrow('MANCODE_EXPECTED_REVISION_CONFLICT'); + }); + + it('requires the shared task-head fence and advances it with the aggregate', async () => { + const { sessionId } = await bootstrap(root, true, true); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Coordinate requirements for a shared V3 workflow.', + workflowMode: 'manteam', + sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + taskId: id(20), + operationId: id(21), + now: NOW, + }); + + const result = await finalizeV3Requirements({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: 1, + requirements: finalizedRequirements( + created.requirements, + created.taskRef, + ), + operationId: id(22), + now: NOW, + }); + + expect(result.taskHeadFence).toMatchObject({ + fenceRevision: 2, + taskRevision: 2, + aggregateDigest: expect.stringMatching(/^sha256:/), + lastOperationId: id(22), + }); + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + expect(await readTaskHeadFence(home, created.taskRef)).toEqual( + result.taskHeadFence, + ); + await expect(readOperationJournal(home, id(22))).resolves.toMatchObject({ + entityLocks: expect.arrayContaining([ + `task:shared:${created.taskRef.taskId}`, + `task_head:${created.taskRef.taskId}`, + ]), + expectedRevisions: { + [`task_head:${created.taskRef.taskId}`]: 1, + }, + }); + }); + + it('writes a plan revision before exposing its metadata and stale ledgers', async () => { + const { sessionId } = await bootstrap(root, false, false); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Prepare a V3 plan revision contract.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(70), + operationId: id(71), + now: NOW, + }); + const finalized = await finalizeV3Requirements({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: 1, + requirements: finalizedRequirements( + created.requirements, + created.taskRef, + ), + operationId: id(72), + now: NOW, + }); + + const result = await reviseV3Plan({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: finalized.metadata.revision, + plan: '# Plan\n\n1. Implement the V3 operation.\n', + planDecision: 'governed_execution', + operationId: id(73), + now: NOW, + }); + + expect(result.metadata).toMatchObject({ + revision: 3, + status: 'in_progress', + currentStep: 5, + governance: { + planVersion: 2, + planDecision: 'governed_execution', + reviewStatus: 'stale', + verificationStatus: 'stale', + }, + }); + expect(result.review).toMatchObject({ revision: 3, status: 'stale' }); + expect(result.verification).toMatchObject({ + revision: 3, + status: 'stale', + }); + const persisted = await new V3ContextStore(root).readTaskSnapshot( + created.taskRef, + ); + expect(persisted.plan?.content).toBe( + '# Plan\n\n1. Implement the V3 operation.\n', + ); + expect(persisted.aggregate).toEqual(result.aggregate); + expect(persisted.aggregateError).toBeNull(); + + const reviewResult = await applyV3ReviewLedger({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: result.metadata.revision, + review: currentReview( + result.review, + finalized.requirements.contentDigest, + result.metadata.governance.planVersion, + ), + operationId: id(74), + now: NOW, + }); + expect(reviewResult.metadata).toMatchObject({ + revision: 4, + governance: { reviewStatus: 'passed', verificationStatus: 'stale' }, + }); + expect(reviewResult.review).toMatchObject({ + revision: 4, + status: 'passed', + }); + expect(reviewResult.verification).toMatchObject({ + revision: 4, + status: 'stale', + }); + const reviewed = await new V3ContextStore(root).readTaskSnapshot( + created.taskRef, + ); + expect(reviewed.aggregate).toEqual(reviewResult.aggregate); + expect(reviewed.aggregateError).toBeNull(); + + const verificationResult = await recordV3Verification({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: reviewResult.metadata.revision, + verification: currentVerification( + reviewResult.verification, + finalized.requirements, + reviewResult.metadata.governance.planVersion, + reviewResult.review.remediationRound, + ), + operationId: id(76), + now: NOW, + }); + expect(verificationResult.metadata).toMatchObject({ + revision: 5, + governance: { verificationStatus: 'passed' }, + }); + expect(verificationResult.verification).toMatchObject({ + revision: 5, + status: 'passed', + }); + const verified = await new V3ContextStore(root).readTaskSnapshot( + created.taskRef, + ); + expect(verified.aggregate).toEqual(verificationResult.aggregate); + expect(verified.aggregateError).toBeNull(); + + const completed = await completeV3Task({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: verificationResult.metadata.revision, + operationId: id(88), + now: NOW, + }); + expect(completed).toMatchObject({ + metadata: { + revision: 7, + status: 'completed', + currentStep: 9, + transitionState: 'stable', + lastOperationId: id(88), + }, + releasedClaims: [], + operation: { type: 'task_complete', state: 'committed' }, + }); + const persistedCompletion = await new V3ContextStore(root).readTaskSnapshot( + created.taskRef, + ); + expect(persistedCompletion.aggregate).toEqual(completed.aggregate); + expect(persistedCompletion.aggregateError).toBeNull(); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + await expect(readOperationJournal(home, id(88))).resolves.toMatchObject({ + type: 'task_complete', + expectedRevisions: { [`task:local:${created.taskRef.taskId}`]: 5 }, + }); + }); + + it('releases every active shared claim before committing a completed task and fence', async () => { + const { sessionId } = await bootstrap(root, true, true); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Complete a shared task without leaving an active claim behind.', + workflowMode: 'manteam', + sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { include: ['src/**'], modules: ['auth'] }, + taskId: id(90), + operationId: id(91), + now: NOW, + }); + const finalized = await finalizeV3Requirements({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: 1, + requirements: finalizedRequirements( + created.requirements, + created.taskRef, + ), + operationId: id(92), + now: NOW, + }); + const planned = await reviseV3Plan({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: finalized.metadata.revision, + plan: '# Shared plan\n\n1. Complete the governed work.\n', + planDecision: 'governed_execution', + operationId: id(93), + now: NOW, + }); + const reviewed = await applyV3ReviewLedger({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: planned.metadata.revision, + review: currentReview( + planned.review, + finalized.requirements.contentDigest, + planned.metadata.governance.planVersion, + ), + operationId: id(94), + now: NOW, + }); + const verified = await recordV3Verification({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: reviewed.metadata.revision, + verification: currentVerification( + reviewed.verification, + finalized.requirements, + reviewed.metadata.governance.planVersion, + reviewed.review.remediationRound, + ), + operationId: id(95), + now: NOW, + }); + const acquired = await acquireV3Claim({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: verified.metadata.revision, + scope: { + paths: ['src/auth/**'], + modules: ['auth'], + apis: [], + schemas: [], + }, + claimId: id(96), + operationId: id(97), + now: NOW, + }); + + const completed = await completeV3Task({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: verified.metadata.revision, + operationId: id(98), + now: NOW, + }); + expect(completed).toMatchObject({ + metadata: { + revision: 7, + status: 'completed', + currentStep: 9, + transitionState: 'stable', + lastOperationId: id(98), + }, + releasedClaims: [ + { + claimId: acquired.claim.claimId, + state: 'released', + revision: 2, + lastOperationId: id(98), + }, + ], + taskHeadFence: { + fenceRevision: 6, + taskRevision: 7, + lastOperationId: id(98), + }, + operation: { type: 'task_complete', state: 'committed' }, + }); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + const coordination = await new V3ContextStore( + root, + ).readCoordinationSnapshot(created.taskRef, home); + expect(coordination.claims).toMatchObject([ + { claimId: acquired.claim.claimId, state: 'released', revision: 2 }, + ]); + expect(await readTaskHeadFence(home, created.taskRef)).toEqual( + completed.taskHeadFence, + ); + await expect(readOperationJournal(home, id(98))).resolves.toMatchObject({ + expectedRevisions: { + [`task:shared:${created.taskRef.taskId}`]: 5, + [`claim:${acquired.claim.claimId}`]: 1, + [`task_head:${created.taskRef.taskId}`]: 5, + }, + entityLocks: expect.arrayContaining([ + `task:shared:${created.taskRef.taskId}`, + `claim:${acquired.claim.claimId}`, + `task_head:${created.taskRef.taskId}`, + ]), + }); + }); + + it('creates an immutable checkpoint through pending metadata and settles the final aggregate', async () => { + const { sessionId } = await bootstrap(root, false, false); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Record a local V3 checkpoint.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(80), + operationId: id(81), + now: NOW, + }); + + const result = await createV3Checkpoint({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: 1, + kind: 'diagnostic_started', + summary: 'Captured the current workflow state before diagnosis.', + nextAction: 'Inspect the narrow diagnostic surface.', + checkpointId: id(82), + operationId: id(83), + now: NOW, + }); + + expect(result.operation).toMatchObject({ + type: 'checkpoint_create', + state: 'committed', + }); + expect(result.metadata).toMatchObject({ + revision: 3, + transitionState: 'stable', + lastOperationId: id(83), + latestCheckpointRef: { + taskRef: created.taskRef, + kind: 'checkpoint', + artifactId: id(82), + }, + }); + expect(result.checkpoint).toMatchObject({ + checkpointId: id(82), + operationId: id(83), + taskRevision: 2, + taskRef: created.taskRef, + }); + + const persisted = await new V3ContextStore(root).readTaskSnapshot( + created.taskRef, + ); + expect(persisted.latestCheckpoint).toEqual(result.checkpoint); + expect(persisted.aggregate).toEqual(result.aggregate); + expect(persisted.aggregateError).toBeNull(); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + await expect(readOperationJournal(home, id(83))).resolves.toMatchObject({ + expectedRevisions: { + [`task:local:${created.taskRef.taskId}`]: 1, + [`checkpoint:${id(82)}`]: 0, + }, + entityLocks: expect.arrayContaining([`checkpoint:${id(82)}`]), + }); + }); + + it('advances the shared task-head fence after a checkpoint settles', async () => { + const { sessionId } = await bootstrap(root, true, true); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Record a shared V3 checkpoint.', + workflowMode: 'manteam', + sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + taskId: id(84), + operationId: id(85), + now: NOW, + }); + + const result = await createV3Checkpoint({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: 1, + kind: 'diagnostic_started', + summary: 'Captured the shared state before a focused diagnostic.', + checkpointId: id(86), + operationId: id(87), + now: NOW, + }); + + expect(result.taskHeadFence).toMatchObject({ + fenceRevision: 2, + taskRevision: 3, + lastOperationId: id(87), + }); + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + expect(await readTaskHeadFence(home, created.taskRef)).toEqual( + result.taskHeadFence, + ); + }); + + it('repairs or aborts an actual plan revision at every declared crash point', async () => { + const fixtures = OPERATION_CRASH_FIXTURES.plan_revision; + for (const [index, fixture] of fixtures.entries()) { + const caseRoot = path.join(root, `plan-crash-${index}`); + await mkdir(caseRoot); + const { sessionId } = await bootstrap(caseRoot, false, false); + const created = await createV3Workflow({ + projectRoot: caseRoot, + task: 'Exercise a real plan revision crash boundary.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(10), + operationId: id(11), + now: NOW, + }); + const finalized = await finalizeV3Requirements({ + projectRoot: caseRoot, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: created.metadata.revision, + requirements: finalizedRequirements( + created.requirements, + created.taskRef, + ), + operationId: id(12), + now: NOW, + }); + const operationId = id(100 + index); + + await expect( + withOperationCrashInjectionForTesting(fixture, () => + reviseV3Plan({ + projectRoot: caseRoot, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: finalized.metadata.revision, + plan: '# Recovered plan\n\n1. Finish the interrupted operation.\n', + planDecision: 'governed_execution', + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const recovered = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId, + actorId: id(4), + sessionId, + now: NOW, + }); + if (fixture.expectedRecovery === 'safe_abort') { + expect(recovered).toMatchObject({ + state: 'aborted', + journal: { state: 'aborted' }, + }); + } else if (fixture.crashAfter === 'commit') { + expect(recovered).toMatchObject({ + state: 'already_terminal', + journal: { state: 'committed' }, + }); + } else { + expect(recovered).toMatchObject({ + state: 'repaired', + journal: { state: 'committed' }, + }); + } + } + }); +}); + +function currentVerification( + previous: VerificationLedgerV1, + requirements: RequirementsLedgerV1, + planVersion: number, + remediationRound: number, +): VerificationLedgerV1 { + const criterion = requirements.acceptanceCriteria[0]; + if (criterion === undefined) throw new Error('missing test criterion'); + const draft: VerificationLedgerV1 = { + ...previous, + revision: 99, + status: 'passed', + requirementsDigest: requirements.contentDigest, + planVersion, + remediationRound, + checks: [ + { + displayId: criterion.displayId, + legacyId: criterion.legacyId, + checkId: id(77), + criterionId: criterion.criterionId, + required: criterion.required, + verificationRequirement: criterion.verificationRequirement, + automated: { + evidenceId: id(78), + status: 'passed', + summary: 'The deterministic verification command passed.', + command: 'npm test', + exitCode: 0, + artifactRef: null, + confirmedByActorId: null, + confirmationSource: null, + updatedAt: NOW.toISOString(), + }, + manual: null, + }, + ], + contentDigest: '', + lastOperationId: id(79), + updatedAt: NOW.toISOString(), + }; + return parseVerificationLedger( + { ...draft, contentDigest: verificationLedgerDigest(draft) }, + requirements, + ); +} + +function currentReview( + previous: ReviewLedgerV1, + requirementsDigest: string, + planVersion: number, +): ReviewLedgerV1 { + const draft: ReviewLedgerV1 = { + ...previous, + revision: 99, + status: 'passed', + requirementsDigest, + planVersion, + requiredDomains: ['quality'], + domains: [{ domain: 'quality', status: 'passed', reportRef: null }], + blockers: [], + remediationRound: 0, + skip: null, + contentDigest: '', + lastOperationId: id(75), + updatedAt: NOW.toISOString(), + }; + return parseReviewLedger({ + ...draft, + contentDigest: reviewLedgerDigest(draft), + }); +} + +function finalizedRequirements( + previous: RequirementsLedgerV1, + taskRef: RequirementsLedgerV1['taskRef'], +): RequirementsLedgerV1 { + const requirementId = id(60); + const draft: RequirementsLedgerV1 = { + ...previous, + taskRef, + revision: 99, + status: 'confirmed', + goal: 'Make V3 requirements finalization durable and reviewable.', + functionalScope: { + inScope: ['V3 workflow requirements finalization'], + outOfScope: ['Legacy workflow mutation'], + }, + technicalDecisions: [], + defaults: [], + coverage: REQUIREMENT_DIMENSIONS.map((dimension, index) => ({ + coverageId: id(30 + index), + dimension, + status: dimension === 'technical_stack' ? 'not_applicable' : 'confirmed', + rationale: `Confirmed ${dimension} coverage.`, + })), + requirements: [ + { + displayId: 'REQ-1', + legacyId: null, + requirementId, + statement: 'A finalization operation writes one consistent V3 tuple.', + priority: 'must', + }, + ], + acceptanceCriteria: [ + { + displayId: 'AC-1', + legacyId: null, + criterionId: id(61), + requirementIds: [requirementId], + statement: 'The committed aggregate references the finalized ledger.', + required: true, + verificationRequirement: 'automated', + }, + ], + blockingUnknowns: [], + contentDigest: '', + lastOperationId: id(62), + updatedAt: NOW.toISOString(), + }; + return parseRequirementsLedger({ + ...draft, + contentDigest: requirementsLedgerDigest(draft), + }); +} + +async function bootstrap( + projectRoot: string, + withGit: boolean, + joined: boolean, +): Promise<{ actorId: Ulid; sessionId: Ulid }> { + if (withGit) { + await execFile('git', ['init'], { cwd: projectRoot }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: projectRoot, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { + cwd: projectRoot, + }); + await writeFile(path.join(projectRoot, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: projectRoot }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: projectRoot }); + } + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const actorId = id(4); + const sessionId = id(5); + await createLocalActor(projectRoot, { + actorId, + displayName: 'Vitest User', + now: NOW, + }); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + if (joined) { + const actor = await readLocalActor(projectRoot); + if (actor === null) throw new Error('missing test actor'); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(actor, NOW), + ); + } + return { actorId, sessionId }; +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-scope-change-contracts.test.ts b/tests/v3-scope-change-contracts.test.ts new file mode 100644 index 0000000..2e76de3 --- /dev/null +++ b/tests/v3-scope-change-contracts.test.ts @@ -0,0 +1,375 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { workflow as workflowCommand } from '../src/commands/workflow.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { changeV3WorkflowScope } from '../src/context/scope-change.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; +import { acquireV3Claim } from '../src/team/claim-acquisition.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-17T15:00:00.000Z'); + +describe('V3 journaled workflow scope change and re-claim', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-scope-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await execFile('git', ['init'], { cwd: root }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: root, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: root }); + await writeFile(path.join(root, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: root }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: root }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('checkpoints the change, terminates old claims, and activates only compatible successors', async () => { + const actors = await bootstrap(root); + const workflow = await createV3Workflow({ + projectRoot: root, + task: 'Narrow the shared implementation boundary.', + workflowMode: 'manteam', + sessionId: actors.ownerSessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { + include: ['src/**'], + exclude: [], + modules: ['auth', 'billing'], + }, + participantActorIds: [actors.participantActorId], + taskId: id(10), + operationId: id(11), + now: NOW, + }); + const authClaim = await acquireV3Claim({ + projectRoot: root, + taskRef: workflow.taskRef, + sessionId: actors.ownerSessionId, + expectedTaskRevision: 1, + scope: { + paths: ['src/auth/**'], + modules: ['auth'], + apis: [], + schemas: [], + }, + claimId: id(12), + operationId: id(13), + now: NOW, + }); + const billingClaim = await acquireV3Claim({ + projectRoot: root, + taskRef: workflow.taskRef, + sessionId: actors.participantSessionId, + expectedTaskRevision: 1, + scope: { + paths: ['src/billing/**'], + modules: ['billing'], + apis: [], + schemas: [], + }, + claimId: id(14), + operationId: id(15), + now: NOW, + }); + + const changed = await changeV3WorkflowScope({ + projectRoot: root, + taskRef: workflow.taskRef, + sessionId: actors.ownerSessionId, + expectedTaskRevision: 1, + scope: { + include: ['src/**'], + exclude: ['src/billing/**'], + modules: ['auth'], + }, + checkpointId: id(16), + successorClaimIds: [id(17)], + operationId: id(18), + now: NOW, + }); + + expect(changed).toMatchObject({ + metadata: { + revision: 3, + transitionState: 'stable', + implementationScope: { + source: 'explicit', + include: ['src/**'], + exclude: ['src/billing/**'], + modules: ['auth'], + }, + latestCheckpointRef: { artifactId: id(16) }, + lastOperationId: id(18), + }, + checkpoint: { + checkpointId: id(16), + operationId: id(18), + kind: 'scope_changed', + taskRevision: 2, + }, + terminatedClaims: [ + { + claimId: authClaim.claim.claimId, + state: 'transferred', + revision: 2, + successorClaimId: id(17), + }, + { + claimId: billingClaim.claim.claimId, + state: 'released', + revision: 2, + successorClaimId: null, + }, + ], + successorClaims: [ + { + claimId: id(17), + state: 'active', + revision: 2, + ownerActorId: actors.ownerActorId, + predecessorClaimId: authClaim.claim.claimId, + taskRevisionAtAcquire: 3, + lastValidatedTaskRevision: 3, + }, + ], + taskHeadFence: { + fenceRevision: 2, + taskRevision: 3, + lastOperationId: id(18), + }, + operation: { type: 'scope_change_reclaim', state: 'committed' }, + }); + expect(changed.successorClaims[0]?.implementationScopeDigest).toBe( + changed.metadata.implementationScope.digest, + ); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + workflow.taskRef, + ); + await expect(readOperationJournal(home, id(18))).resolves.toMatchObject({ + expectedRevisions: { + [`task:shared:${workflow.taskRef.taskId}`]: 1, + [`checkpoint:${id(16)}`]: 0, + [`claim:${id(12)}`]: 1, + [`claim:${id(14)}`]: 1, + [`claim:${id(17)}`]: 0, + [`task_head:${workflow.taskRef.taskId}`]: 1, + }, + entityLocks: expect.arrayContaining([ + `checkpoint:${id(16)}`, + `claim:${id(12)}`, + `claim:${id(14)}`, + `claim:${id(17)}`, + `task_head:${workflow.taskRef.taskId}`, + ]), + }); + const coordination = await new V3ContextStore( + root, + ).readCoordinationSnapshot(workflow.taskRef, home); + expect(coordination.claims).toMatchObject([ + { + claimId: authClaim.claim.claimId, + state: 'transferred', + successorClaimId: id(17), + }, + { claimId: billingClaim.claim.claimId, state: 'released' }, + { + claimId: id(17), + state: 'active', + predecessorClaimId: authClaim.claim.claimId, + }, + ]); + }); + + it('refuses a no-op replacement scope before it creates a checkpoint', async () => { + const actors = await bootstrap(root); + const workflow = await createV3Workflow({ + projectRoot: root, + task: 'Reject a no-op scope change.', + workflowMode: 'manteam', + sessionId: actors.ownerSessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { + include: ['src/**'], + exclude: [], + modules: ['auth'], + }, + participantActorIds: [actors.participantActorId], + taskId: id(30), + operationId: id(31), + now: NOW, + }); + + await expect( + changeV3WorkflowScope({ + projectRoot: root, + taskRef: workflow.taskRef, + sessionId: actors.ownerSessionId, + expectedTaskRevision: 1, + scope: { include: ['src/**'], exclude: [], modules: ['auth'] }, + checkpointId: id(32), + operationId: id(33), + now: NOW, + }), + ).rejects.toThrow('MANCODE_SCOPE_CHANGE_NOOP'); + }); + + it('routes a scope-file replacement through the V3 workflow command', async () => { + const actors = await bootstrap(root); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Change scope through the workflow command.', + workflowMode: 'manteam', + sessionId: actors.ownerSessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + implementationScope: { + include: ['src/**'], + exclude: [], + modules: ['auth', 'billing'], + }, + participantActorIds: [actors.participantActorId], + taskId: id(40), + operationId: id(41), + now: NOW, + }); + await writeFile( + path.join(root, 'scope.json'), + JSON.stringify({ + include: ['src/**'], + exclude: ['src/billing/**'], + modules: ['auth'], + }), + ); + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect( + await workflowCommand( + root, + 'scope', + ['change', `shared:${created.taskRef.taskId}`], + { + expectedRevision: '1', + file: 'scope.json', + session: actors.ownerSessionId, + client: 'owner-client', + json: true, + }, + ), + ).toBe(0); + const payload = JSON.parse(String(logs.mock.calls.at(-1)?.[0])) as { + metadata: { + revision: number; + implementationScope: { modules: string[] }; + }; + checkpoint: { kind: string }; + operation: { type: string; state: string }; + }; + expect(payload).toMatchObject({ + metadata: { + revision: 3, + implementationScope: { modules: ['auth'] }, + }, + checkpoint: { kind: 'scope_changed' }, + operation: { type: 'scope_change_reclaim', state: 'committed' }, + }); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + }); +}); + +async function bootstrap(projectRoot: string): Promise<{ + ownerActorId: Ulid; + ownerSessionId: Ulid; + participantActorId: Ulid; + participantSessionId: Ulid; +}> { + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const ownerActorId = id(4); + const ownerSessionId = id(5); + const participantActorId = id(6); + const participantSessionId = id(7); + await createLocalActor(projectRoot, { + actorId: ownerActorId, + displayName: 'Scope Owner', + now: NOW, + }); + const owner = await readLocalActor(projectRoot); + if (owner === null) throw new Error('missing owner actor'); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(owner, NOW), + ); + await publishSharedActorProfile(projectRoot, { + schemaVersion: 1, + actorId: participantActorId, + displayName: 'Scope Participant', + joinedAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + }); + await createSession(projectRoot, { + actorId: ownerActorId, + sessionId: ownerSessionId, + client: 'owner-client', + identitySource: 'explicit', + now: NOW, + }); + await createSession(projectRoot, { + actorId: participantActorId, + sessionId: participantSessionId, + client: 'participant-client', + identitySource: 'explicit', + now: NOW, + }); + return { + ownerActorId, + ownerSessionId, + participantActorId, + participantSessionId, + }; +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-solo-handoff-contracts.test.ts b/tests/v3-solo-handoff-contracts.test.ts new file mode 100644 index 0000000..4aaf33b --- /dev/null +++ b/tests/v3-solo-handoff-contracts.test.ts @@ -0,0 +1,225 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { reviseV3Plan } from '../src/context/plan-revision.js'; +import { finalizeV3Requirements } from '../src/context/requirements-finalize.js'; +import { + REQUIREMENT_DIMENSIONS, + type RequirementsLedgerV1, + parseRequirementsLedger, + requirementsLedgerDigest, +} from '../src/context/requirements-ledger.js'; +import { + completeV3SoloHandoff, + startV3SoloHandoff, +} from '../src/context/solo-handoff.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { createSession, readSession } from '../src/runtime/session.js'; +import { createLocalActor } from '../src/team/actor.js'; + +const NOW = new Date('2026-07-17T20:00:00.000Z'); + +describe('V3 solo handoff', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-solo-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('records an authoritative solo assignment, then completes through its dedicated completion gate', async () => { + const { sessionId } = await bootstrap(root); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Execute the verified single-owner implementation plan.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(10), + operationId: id(11), + now: NOW, + }); + const finalized = await finalizeV3Requirements({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: created.metadata.revision, + requirements: finalizedRequirements( + created.requirements, + created.taskRef, + ), + operationId: id(12), + now: NOW, + }); + const planned = await reviseV3Plan({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: finalized.metadata.revision, + plan: '# Plan\n\n1. Implement and verify the change.\n', + operationId: id(13), + now: NOW, + }); + const started = await startV3SoloHandoff({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: planned.metadata.revision, + operationId: id(16), + now: NOW, + }); + expect(started).toMatchObject({ + metadata: { + status: 'planned', + revision: 4, + governance: { planDecision: 'solo_handoff' }, + soloExecution: { + state: 'active', + assignedSessionId: sessionId, + }, + }, + operation: { type: 'solo_handoff', state: 'committed' }, + sessionPointerUpdated: true, + }); + expect((await readSession(root, sessionId))?.activeTaskRef).toEqual( + created.taskRef, + ); + + const completed = await completeV3SoloHandoff({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: started.metadata.revision, + operationId: id(17), + now: NOW, + }); + expect(completed).toMatchObject({ + metadata: { + status: 'completed', + currentStep: 9, + revision: 5, + soloExecution: { state: 'completed' }, + }, + operation: { type: 'solo_handoff', state: 'committed' }, + sessionPointerUpdated: true, + }); + expect((await readSession(root, sessionId))?.activeTaskRef).toBeNull(); + }); + + it('refuses a shared or unplanned task before it can create a solo assignment', async () => { + const { sessionId } = await bootstrap(root); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Do not permit an unplanned solo assignment.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(20), + operationId: id(21), + now: NOW, + }); + await expect( + startV3SoloHandoff({ + projectRoot: root, + taskRef: created.taskRef, + sessionId, + expectedTaskRevision: created.metadata.revision, + operationId: id(22), + now: NOW, + }), + ).rejects.toThrow('MANCODE_SOLO_HANDOFF_NOT_ELIGIBLE'); + }); +}); + +function finalizedRequirements( + previous: RequirementsLedgerV1, + taskRef: RequirementsLedgerV1['taskRef'], +): RequirementsLedgerV1 { + const requirementId = id(60); + const draft: RequirementsLedgerV1 = { + ...previous, + taskRef, + revision: 99, + status: 'confirmed', + goal: 'Make the solo assignment testable and reviewable.', + functionalScope: { inScope: ['V3 solo handoff'], outOfScope: [] }, + technicalDecisions: [], + defaults: [], + coverage: REQUIREMENT_DIMENSIONS.map((dimension, index) => ({ + coverageId: id(30 + index), + dimension, + status: dimension === 'technical_stack' ? 'not_applicable' : 'confirmed', + rationale: `Confirmed ${dimension}.`, + })), + requirements: [ + { + displayId: 'REQ-1', + legacyId: null, + requirementId, + statement: 'A local solo assignment must remain journaled.', + priority: 'must', + }, + ], + acceptanceCriteria: [ + { + displayId: 'AC-1', + legacyId: null, + criterionId: id(61), + requirementIds: [requirementId], + statement: 'Completion requires passing recorded verification.', + required: true, + verificationRequirement: 'automated', + }, + ], + blockingUnknowns: [], + contentDigest: '', + lastOperationId: id(62), + updatedAt: NOW.toISOString(), + }; + return parseRequirementsLedger({ + ...draft, + contentDigest: requirementsLedgerDigest(draft), + }); +} + +async function bootstrap(projectRoot: string): Promise<{ sessionId: Ulid }> { + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const actorId = id(4); + const sessionId = id(5); + await createLocalActor(projectRoot, { + actorId, + displayName: 'Solo Owner', + now: NOW, + }); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + return { sessionId }; +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-task-head-reconcile-contracts.test.ts b/tests/v3-task-head-reconcile-contracts.test.ts new file mode 100644 index 0000000..d0df300 --- /dev/null +++ b/tests/v3-task-head-reconcile-contracts.test.ts @@ -0,0 +1,320 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { taskAggregateDigest } from '../src/context/aggregate.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { + previewV3TaskHeadReconcile, + reconcileV3TaskHead, +} from '../src/context/task-head-reconcile.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; +import { executeOperationRecovery } from '../src/runtime/operation-recovery-executor.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-17T17:00:00.000Z'); + +describe('V3 explicit task-head reconcile', () => { + let root: string; + let crashRoots: string[]; + + beforeEach(async () => { + crashRoots = []; + root = path.join( + tmpdir(), + `mancode-v3-reconcile-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + await execFile('git', ['init'], { cwd: root }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: root, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { cwd: root }); + await writeFile(path.join(root, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: root }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: root }); + }); + + afterEach(async () => { + await Promise.all( + [root, ...crashRoots].map((target) => + rm(target, { recursive: true, force: true }), + ), + ); + }); + + it('adopts a Git-sourced aggregate only after an explicit fence CAS', async () => { + const actors = await bootstrap(root); + const created = await createV3Workflow({ + projectRoot: root, + task: 'Adopt a newer task aggregate from Git.', + workflowMode: 'manteam', + sessionId: actors.sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + taskId: id(10), + operationId: id(11), + now: NOW, + }); + const store = new V3ContextStore(root); + const original = await store.readTaskSnapshot(created.taskRef); + await commitTaskAuthority(root, original.location.taskRoot, 'share task'); + await writeFile( + path.join(original.location.taskRoot, 'metadata.json'), + `${JSON.stringify( + { + ...original.metadata, + revision: 2, + updatedAt: new Date(NOW.getTime() + 1_000).toISOString(), + }, + null, + 2, + )}\n`, + ); + + await expect( + reconcileV3TaskHead({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedFenceRevision: 1, + fromGit: false, + operationId: id(12), + now: NOW, + }), + ).rejects.toThrow('MANCODE_GIT_SOURCE_CONFIRMATION_REQUIRED'); + + await expect( + reconcileV3TaskHead({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedFenceRevision: 1, + fromGit: true, + operationId: id(13), + now: NOW, + }), + ).rejects.toThrow('MANCODE_TASK_UNAVAILABLE'); + await commitTaskAuthority(root, original.location.taskRoot, 'adopt task'); + + const preview = await previewV3TaskHeadReconcile({ + projectRoot: root, + taskRef: created.taskRef, + sessionActorId: id(4), + expectedFenceRevision: 1, + fromGit: true, + operationId: id(14), + now: NOW, + }); + expect(preview).toMatchObject({ + currentTaskHeadFence: { fenceRevision: 1 }, + proposedTaskHeadFence: { + fenceRevision: 2, + taskRevision: 2, + lastOperationId: id(14), + }, + }); + + const reconciled = await reconcileV3TaskHead({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: actors.sessionId, + expectedFenceRevision: 1, + fromGit: true, + operationId: id(14), + now: NOW, + }); + expect(reconciled).toMatchObject({ + aggregate: { taskRevision: 2 }, + taskHeadFence: { + fenceRevision: 2, + taskRevision: 2, + lastOperationId: id(14), + }, + operation: { type: 'task_head_reconcile', state: 'committed' }, + }); + expect(reconciled.taskHeadFence.aggregateDigest).toBe( + taskAggregateDigest(reconciled.aggregate), + ); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + await expect(readOperationJournal(home, id(14))).resolves.toMatchObject({ + expectedRevisions: { + [`task:shared:${created.taskRef.taskId}`]: 2, + [`task_head:${created.taskRef.taskId}`]: 1, + }, + entityLocks: expect.arrayContaining([ + `task_head:${created.taskRef.taskId}`, + ]), + }); + }); + + it('repairs or aborts task-head reconciliation at every durable crash point', async () => { + for (const [ + index, + fixture, + ] of OPERATION_CRASH_FIXTURES.task_head_reconcile.entries()) { + const caseRoot = await mkdtemp( + path.join(tmpdir(), `mancode-v3-reconcile-crash-${index}-`), + ); + crashRoots.push(caseRoot); + await initializeGitFixture(caseRoot); + const actors = await bootstrap(caseRoot); + const workflow = await createV3Workflow({ + projectRoot: caseRoot, + task: 'Recover an explicit Git-sourced task-head adoption.', + workflowMode: 'manteam', + sessionId: actors.sessionId, + client: 'vitest', + sharedPrivacyConfirmed: true, + taskId: id(100 + index), + operationId: id(120 + index), + now: NOW, + }); + const store = new V3ContextStore(caseRoot); + const original = await store.readTaskSnapshot(workflow.taskRef); + await commitTaskAuthority( + caseRoot, + original.location.taskRoot, + 'share task', + ); + await writeFile( + path.join(original.location.taskRoot, 'metadata.json'), + `${JSON.stringify( + { + ...original.metadata, + revision: 2, + updatedAt: new Date(NOW.getTime() + 1_000).toISOString(), + }, + null, + 2, + )}\n`, + ); + await commitTaskAuthority( + caseRoot, + original.location.taskRoot, + 'adopt task', + ); + const operationId = id(140 + index); + + await expect( + withOperationCrashInjectionForTesting(fixture, () => + reconcileV3TaskHead({ + projectRoot: caseRoot, + taskRef: workflow.taskRef, + sessionId: actors.sessionId, + expectedFenceRevision: 1, + fromGit: true, + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const recovered = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId, + actorId: actors.actorId, + sessionId: actors.sessionId, + now: NOW, + }); + if (fixture.expectedRecovery === 'safe_abort') { + expect(recovered.journal.state).toBe('aborted'); + expect(['aborted', 'already_terminal']).toContain(recovered.state); + } else if (fixture.crashAfter === 'commit') { + expect(recovered).toMatchObject({ + state: 'already_terminal', + journal: { state: 'committed' }, + }); + } else { + expect(recovered).toMatchObject({ + state: 'repaired', + journal: { state: 'committed' }, + }); + } + } + }, 20_000); +}); + +async function bootstrap( + projectRoot: string, +): Promise<{ actorId: Ulid; sessionId: Ulid }> { + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const actorId = id(4); + const sessionId = id(5); + await createLocalActor(projectRoot, { + actorId, + displayName: 'Reconcile Owner', + now: NOW, + }); + const actor = await readLocalActor(projectRoot); + if (actor === null) throw new Error('missing local actor'); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(actor, NOW), + ); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + return { actorId, sessionId }; +} + +async function initializeGitFixture(projectRoot: string): Promise { + await execFile('git', ['init'], { cwd: projectRoot }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: projectRoot, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { + cwd: projectRoot, + }); + await writeFile(path.join(projectRoot, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: projectRoot }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: projectRoot }); +} + +async function commitTaskAuthority( + projectRoot: string, + taskRoot: string, + message: string, +): Promise { + const relative = path.relative(projectRoot, taskRoot); + await execFile('git', ['add', '--', relative], { cwd: projectRoot }); + await execFile('git', ['commit', '-m', message], { cwd: projectRoot }); +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-workflow-create-contracts.test.ts b/tests/v3-workflow-create-contracts.test.ts new file mode 100644 index 0000000..660b7fb --- /dev/null +++ b/tests/v3-workflow-create-contracts.test.ts @@ -0,0 +1,315 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { taskRootPath } from '../src/context/task-locator.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { parseWorkflowMetadata } from '../src/context/workflow-metadata.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { withOperationCrashInjectionForTesting } from '../src/runtime/operation-crash-injection.js'; +import { OPERATION_CRASH_FIXTURES } from '../src/runtime/operation-definition.js'; +import { executeOperationRecovery } from '../src/runtime/operation-recovery-executor.js'; +import { readOperationJournal } from '../src/runtime/operation-store.js'; +import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js'; +import { createSession, readSession } from '../src/runtime/session.js'; +import { readTaskHeadFence } from '../src/runtime/task-head-store.js'; +import { openV3TaskOperation } from '../src/runtime/task-operation.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, + readLocalActor, +} from '../src/team/actor.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-17T10:00:00.000Z'); + +describe('V3 workflow create operation', () => { + let root: string; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-workflow-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('writes a complete local tuple behind a committed write-ahead journal and resumes only its session', async () => { + const { actorId, sessionId } = await bootstrap(root, false); + const taskId = id(6); + const operationId = id(7); + + const created = await createV3Workflow({ + projectRoot: root, + task: 'Add a deterministic V3 creation contract.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId, + operationId, + implementationScope: { include: ['src/context/**'] }, + now: NOW, + }); + + expect(created.taskRef).toEqual({ namespace: 'local', taskId }); + expect(created.metadata.ownerActorId).toBe(actorId); + expect(created.metadata.governance.requirementsStatus).toBe( + 'needs_clarification', + ); + expect(created.operation.state).toBe('committed'); + expect( + created.operation.steps.every((step) => step.state === 'completed'), + ).toBe(true); + await expect( + readFile( + path.join(taskRootPath(root, created.taskRef), 'metadata.json'), + 'utf8', + ), + ).resolves.toContain('deterministic V3 creation contract'); + + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + expect(await readOperationJournal(home, operationId)).toMatchObject({ + state: 'committed', + actorId, + sessionId, + }); + expect((await readSession(root, sessionId))?.activeTaskRef).toEqual( + created.taskRef, + ); + }); + + it('requires a privacy confirmation and joined profile before creating shared authority', async () => { + const { actorId, sessionId } = await bootstrap(root, true); + const taskId = id(8); + const request = { + projectRoot: root, + task: 'Coordinate shared review responsibilities.', + workflowMode: 'manteam' as const, + sessionId, + client: 'vitest', + taskId, + operationId: id(9), + now: NOW, + }; + + await expect(createV3Workflow(request)).rejects.toThrow( + 'MANCODE_PRIVACY_CONFIRMATION_REQUIRED', + ); + await expect( + readFile( + path.join( + root, + '.mancode', + 'shared', + 'workflows', + taskId, + 'metadata.json', + ), + 'utf8', + ), + ).rejects.toThrow(); + + const localActor = await readLocalActor(root); + expect(localActor).not.toBeNull(); + if (localActor === null) throw new Error('missing test local actor'); + await publishSharedActorProfile( + root, + createSharedActorProfile(localActor, NOW), + ); + + const created = await createV3Workflow({ + ...request, + sharedPrivacyConfirmed: true, + }); + expect(created.taskRef.namespace).toBe('shared'); + const runtime = await readProjectRuntimeContext(root); + const home = resolveTaskEntityHomeStore( + runtime.entityHomeStoreContext, + created.taskRef, + ); + expect(await readTaskHeadFence(home, created.taskRef)).toMatchObject({ + taskRef: created.taskRef, + taskRevision: 1, + aggregateDigest: expect.stringMatching(/^sha256:/), + }); + }); + + it("creates a child only from its owner's step-six parent and freezes the inherited snapshot", async () => { + const { sessionId } = await bootstrap(root, false); + const parent = await createV3Workflow({ + projectRoot: root, + task: 'Investigate an intermittent verification failure.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId: id(10), + operationId: id(11), + implementationScope: { include: ['src/**'], modules: ['core'] }, + now: NOW, + }); + const parentPath = path.join( + taskRootPath(root, parent.taskRef), + 'metadata.json', + ); + const stepSix = parseWorkflowMetadata({ + ...parent.metadata, + revision: 2, + currentStep: 6, + updatedAt: '2026-07-17T10:05:00.000Z', + }); + await writeFile(parentPath, `${JSON.stringify(stepSix, null, 2)}\n`); + + const child = await createV3Workflow({ + projectRoot: root, + task: 'Reproduce the narrow verification failure.', + workflowMode: 'manba', + sessionId, + client: 'vitest', + parentTaskRef: parent.taskRef, + taskId: id(12), + operationId: id(13), + now: NOW, + }); + + expect(child.taskRef.namespace).toBe('local'); + expect(child.metadata.parent).toMatchObject({ + taskRef: parent.taskRef, + revisionAtCreate: 2, + planVersionAtCreate: 1, + }); + expect(child.metadata.implementationScope).toMatchObject({ + source: 'inherited', + include: ['src/**'], + modules: ['core'], + }); + expect(child.resolution.dimensions.visibility.source).toBe('parent'); + + const staleParent = parseWorkflowMetadata({ + ...stepSix, + revision: 3, + updatedAt: '2026-07-17T10:06:00.000Z', + }); + await writeFile(parentPath, `${JSON.stringify(staleParent, null, 2)}\n`); + + await expect( + openV3TaskOperation({ + projectRoot: root, + taskRef: child.taskRef, + sessionId, + expectedTaskRevision: child.metadata.revision, + operationId: id(14), + now: NOW, + }), + ).rejects.toThrow('MANCODE_PARENT_STALE'); + }); + + it('repairs or aborts workflow creation at every declared crash point', async () => { + const fixtures = OPERATION_CRASH_FIXTURES.workflow_create; + for (const [index, fixture] of fixtures.entries()) { + const caseRoot = path.join(root, `create-crash-${index}`); + await mkdir(caseRoot); + const { actorId, sessionId } = await bootstrap(caseRoot, false); + const taskId = id(100 + index); + const operationId = id(120 + index); + + await expect( + withOperationCrashInjectionForTesting(fixture, () => + createV3Workflow({ + projectRoot: caseRoot, + task: 'Exercise a durable workflow creation boundary.', + workflowMode: 'man', + sessionId, + client: 'vitest', + taskId, + operationId, + now: NOW, + }), + ), + ).rejects.toThrow('MANCODE_TEST_OPERATION_CRASH_INJECTED'); + + const recovered = await executeOperationRecovery({ + projectRoot: caseRoot, + operationId, + actorId, + sessionId, + now: NOW, + }); + if (fixture.expectedRecovery === 'safe_abort') { + expect(recovered).toMatchObject({ + journal: { state: 'aborted' }, + }); + expect(['aborted', 'already_terminal']).toContain(recovered.state); + } else if (fixture.crashAfter === 'commit') { + expect(recovered).toMatchObject({ + state: 'already_terminal', + journal: { state: 'committed' }, + }); + } else { + expect(recovered).toMatchObject({ + state: 'repaired', + journal: { state: 'committed' }, + }); + } + } + }); +}); + +async function bootstrap( + projectRoot: string, + withGit: boolean, +): Promise<{ actorId: Ulid; sessionId: Ulid }> { + if (withGit) { + await execFile('git', ['init'], { cwd: projectRoot }); + await execFile('git', ['config', 'user.email', 'vitest@example.test'], { + cwd: projectRoot, + }); + await execFile('git', ['config', 'user.name', 'Vitest'], { + cwd: projectRoot, + }); + await writeFile(path.join(projectRoot, 'README.md'), '# fixture\n'); + await execFile('git', ['add', 'README.md'], { cwd: projectRoot }); + await execFile('git', ['commit', '-m', 'fixture'], { cwd: projectRoot }); + } + await initializeV3Project({ + projectRoot, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + const actorId = id(4); + const sessionId = id(5); + await createLocalActor(projectRoot, { + actorId, + displayName: 'Vitest User', + now: NOW, + }); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); + return { actorId, sessionId }; +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-17T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/v3-worktree-e2e.test.ts b/tests/v3-worktree-e2e.test.ts new file mode 100644 index 0000000..52e6303 --- /dev/null +++ b/tests/v3-worktree-e2e.test.ts @@ -0,0 +1,224 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initializeV3Project } from '../src/commands/v3-init.js'; +import { type Ulid, createUlid } from '../src/context/ids.js'; +import { V3ContextStore } from '../src/context/store.js'; +import { reconcileV3TaskHead } from '../src/context/task-head-reconcile.js'; +import { createV3Workflow } from '../src/context/workflow-create.js'; +import { resolveTaskEntityHomeStore } from '../src/runtime/entity-home-store.js'; +import { + ensureProjectRuntimeContext, + readProjectRuntimeContext, +} from '../src/runtime/project-runtime.js'; +import { createSession } from '../src/runtime/session.js'; +import { + createLocalActor, + createSharedActorProfile, + publishSharedActorProfile, +} from '../src/team/actor.js'; +import { acquireV3Claim } from '../src/team/claim-acquisition.js'; + +const execFile = promisify(execFileCallback); +const NOW = new Date('2026-07-18T10:00:00.000Z'); + +describe('V3 linked-worktree end to end', () => { + let root: string; + let linked: string | null; + + beforeEach(async () => { + root = path.join( + tmpdir(), + `mancode-v3-worktree-e2e-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + linked = null; + await mkdir(root, { recursive: true }); + await git(root, ['init']); + await git(root, ['config', 'user.email', 'vitest@example.test']); + await git(root, ['config', 'user.name', 'Vitest']); + await writeFile(path.join(root, 'README.md'), '# worktree fixture\n'); + await git(root, ['add', 'README.md']); + await git(root, ['commit', '-m', 'baseline']); + await git(root, ['branch', '-M', 'main']); + }); + + afterEach(async () => { + if (linked !== null) { + await git(root, ['worktree', 'remove', '--force', linked]).catch( + () => undefined, + ); + await rm(linked, { recursive: true, force: true }); + } + await rm(root, { recursive: true, force: true }); + }); + + it('uses Git for shared task delivery and the common dir for immediate claim coordination', async () => { + const actorA = id(4); + const actorB = id(5); + const sessionA = id(6); + const sessionB = id(7); + + await initializeV3Project({ + projectRoot: root, + operationId: id(1), + workspaceId: id(2), + schemaEpoch: id(3), + now: NOW, + }); + await createActorAndSession(root, actorA, sessionA, 'Actor A'); + await commitSharedAuthority(root, 'initialize shared authority'); + + linked = path.join( + tmpdir(), + `mancode-v3-worktree-linked-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await git(root, ['worktree', 'add', '-b', 'worktree-b', linked]); + await ensureProjectRuntimeContext(linked, NOW); + await createActorAndSession(linked, actorB, sessionB, 'Actor B'); + await commitSharedAuthority(linked, 'join actor B'); + await git(root, ['merge', '--ff-only', 'worktree-b']); + + const created = await createV3Workflow({ + projectRoot: root, + task: 'Coordinate authentication boundary changes across two worktrees.', + workflowMode: 'manteam', + sessionId: sessionA, + client: 'vitest', + sharedPrivacyConfirmed: true, + participantActorIds: [actorB], + implementationScope: { include: ['src/**', 'tests/**'] }, + taskId: id(10), + operationId: id(11), + now: NOW, + }); + + await expect( + new V3ContextStore(linked).readTaskSnapshot(created.taskRef), + ).rejects.toThrow('MANCODE_TASK_NOT_FOUND'); + + await commitSharedAuthority(root, 'publish shared task'); + await reconcileV3TaskHead({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: sessionA, + expectedFenceRevision: 1, + fromGit: true, + operationId: id(12), + now: NOW, + }); + await git(linked, ['merge', '--ff-only', 'main']); + + const [runtimeA, runtimeB, taskFromB] = await Promise.all([ + readProjectRuntimeContext(root), + readProjectRuntimeContext(linked), + new V3ContextStore(linked).readTaskSnapshot(created.taskRef), + ]); + expect(runtimeB.checkoutId).not.toBe(runtimeA.checkoutId); + expect(runtimeB.gitCommonDir).toBe(runtimeA.gitCommonDir); + expect(taskFromB.metadata.participants).toEqual([actorA, actorB]); + + const firstClaim = await acquireV3Claim({ + projectRoot: root, + taskRef: created.taskRef, + sessionId: sessionA, + expectedTaskRevision: 1, + scope: claimScope('src/auth/**'), + claimId: id(20), + operationId: id(21), + now: NOW, + }); + + await expect( + acquireV3Claim({ + projectRoot: linked, + taskRef: created.taskRef, + sessionId: sessionB, + expectedTaskRevision: 1, + scope: claimScope('src/auth/**'), + claimId: id(22), + operationId: id(23), + now: NOW, + }), + ).rejects.toThrow('MANCODE_SCOPE_CONFLICT'); + + const secondClaim = await acquireV3Claim({ + projectRoot: linked, + taskRef: created.taskRef, + sessionId: sessionB, + expectedTaskRevision: 1, + scope: claimScope('tests/auth/**'), + claimId: id(24), + operationId: id(25), + now: NOW, + }); + + const homeA = resolveTaskEntityHomeStore( + runtimeA.entityHomeStoreContext, + created.taskRef, + ); + const homeB = resolveTaskEntityHomeStore( + runtimeB.entityHomeStoreContext, + created.taskRef, + ); + expect(homeB.root).toBe(homeA.root); + const coordination = await new V3ContextStore( + root, + ).readCoordinationSnapshot(created.taskRef, homeA); + expect(coordination.claims).toEqual([firstClaim.claim, secondClaim.claim]); + }); +}); + +async function createActorAndSession( + projectRoot: string, + actorId: Ulid, + sessionId: Ulid, + displayName: string, +): Promise { + const actor = await createLocalActor(projectRoot, { + actorId, + displayName, + now: NOW, + }); + await publishSharedActorProfile( + projectRoot, + createSharedActorProfile(actor, NOW), + ); + await createSession(projectRoot, { + actorId, + sessionId, + client: 'vitest', + identitySource: 'explicit', + now: NOW, + }); +} + +async function commitSharedAuthority( + projectRoot: string, + message: string, +): Promise { + await git(projectRoot, [ + 'add', + '--force', + '.mancode/schema.json', + '.mancode/shared', + ]); + await git(projectRoot, ['commit', '-m', message]); +} + +function claimScope(pathPattern: string) { + return { paths: [pathPattern], modules: [], apis: [], schemas: [] }; +} + +async function git(projectRoot: string, args: string[]): Promise { + await execFile('git', args, { cwd: projectRoot }); +} + +function id(offset: number): Ulid { + return createUlid( + Date.parse('2026-07-18T00:00:00.000Z') + offset, + new Uint8Array(10).fill(offset), + ); +} diff --git a/tests/verification-ledger-contracts.test.ts b/tests/verification-ledger-contracts.test.ts new file mode 100644 index 0000000..9921498 --- /dev/null +++ b/tests/verification-ledger-contracts.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; +import type { RequirementsLedgerV1 } from '../src/context/requirements-ledger.js'; +import { + type VerificationLedgerV1, + assertVerificationLedgerAgainstContext, + assertVerificationLedgerTransition, + deriveVerificationLedgerStatus, + parseVerificationLedger, + verificationLedgerDigest, +} from '../src/context/verification-ledger.js'; + +const TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const CRITERION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const CHECK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const AUTOMATED_EVIDENCE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const MANUAL_EVIDENCE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; +const ARTIFACT_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7P'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7Q'; +const REQUIREMENTS_DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('verification ledger V3 contract', () => { + it('derives status from required evidence and validates acceptance identity', () => { + const parsed = parseVerificationLedger( + verificationLedger(), + requirements(), + ); + expect(deriveVerificationLedgerStatus(parsed)).toBe('manual_required'); + expect(() => + assertVerificationLedgerAgainstContext(parsed, { + requirementsDigest: REQUIREMENTS_DIGEST, + planVersion: 2, + remediationRound: 0, + }), + ).not.toThrow(); + expect(() => + assertVerificationLedgerAgainstContext(parsed, { + requirementsDigest: REQUIREMENTS_DIGEST, + planVersion: 3, + remediationRound: 0, + }), + ).toThrow(/must be stale/); + }); + + it('requires both hybrid slots and an explicit manual confirmation for pass', () => { + expect(() => + parseVerificationLedger( + withDigest({ + ...verificationLedger(), + checks: verificationLedger().checks.map((check) => ({ + ...check, + manual: null, + })), + }), + ), + ).toThrow(/both automated and manual evidence slots/); + + expect(() => + parseVerificationLedger( + withDigest({ + ...verificationLedger(), + status: 'passed', + checks: verificationLedger().checks.map((check) => ({ + ...check, + manual: + check.manual === null + ? null + : { ...check.manual, status: 'passed' }, + })), + }), + ), + ).toThrow(/requires an explicit confirmation/); + }); + + it('permits a confirmed hybrid pass and enforces ledger revisions', () => { + const previous = parseVerificationLedger( + verificationLedger(), + requirements(), + ); + const next = parseVerificationLedger( + withDigest({ + ...verificationLedger(), + revision: 2, + status: 'passed', + checks: verificationLedger().checks.map((check) => ({ + ...check, + manual: + check.manual === null + ? null + : { + ...check.manual, + status: 'passed', + confirmedByActorId: ACTOR_ID, + confirmationSource: 'actor', + updatedAt: '2026-07-17T10:01:00.000Z', + }, + })), + }), + requirements(), + ); + expect(deriveVerificationLedgerStatus(next)).toBe('passed'); + expect(() => + assertVerificationLedgerTransition(previous, next), + ).not.toThrow(); + expect(() => + assertVerificationLedgerTransition(previous, { ...next, revision: 3 }), + ).toThrow(/increase exactly once/); + }); + + it('preserves a historical manual confirmation without inventing an actor', () => { + const migrated = parseVerificationLedger( + withDigest({ + ...verificationLedger(), + status: 'passed', + legacySource: { + sourceSchema: 'verification-v1', + sourceDigest: `sha256:${'b'.repeat(64)}`, + sourceRequirementsDigest: `sha256:${'c'.repeat(64)}`, + fieldMapVersion: 1, + }, + checks: verificationLedger().checks.map((check) => ({ + ...check, + manual: + check.manual === null + ? null + : { + ...check.manual, + status: 'passed', + summary: 'A legacy reviewer confirmed the behavior.', + confirmationSource: 'legacy_migration', + updatedAt: '2026-07-17T10:01:00.000Z', + }, + })), + }), + ); + expect(migrated.checks[0]?.manual?.confirmationSource).toBe( + 'legacy_migration', + ); + }); +}); + +function verificationLedger(): VerificationLedgerV1 { + const draft: VerificationLedgerV1 = { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: { namespace: 'shared', taskId: TASK_ID }, + revision: 1, + status: 'manual_required', + requirementsDigest: REQUIREMENTS_DIGEST, + planVersion: 2, + remediationRound: 0, + checks: [ + { + displayId: 'AC-1', + legacyId: 'AC-1', + checkId: CHECK_ID, + criterionId: CRITERION_ID, + required: true, + verificationRequirement: 'hybrid', + automated: { + evidenceId: AUTOMATED_EVIDENCE_ID, + status: 'passed', + summary: 'Automated checks passed.', + command: 'npm test', + exitCode: 0, + artifactRef: { + taskRef: { namespace: 'shared', taskId: TASK_ID }, + kind: 'evidence_summary', + artifactId: ARTIFACT_ID, + }, + confirmedByActorId: null, + confirmationSource: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }, + manual: { + evidenceId: MANUAL_EVIDENCE_ID, + status: 'manual_required', + summary: null, + command: null, + exitCode: null, + artifactRef: null, + confirmedByActorId: null, + confirmationSource: null, + updatedAt: null, + }, + }, + ], + legacySource: null, + contentDigest: '', + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; + return withDigest(draft); +} + +function requirements(): RequirementsLedgerV1 { + return { + schemaVersion: 1, + canonicalizationVersion: 'mancode-jcs-v1', + taskRef: { namespace: 'shared', taskId: TASK_ID }, + revision: 1, + status: 'confirmed', + goal: 'Verify the shared acceptance criterion.', + functionalScope: { inScope: ['Verify it.'], outOfScope: [] }, + technicalDecisions: [], + defaults: [], + coverage: [], + requirements: [], + acceptanceCriteria: [ + { + displayId: 'AC-1', + legacyId: 'AC-1', + criterionId: CRITERION_ID, + requirementIds: [], + statement: 'A reviewer confirms the result.', + required: true, + verificationRequirement: 'hybrid', + }, + ], + blockingUnknowns: [], + legacySource: null, + contentDigest: REQUIREMENTS_DIGEST, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} + +function withDigest(draft: VerificationLedgerV1): VerificationLedgerV1 { + return { ...draft, contentDigest: verificationLedgerDigest(draft) }; +} diff --git a/tests/workflow-metadata-contracts.test.ts b/tests/workflow-metadata-contracts.test.ts new file mode 100644 index 0000000..5a34d95 --- /dev/null +++ b/tests/workflow-metadata-contracts.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; +import { digestCanonicalJson } from '../src/context/canonical.js'; +import { + type WorkflowMetadataV3, + assertWorkflowMetadataTransition, + parseWorkflowMetadata, + workflowMetadataDigest, +} from '../src/context/workflow-metadata.js'; + +const LOCAL_TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const SHARED_TASK_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const ACTOR_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const SESSION_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('workflow metadata V3 contract', () => { + it('enforces the V3 workflow dimensions without accepting legacy mamba or non-workflow modes', () => { + const metadata = parseWorkflowMetadata(rawMetadata()); + expect(metadata.workflowMode).toBe('manteam'); + expect(metadata.taskRef.namespace).toBe('shared'); + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + workflowMode: 'mamba', + }), + ).toThrow(/workflowMode/); + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + coordination: 'single', + }), + ).toThrow(/manteam metadata/); + }); + + it('requires lifecycle, owner, scope, and pending-operation invariants', () => { + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + status: 'blocked', + blockingReason: null, + }), + ).toThrow(/blockingReason/); + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + transitionState: 'operation_pending', + lastOperationId: null, + }), + ).toThrow(/requires lastOperationId/); + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + implementationScope: { + ...rawMetadata().implementationScope, + digest: DIGEST, + }, + }), + ).toThrow(/does not match scope/); + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + taskRef: { namespace: 'local', taskId: LOCAL_TASK_ID }, + workflowMode: 'manba', + visibility: 'local', + coordination: 'single', + status: 'in_progress', + outcome: 'fixed', + }), + ).toThrow(/only valid for completed/); + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + task: 'Investigate /Users/alice/private-project.', + }), + ).toThrow(/MANCODE_PRIVACY_BLOCKED/); + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + latestCheckpointRef: { + taskRef: { namespace: 'local', taskId: LOCAL_TASK_ID }, + kind: 'checkpoint', + }, + }), + ).toThrow(/shared entities cannot reference local/); + expect(() => + parseWorkflowMetadata({ + ...rawMetadata(), + status: 'superseded', + successorTaskRef: { namespace: 'shared', taskId: SHARED_TASK_ID }, + }), + ).toThrow(/successor must promote a local task/); + }); + + it('excludes revision and timestamps from metadataDigest but keeps successor transitions explicit', () => { + const metadata = parseWorkflowMetadata(rawMetadata()); + const revisionOnly = parseWorkflowMetadata({ + ...metadata, + revision: 8, + updatedAt: '2026-07-17T10:01:00.000Z', + }); + expect(workflowMetadataDigest(revisionOnly)).toBe( + workflowMetadataDigest(metadata), + ); + + const local = parseWorkflowMetadata(localMetadata()); + const published = parseWorkflowMetadata({ + ...local, + status: 'superseded', + revision: 8, + successorTaskRef: { namespace: 'shared', taskId: SHARED_TASK_ID }, + }); + expect(() => + assertWorkflowMetadataTransition(local, published, 'publish'), + ).not.toThrow(); + expect(() => + assertWorkflowMetadataTransition(local, published, 'ordinary'), + ).toThrow(/publish or promote/); + }); + + it('preserves skipped step order for legacy compatibility evidence', () => { + const metadata = parseWorkflowMetadata({ + ...rawMetadata(), + skippedSteps: ['review', 'clarification'], + }); + expect(metadata.skippedSteps).toEqual(['review', 'clarification']); + }); +}); + +function rawMetadata(): WorkflowMetadataV3 { + const scope = { + source: 'explicit' as const, + include: ['tests/auth/**', 'src/auth/**'], + exclude: ['src/billing/**'], + modules: ['auth-api'], + }; + return { + schemaVersion: 3, + taskRef: { namespace: 'shared', taskId: SHARED_TASK_ID }, + displaySlug: 'login-rate-limit', + task: 'Add login rate limits.', + workflowMode: 'manteam', + visibility: 'shared', + coordination: 'team', + status: 'in_progress', + currentStep: 5, + skippedSteps: [], + blockingReason: null, + outcome: null, + revision: 7, + transitionState: 'stable', + lastOperationId: null, + ownerActorId: ACTOR_ID, + ownershipEpoch: 3, + participants: [ACTOR_ID], + createdBy: { actorId: ACTOR_ID, client: 'codex', source: 'actor' }, + base: { + branch: 'feature/login', + head: 'abc1234', + upstream: 'origin/feature/login', + }, + implementationScope: { + ...scope, + digest: digestCanonicalJson({ + ...scope, + include: ['src/auth/**', 'tests/auth/**'], + }), + }, + governance: { + requirementsStatus: 'ready', + requirementsDigest: DIGEST, + planVersion: 2, + planDecision: 'governed_execution', + policyVersions: { planning: 2, review: 2, verification: 1 }, + reviewStatus: 'passed', + reviewLedgerDigest: DIGEST, + verificationStatus: 'passed', + verificationLedgerDigest: DIGEST, + }, + soloExecution: null, + latestCheckpointRef: null, + parent: null, + successorTaskRef: null, + legacyCompatibility: null, + startedAt: '2026-07-17T09:30:00.000Z', + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} + +function localMetadata(): WorkflowMetadataV3 { + const metadata = rawMetadata(); + const scope = { + source: 'explicit' as const, + include: ['src/auth/**'], + exclude: [], + modules: ['auth-api'], + }; + return { + ...metadata, + taskRef: { namespace: 'local', taskId: LOCAL_TASK_ID }, + workflowMode: 'man', + visibility: 'local', + coordination: 'single', + implementationScope: { ...scope, digest: digestCanonicalJson(scope) }, + ownershipEpoch: 1, + successorTaskRef: null, + }; +} diff --git a/tests/workspace-binding-contracts.test.ts b/tests/workspace-binding-contracts.test.ts new file mode 100644 index 0000000..84d05e0 --- /dev/null +++ b/tests/workspace-binding-contracts.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { + assertCheckoutBindingMatchesWorkspace, + assertWorkspaceBindingCompatible, + assertWorkspaceBindingMatchesConfig, + gitRefCoordinationDomainId, + localCoordinationDomainId, + parseCheckoutBinding, + parseCommonDirRegistry, + parseWorkspaceBinding, +} from '../src/runtime/workspace-binding.js'; +import { + parseProjectConfig, + projectConfigIdentityDigest, +} from '../src/team/policy.js'; + +const WORKSPACE_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7H'; +const BINDING_ID = '01JZ4B6W5Z0A1B2C3D4E5F6G7J'; +const CHECKOUT_A = '01JZ4B6W5Z0A1B2C3D4E5F6G7K'; +const CHECKOUT_B = '01JZ4B6W5Z0A1B2C3D4E5F6G7M'; +const EPOCH = '01JZ4B6W5Z0A1B2C3D4E5F6G7N'; +const DIGEST = `sha256:${'a'.repeat(64)}`; + +describe('common-dir workspace and checkout bindings', () => { + it('binds a workspace to config identity and monorepo-relative project path', () => { + const config = parseProjectConfig(rawConfig()); + const binding = parseWorkspaceBinding(rawWorkspaceBinding(config)); + expect(() => + assertWorkspaceBindingMatchesConfig(binding, config), + ).not.toThrow(); + expect(() => + assertWorkspaceBindingCompatible( + binding, + parseWorkspaceBinding({ + ...rawWorkspaceBinding(config), + projectPathFromWorktreeRoot: 'packages/other', + }), + ), + ).toThrow('MANCODE_WORKSPACE_BINDING_MISMATCH'); + expect(() => + parseWorkspaceBinding({ + ...rawWorkspaceBinding(config), + projectPathFromWorktreeRoot: '../outside', + }), + ).toThrow(/projectPathFromWorktreeRoot/); + }); + + it('allows distinct worktree identities while keeping coordination domain scoped', () => { + const config = parseProjectConfig(rawConfig()); + const workspace = parseWorkspaceBinding(rawWorkspaceBinding(config)); + const checkoutA = parseCheckoutBinding( + rawCheckoutBinding(CHECKOUT_A, DIGEST), + ); + const checkoutB = parseCheckoutBinding( + rawCheckoutBinding(CHECKOUT_B, `sha256:${'b'.repeat(64)}`), + ); + expect(() => + assertCheckoutBindingMatchesWorkspace(checkoutA, workspace), + ).not.toThrow(); + expect(() => + assertCheckoutBindingMatchesWorkspace(checkoutB, workspace), + ).not.toThrow(); + expect(localCoordinationDomainId(BINDING_ID, WORKSPACE_ID)).toBe( + `local:${BINDING_ID}:${WORKSPACE_ID}`, + ); + expect(gitRefCoordinationDomainId(DIGEST, WORKSPACE_ID, EPOCH)).toBe( + `git-ref:${DIGEST}:${WORKSPACE_ID}:${EPOCH}`, + ); + expect(() => + parseCommonDirRegistry({ + schemaVersion: 1, + workspaceIds: [WORKSPACE_ID, WORKSPACE_ID], + updatedAt: '2026-07-17T10:00:00.000Z', + }), + ).toThrow(/must not contain duplicates/); + }); +}); + +function rawConfig() { + return { + schemaVersion: 1, + revision: 1, + workspaceId: WORKSPACE_ID, + transport: { mode: 'local', remote: null }, + lastOperationId: null, + updatedAt: '2026-07-17T10:00:00.000Z', + }; +} + +function rawWorkspaceBinding(config: ReturnType) { + return { + schemaVersion: 1, + workspaceId: WORKSPACE_ID, + repositoryBindingId: BINDING_ID, + projectPathFromWorktreeRoot: 'packages/app', + configSchemaVersion: config.schemaVersion, + configIdentityDigest: projectConfigIdentityDigest(config), + registeredAt: '2026-07-17T10:00:00.000Z', + }; +} + +function rawCheckoutBinding(checkoutId: string, realpathHash: string) { + return { + schemaVersion: 1, + workspaceId: WORKSPACE_ID, + repositoryBindingId: BINDING_ID, + checkoutId, + worktreeGitDirHash: DIGEST, + projectRealpathHash: realpathHash, + registeredAt: '2026-07-17T10:00:00.000Z', + lastSeenAt: '2026-07-17T10:01:00.000Z', + }; +}