From dfef14bb5c5cc804d44b047f982171e8b22d99c1 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Tue, 25 Aug 2026 07:27:42 +0800 Subject: [PATCH 1/8] test: add packaged continuity v2 acceptance smoke --- apps/desktop/package.json | 1 + .../tests/run-packaged-continuity-smoke.mjs | 737 ++++++++++++++++++ .../channel-conversation-continuity-v2.md | 41 +- ...xecution-completion-implementation-plan.md | 98 +-- 4 files changed, 817 insertions(+), 60 deletions(-) create mode 100644 apps/desktop/tests/run-packaged-continuity-smoke.mjs diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 17d8e7f6c..e7cc5e5f3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,6 +21,7 @@ "performance:computer-use:real": "corepack pnpm build && node --expose-gc tests/computer-use-performance-harness.mjs --real", "smoke:computer-use": "corepack pnpm build && node tests/run-computer-use-smoke.mjs", "smoke:computer-use:real": "corepack pnpm build && node tests/run-computer-use-smoke.mjs --real", + "smoke:continuity:mac": "node tests/run-packaged-continuity-smoke.mjs", "smoke:computer-use:windows-provider": "corepack pnpm build && node tests/run-windows-provider-smoke.mjs", "smoke:chrome-extension:real": "corepack pnpm --filter @ling-stack/chrome-extension build && corepack pnpm --filter @ling-stack/daemon build && node tests/chrome-extension-real-smoke.mjs", "verify:release-schemas": "node tests/validate-release-evidence-schemas.mjs", diff --git a/apps/desktop/tests/run-packaged-continuity-smoke.mjs b/apps/desktop/tests/run-packaged-continuity-smoke.mjs new file mode 100644 index 000000000..074663164 --- /dev/null +++ b/apps/desktop/tests/run-packaged-continuity-smoke.mjs @@ -0,0 +1,737 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { DatabaseSync } from "node:sqlite"; + +const repositoryRoot = resolve(new URL("../../..", import.meta.url).pathname); +const defaultApp = join(repositoryRoot, "apps/desktop/release/mac-arm64/Ling Stack.app/Contents/MacOS/Ling Stack"); +const defaultArtifact = join(repositoryRoot, "apps/desktop/release/Ling-Stack-0.0.0-macos-arm64.zip"); +const options = parseArgs(process.argv.slice(2)); + +if (process.platform !== "darwin") { + throw new Error("packaged_continuity_smoke_requires_darwin"); +} + +const appExecutable = resolve(options.app ?? defaultApp); +const artifact = options.artifact ? resolve(options.artifact) : defaultArtifact; +const runRoot = options.output ? resolve(options.output) : await mkdtemp(join(tmpdir(), "ling-packaged-continuity-")); +const userData = join(runRoot, "user-data"); +const lingHome = join(runRoot, "ling-stack-home"); +const workspace = join(runRoot, "workspace"); +const logs = join(runRoot, "logs"); +const proofFileName = "r5-packaged-proof.txt"; +const proofContent = "continuity-v2-r5-proof"; +const exactVerificationCommand = `node -e "const fs=require('fs');const p=Buffer.from('${Buffer.from(proofFileName, "utf8").toString("base64")}','base64').toString('utf8');const e=Buffer.from('${Buffer.from(proofContent, "utf8").toString("base64")}','base64').toString('utf8');if(fs.readFileSync(p,'utf8')!==e)process.exit(1)"`; +await mkdir(workspace, { recursive: true }); +await mkdir(logs, { recursive: true }); + +const report = { + schemaVersion: 1, + kind: "ling-desktop-macos-packaged-continuity", + status: "running", + appExecutable, + artifact, + runRoot, + checks: [], +}; + +function check(id, status, details) { + report.checks.push({ id, status, details }); + if (status !== "passed") throw new Error(`${id}:${details?.message ?? "failed"}`); +} + +function hashFile(path) { + return new Promise((resolveHash, reject) => { + const hash = createHash("sha256"); + readFile(path).then((bytes) => resolveHash(hash.update(bytes).digest("hex")), reject); + }); +} + +async function freePort() { + return await new Promise((resolvePort, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : undefined; + server.close((error) => error ? reject(error) : resolvePort(port)); + }); + }); +} + +async function startMockOpenAICompatibleServer() { + const requests = []; + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const bodyText = Buffer.concat(chunks).toString("utf8"); + let body = {}; + try { + body = bodyText ? JSON.parse(bodyText) : {}; + } catch { + response.writeHead(400, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "invalid_json" } })); + return; + } + requests.push({ method: request.method, path: request.url, body }); + + const messages = Array.isArray(body.messages) ? body.messages : []; + const messageText = (message) => { + if (typeof message?.content === "string") return message.content; + if (!Array.isArray(message?.content)) return ""; + return message.content + .map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "") + .join("\n"); + }; + const prompt = messages.map(messageText).join("\n"); + const systemPrompt = messages + .filter((message) => message?.role === "system" || message?.role === "developer") + .map(messageText) + .join("\n"); + const toolNames = (Array.isArray(body.tools) ? body.tools : []) + .map((tool) => tool?.function?.name ?? tool?.custom?.name) + .filter((name) => typeof name === "string"); + const lastMessage = messages.at(-1); + let content = "Packaged continuity mock completed."; + let toolCall; + + if (/channel intent selector/i.test(systemPrompt)) { + const nakedReady = /naked-ready/i.test(prompt); + content = JSON.stringify({ + action: "execute", + reason: nakedReady + ? "The user explicitly requested a delegated execution that must prove evidence before completion." + : "The user explicitly requested a bounded multi-participant file and verification workflow.", + execution: { + operations: nakedReady + ? [{ id: "create_file", kind: "file_create", targetKind: "file", required: true, dependsOn: [] }] + : [ + { id: "create_file", kind: "file_create", targetKind: "file", required: true, dependsOn: [] }, + { id: "verify_target", kind: "run_command", targetKind: "command", required: true, dependsOn: ["create_file"] }, + ], + }, + }); + } else if (/terminal report synthesizer/i.test(systemPrompt)) { + const ref = (prompt.match(/Allowed source refs:\s*([^\n]+)/i)?.[1] ?? "").split(",")[0]?.trim(); + const [kind, id] = ref.split(":"); + content = JSON.stringify({ + outcome: { + status: "succeeded", + title: "Packaged continuity workflow completed", + summary: "Engineer and QA produced verified Host evidence.", + corrections: [], + sourceRefs: kind && id ? [{ kind, id, visibility: "conversation" }] : [], + }, + changes: [], + verification: [], + participants: [], + artifacts: [], + risks: [], + references: kind && id ? [{ kind, id, visibility: "conversation" }] : [], + }); + } else if (/naked-ready/i.test(prompt)) { + content = "ready"; + } else if (lastMessage?.role === "tool") { + content = /bash/i.test(lastMessage?.name ?? "") || /bash/i.test(prompt) + ? "QA verified the exact Host-materialized command with clean output." + : "Engineer created the requested file with a durable Host write result."; + } else if (toolNames.includes("write")) { + toolCall = { + id: "packaged_engineer_write", + name: "write", + arguments: { path: proofFileName, content: proofContent }, + }; + } else if (toolNames.includes("bash")) { + const commandMatch = prompt.match(/exact command:\s*([^\n\r]+)/i); + const command = commandMatch?.[1]?.trim().replace(/^`|`$/g, "") || exactVerificationCommand; + toolCall = { + id: "packaged_qa_verify", + name: "bash", + arguments: { command }, + }; + } + + const payload = { + id: "chatcmpl-packaged-continuity", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: body.model ?? "packaged-continuity", + choices: [{ + index: 0, + message: { + role: "assistant", + ...(toolCall + ? { + content: null, + tool_calls: [{ + id: toolCall.id, + type: "function", + function: { name: toolCall.name, arguments: JSON.stringify(toolCall.arguments) }, + }], + } + : { content }), + }, + finish_reason: toolCall ? "tool_calls" : "stop", + }], + }; + + if (body.stream === true) { + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const choice = payload.choices[0]; + const delta = toolCall + ? { + role: "assistant", + tool_calls: [{ + index: 0, + id: toolCall.id, + type: "function", + function: { name: toolCall.name, arguments: JSON.stringify(toolCall.arguments) }, + }], + } + : { role: "assistant", content }; + response.write(`data: ${JSON.stringify({ ...payload, object: "chat.completion.chunk", choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`); + response.write(`data: ${JSON.stringify({ ...payload, object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: choice.finish_reason }] })}\n\n`); + response.end("data: [DONE]\n\n"); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(payload)); + }); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", resolveListen); + }); + const address = server.address(); + if (!address || typeof address !== "object") throw new Error("mock_provider_address_unavailable"); + return { + url: `http://127.0.0.1:${address.port}`, + requests, + close: () => new Promise((resolveClose, rejectClose) => server.close((error) => error ? rejectClose(error) : resolveClose())), + }; +} + +const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); + +async function request(baseUrl, path, init = {}) { + let lastError; + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + const response = await fetch(`${baseUrl}${path}`, init); + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { status: response.status, body, text }; + } catch (error) { + lastError = error; + await sleep(200); + } + } + throw lastError ?? new Error("request_failed"); +} + +async function waitForHealth(baseUrl) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const response = await request(baseUrl, "/health"); + if (response.status === 200 && response.body?.ok === true) return response.body; + await sleep(200); + } + throw new Error("packaged_daemon_health_timeout"); +} + +function launch(baseUrl, cwd) { + const child = spawn(appExecutable, [`--user-data-dir=${userData}`], { + cwd, + env: { + ...process.env, + LING_STACK_DAEMON_URL: baseUrl, + LING_STACK_DEFAULT_WORKSPACE_CWD: cwd, + LING_STACK_HOME: lingHome, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout = []; + const stderr = []; + child.stdout.on("data", (chunk) => stdout.push(String(chunk))); + child.stderr.on("data", (chunk) => stderr.push(String(chunk))); + return { child, stdout, stderr }; +} + +async function stop(session) { + if (!session?.child || session.child.exitCode !== null) return; + session.child.kill("SIGTERM"); + await Promise.race([ + new Promise((resolveExit) => session.child.once("exit", resolveExit)), + sleep(5_000), + ]); + if (session.child.exitCode === null) session.child.kill("SIGKILL"); +} + +function jsonHeaders() { + return { "content-type": "application/json", accept: "application/json" }; +} + +function parseArgs(argv) { + const result = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--app" || arg === "--artifact" || arg === "--output") result[arg.slice(2)] = argv[++index]; + else if (arg === "--keep") result.keep = true; + else if (arg === "--help") { + console.log("Usage: node apps/desktop/tests/run-packaged-continuity-smoke.mjs [--app path] [--artifact path] [--output dir] [--keep]"); + process.exit(0); + } else throw new Error(`unknown_argument:${arg}`); + } + return result; +} + +async function waitForFile(path, expectedContent) { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + await access(path); + const content = await readFile(path, "utf8"); + if (content === expectedContent) return; + } catch { + // The workflow is still executing. + } + await sleep(200); + } + throw new Error(`host_file_evidence_timeout:${path}`); +} + +async function waitFor(checkFn, failureCode, attempts = 100) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + const result = await checkFn(); + if (result) return result; + await sleep(200); + } + throw new Error(failureCode); +} + +function readSqliteAudit(databasePath, ids) { + const database = new DatabaseSync(databasePath, { readOnly: true }); + try { + const count = (sql, ...params) => Number(database.prepare(sql).get(...params)?.count ?? 0); + const rows = (sql, ...params) => database.prepare(sql).all(...params); + const foreignKeys = rows("PRAGMA foreign_key_check"); + const integrity = rows("PRAGMA integrity_check")[0]?.integrity_check; + if (foreignKeys.length !== 0) throw new Error(`sqlite_foreign_key_check_failed:${JSON.stringify(foreignKeys)}`); + if (integrity !== "ok") throw new Error(`sqlite_integrity_check_failed:${String(integrity)}`); + const sourceItem = rows("SELECT id, turn_id, thread_id FROM conversation_items WHERE id = ?", ids.sourceItemId)[0]; + if (!sourceItem) throw new Error("sqlite_lineage_source_item_missing"); + const sourceTurn = rows("SELECT id, thread_id, status, source_item_id FROM ling_turns WHERE id = ?", sourceItem.turn_id)[0]; + if (!sourceTurn || sourceTurn.source_item_id !== ids.sourceItemId) throw new Error("sqlite_lineage_source_turn_missing"); + const decision = rows("SELECT id, source_item_id, workflow_id, participant_manifest_json FROM channel_intent_decisions WHERE source_item_id = ? AND workflow_id = ?", ids.sourceItemId, ids.workflowId)[0]; + if (!decision || decision.workflow_id !== ids.workflowId) throw new Error("sqlite_lineage_intent_decision_missing"); + const workflow = rows("SELECT id, channel_id, status, metadata_json FROM channel_workflows WHERE id = ?", ids.workflowId)[0]; + if (!workflow || workflow.status !== "succeeded") throw new Error("sqlite_lineage_workflow_missing"); + const steps = rows("SELECT id, workflow_id, run_id, to_participant_id, status, metadata_json FROM channel_workflow_steps WHERE workflow_id = ? ORDER BY position", ids.workflowId); + if (steps.length !== 2 || steps.some((step) => step.workflow_id !== ids.workflowId || step.status !== "succeeded" || !step.run_id)) { + throw new Error("sqlite_lineage_workflow_steps_incomplete"); + } + const runs = rows(`SELECT id, status, workflow_step_id FROM run_sessions WHERE id IN (${ids.runIds.map(() => "?").join(",")})`, ...ids.runIds); + if (runs.length !== ids.runIds.length || runs.some((run) => run.status !== "succeeded")) throw new Error("sqlite_lineage_runs_incomplete"); + const evidenceEvents = count(`SELECT COUNT(*) AS count FROM run_events WHERE run_id IN (${ids.runIds.map(() => "?").join(",")}) AND type IN ('tool_start','tool_end')`, ...ids.runIds); + if (evidenceEvents < 4) throw new Error(`sqlite_lineage_evidence_incomplete:${evidenceEvents}`); + const reports = rows("SELECT id, source_kind, source_id, outcome_status, published_message_id FROM ling_terminal_reports WHERE source_kind = 'workflow' AND source_id = ? AND status IN ('committed','fallback')", ids.workflowId); + if (reports.length !== 1 || reports[0].outcome_status !== "succeeded" || !reports[0].published_message_id) throw new Error("sqlite_lineage_terminal_report_incomplete"); + const succeededTransitions = count("SELECT COUNT(*) AS count FROM state_transition_events WHERE entity_type = 'turn' AND entity_id = ? AND to_status = 'succeeded'", sourceTurn.id); + if (succeededTransitions !== 1) throw new Error(`sqlite_lineage_turn_settlement_not_idempotent:${succeededTransitions}`); + const manifest = JSON.parse(decision.participant_manifest_json); + if (manifest.participants?.length !== 2) throw new Error("sqlite_lineage_manifest_incomplete"); + return { + sourceItemId: ids.sourceItemId, + sourceTurnId: sourceTurn.id, + threadId: sourceItem.thread_id, + workflowId: ids.workflowId, + workflowStepIds: steps.map((step) => step.id), + runIds: runs.map((run) => run.id), + evidenceEvents, + terminalReportId: reports[0].id, + publishedMessageId: reports[0].published_message_id, + succeededTransitions, + foreignKeyCheck: "ok", + integrityCheck: "ok", + tableCounts: { + threads: count("SELECT COUNT(*) AS count FROM ling_threads WHERE channel_id = ?", ids.channelId), + turns: count("SELECT COUNT(*) AS count FROM ling_turns WHERE source_item_id = ?", ids.sourceItemId), + items: count("SELECT COUNT(*) AS count FROM conversation_items WHERE channel_id = ?", ids.channelId), + decisions: count("SELECT COUNT(*) AS count FROM channel_intent_decisions WHERE source_item_id = ?", ids.sourceItemId), + workflows: count("SELECT COUNT(*) AS count FROM channel_workflows WHERE id = ?", ids.workflowId), + runs: runs.length, + terminalReports: reports.length, + }, + }; + } finally { + database.close(); + } +} + +let session; +let mockProvider; +try { + check("artifact", "passed", { + sha256: await hashFile(artifact), + app: appExecutable, + }); + const daemonPort = await freePort(); + const baseUrl = `http://127.0.0.1:${daemonPort}`; + mockProvider = await startMockOpenAICompatibleServer(); + session = launch(baseUrl, workspace); + const health = await waitForHealth(baseUrl); + check("launch", "passed", { + pid: session.child.pid, + daemonPort, + readiness: health.readiness, + features: health.features, + }); + + const cwdQuery = `?cwd=${encodeURIComponent(workspace)}`; + const providerResponse = await request(baseUrl, `/providers${cwdQuery}`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + kind: "openai-compatible", + displayName: "Packaged Continuity Mock", + providerId: "packaged-continuity-mock", + model: "packaged-continuity", + api: "openai-completions", + baseUrl: `${mockProvider.url}/v1`, + capabilities: ["chat", "tool_call"], + input: ["text"], + authHeader: false, + apiKey: "packaged-mock-key", + enabled: true, + }), + }); + if (providerResponse.status !== 201) throw new Error(`provider_create:${providerResponse.text}`); + const providerId = providerResponse.body.provider.id; + const providerCheck = await request(baseUrl, `/providers/${encodeURIComponent(providerId)}/check${cwdQuery}`, { method: "POST" }); + if (providerCheck.status !== 200) throw new Error(`provider_check:${providerCheck.text}`); + const approvalPolicy = await request(baseUrl, `/approval-policy${cwdQuery}`, { + method: "PATCH", + headers: jsonHeaders(), + body: JSON.stringify({ toolDecisions: { write: "allow", bash: "allow" }, allowedWritePaths: ["."] }), + }); + if (approvalPolicy.status !== 200) throw new Error(`approval_policy:${approvalPolicy.text}`); + + const channelResponse = await request(baseUrl, `/channels${cwdQuery}`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + name: "Packaged Continuity", + status: "active", + boundPath: workspace, + defaultProviderPresetId: providerId, + autoReplyMode: "mentions_only", + allowedTools: ["read", "write", "bash"], + }), + }); + if (channelResponse.status !== 201) throw new Error(`channel_create:${channelResponse.text}`); + const channelId = channelResponse.body.channel.id; + for (const teammateId of ["teammate_builtin_engineer", "teammate_builtin_qa"]) { + const teammateResponse = await request(baseUrl, `/teammates/${encodeURIComponent(teammateId)}${cwdQuery}`, { + method: "PATCH", + headers: jsonHeaders(), + body: JSON.stringify({ providerPresetId: providerId, allowedTools: ["read", "write", "bash"], approvalProfile: { mode: "none" } }), + }); + if (teammateResponse.status !== 200) throw new Error(`teammate_patch:${teammateId}:${teammateResponse.text}`); + const membershipResponse = await request(baseUrl, `/teammates/${encodeURIComponent(teammateId)}/channels${cwdQuery}`, { + method: "PUT", + headers: jsonHeaders(), + body: JSON.stringify({ memberships: [{ channelId }] }), + }); + if (membershipResponse.status !== 200) throw new Error(`teammate_membership:${teammateId}:${membershipResponse.text}`); + } + const turnBody = JSON.stringify({ + body: `@Engineer @QA 请协作完成一个最小可验证任务:由 Engineer 创建 ${proofFileName},内容为 ${proofContent};QA 执行精确命令:${exactVerificationCommand} 验证文件内容。Engineer 与 QA 都必须完成并提供真实 evidence 后才报告成功。`, + mentions: [ + { id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }, + { id: "teammate_builtin_qa", label: "QA", kind: "ai-teammate" }, + ], + requestAiReply: true, + idempotencyKey: "packaged-continuity-sse-1", + }); + + const initialStream = await fetch(`${baseUrl}/channels/${encodeURIComponent(channelId)}/turns/stream${cwdQuery}`, { + method: "POST", + headers: { ...jsonHeaders(), accept: "text/event-stream" }, + body: turnBody, + }); + if (!initialStream.ok || !initialStream.body) throw new Error(`sse_initial:${initialStream.status}`); + const reader = initialStream.body.getReader(); + const firstChunk = await reader.read(); + await reader.cancel(); + const firstText = new TextDecoder().decode(firstChunk.value); + if (!firstText.includes("event: ready")) throw new Error("sse_initial_missing_ready"); + check("sse_disconnect", "passed", { status: initialStream.status, firstEvent: "ready" }); + + await sleep(1_500); + const replayStream = await fetch(`${baseUrl}/channels/${encodeURIComponent(channelId)}/turns/stream${cwdQuery}`, { + method: "POST", + headers: { ...jsonHeaders(), accept: "text/event-stream" }, + body: turnBody, + }); + const replayText = await replayStream.text(); + if (!replayStream.ok || !replayText.includes("event: ready")) throw new Error(`sse_reconnect:${replayStream.status}`); + check("sse_reconnect", "passed", { + status: replayStream.status, + replayedReady: replayText.includes("event: ready"), + terminalEvent: replayText.includes("event: blocked") ? "blocked" : replayText.includes("event: done") ? "done" : "unknown", + }); + + await waitForFile(join(workspace, proofFileName), proofContent); + const workflowsResponse = await request(baseUrl, `/channels/${encodeURIComponent(channelId)}/workflows${cwdQuery}`); + if (workflowsResponse.status !== 200 || workflowsResponse.body.workflows?.length !== 1) throw new Error(`workflow_list:${workflowsResponse.text}`); + const workflow = workflowsResponse.body.workflows[0]; + if (workflow.status !== "succeeded" || workflow.metadata?.completionGate?.status !== "verified") { + throw new Error(`workflow_completion_gate:${JSON.stringify({ status: workflow.status, gate: workflow.metadata?.completionGate })}`); + } + const stepsResponse = await request(baseUrl, `/workflows/${encodeURIComponent(workflow.id)}/steps${cwdQuery}`); + const workflowSteps = stepsResponse.body.workflowSteps ?? []; + if (stepsResponse.status !== 200 || workflowSteps.length !== 2 || workflowSteps.some((step) => step.status !== "succeeded" || !step.runId)) { + throw new Error(`workflow_steps:${stepsResponse.text}`); + } + + const itemsResponse = await request(baseUrl, `/channels/${encodeURIComponent(channelId)}/items${cwdQuery}`); + const items = itemsResponse.body.items ?? []; + const userItems = items.filter((item) => item.kind === "user_message"); + if (userItems.length !== 1) throw new Error(`duplicate_user_items:${userItems.length}`); + const sourceTurnId = userItems[0].turnId; + const intentResponse = await request(baseUrl, `/channels/${encodeURIComponent(channelId)}/intent-decisions${cwdQuery}`); + const decision = (intentResponse.body.decisions ?? []).find((candidate) => candidate.sourceItemId === userItems[0].id && candidate.workflowId === workflow.id); + if (!decision || decision.participantManifest?.participants?.length !== 2) throw new Error("participant_manifest_not_persisted"); + if (new Set(decision.participantManifest.participants.map((participant) => participant.participantId)).size !== 2) throw new Error("participant_manifest_not_distinct"); + + const terminalReportsResponse = await request(baseUrl, `/channels/${encodeURIComponent(channelId)}/terminal-reports${cwdQuery}`); + if (terminalReportsResponse.status !== 200 || terminalReportsResponse.body.reports?.length !== 1 || terminalReportsResponse.body.reports[0].outcome.status !== "succeeded") { + throw new Error(`terminal_reports:${terminalReportsResponse.text}`); + } + for (const step of workflowSteps) { + const eventsResponse = await request(baseUrl, `/runs/${encodeURIComponent(step.runId)}/events${cwdQuery}`); + const events = eventsResponse.body.events ?? []; + if (eventsResponse.status !== 200 || !events.some((event) => event.type === "tool_start") || !events.some((event) => event.type === "tool_end")) { + throw new Error(`run_evidence:${step.runId}:${eventsResponse.text}`); + } + } + const terminalReportId = terminalReportsResponse.body.reports[0].id; + const terminalItems = items.filter((item) => item.kind === "assistant_message" && item.idempotencyKey === `terminal-report:${terminalReportId}`); + if (terminalItems.length !== 1) throw new Error(`duplicate_terminal_items:${terminalItems.length}`); + check("lineage_before_restart", "passed", { + itemCount: items.length, + userItemCount: userItems.length, + sourceTurnId, + workflowId: workflow.id, + workflowStepIds: workflowSteps.map((step) => step.id), + runIds: workflowSteps.map((step) => step.runId), + terminalReportCount: terminalReportsResponse.body.reports.length, + hostEvidence: { path: proofFileName, content: proofContent }, + }); + + const nakedTeammateResponse = await request(baseUrl, `/teammates${cwdQuery}`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + roleKey: "engineer", + displayName: "Naked Ready", + description: "Packaged negative-path participant.", + providerPresetId: providerId, + allowedTools: ["write"], + approvalProfile: { mode: "none" }, + status: "active", + }), + }); + if (nakedTeammateResponse.status !== 201) throw new Error(`naked_teammate_create:${nakedTeammateResponse.text}`); + const nakedTeammateId = nakedTeammateResponse.body.teammate.id; + const nakedChannelResponse = await request(baseUrl, `/channels${cwdQuery}`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + name: "Packaged Naked Ready", + status: "active", + boundPath: workspace, + defaultProviderPresetId: providerId, + autoReplyMode: "mentions_only", + allowedTools: ["read", "write"], + }), + }); + if (nakedChannelResponse.status !== 201) throw new Error(`naked_channel_create:${nakedChannelResponse.text}`); + const nakedChannelId = nakedChannelResponse.body.channel.id; + const nakedMembership = await request(baseUrl, `/teammates/${encodeURIComponent(nakedTeammateId)}/channels${cwdQuery}`, { + method: "PUT", + headers: jsonHeaders(), + body: JSON.stringify({ memberships: [{ channelId: nakedChannelId }] }), + }); + if (nakedMembership.status !== 200) throw new Error(`naked_membership:${nakedMembership.text}`); + const nakedTurnResponse = await request(baseUrl, `/channels/${encodeURIComponent(nakedChannelId)}/turns${cwdQuery}`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + body: "@Naked Ready 请创建 naked-ready.txt 完成 naked-ready delegated task;只有真实 evidence 才能报告成功。", + mentions: [{ id: nakedTeammateId, label: "Naked Ready", kind: "ai-teammate" }], + requestAiReply: true, + idempotencyKey: "packaged-continuity-naked-ready-1", + }), + }); + if (nakedTurnResponse.status < 200 || nakedTurnResponse.status >= 300) throw new Error(`naked_turn:${nakedTurnResponse.text}`); + const nakedWorkflow = await waitFor(async () => { + const response = await request(baseUrl, `/channels/${encodeURIComponent(nakedChannelId)}/workflows${cwdQuery}`); + const candidate = response.body.workflows?.[0]; + return candidate?.status === "blocked" ? candidate : undefined; + }, "naked_ready_blocker_timeout"); + const nakedStepsResponse = await request(baseUrl, `/workflows/${encodeURIComponent(nakedWorkflow.id)}/steps${cwdQuery}`); + const nakedSteps = nakedStepsResponse.body.workflowSteps ?? []; + if (nakedSteps.length !== 1 || nakedSteps[0].status !== "blocked") throw new Error(`naked_ready_step:${nakedStepsResponse.text}`); + if (nakedSteps[0].runId) { + const nakedEventsResponse = await request(baseUrl, `/runs/${encodeURIComponent(nakedSteps[0].runId)}/events${cwdQuery}`); + const nakedEvents = nakedEventsResponse.body.events ?? []; + if (nakedEvents.some((event) => event.type === "tool_start" || event.type === "tool_end")) { + throw new Error("naked_ready_published_execution_evidence"); + } + } + check("naked_ready_blocker", "passed", { + workflowId: nakedWorkflow.id, + workflowStatus: nakedWorkflow.status, + stepStatus: nakedSteps[0].status, + blocker: nakedSteps[0].metadata?.delegatedOutcome?.code ?? nakedSteps[0].metadata?.blocker, + toolEvidence: false, + }); + + for (const teammateId of ["teammate_builtin_engineer", "teammate_builtin_qa"]) { + const reset = await request(baseUrl, `/teammates/${encodeURIComponent(teammateId)}${cwdQuery}`, { + method: "PATCH", + headers: jsonHeaders(), + body: JSON.stringify({ modelBindingMode: "workspace_default", providerPresetId: null }), + }); + if (reset.status !== 200) throw new Error(`reset_workspace_binding:${teammateId}:${reset.text}`); + } + const missingChannelResponse = await request(baseUrl, `/channels${cwdQuery}`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + name: "Packaged Missing Provider", + status: "active", + boundPath: workspace, + defaultProviderPresetId: providerId, + autoReplyMode: "mentions_only", + allowedTools: ["read", "write", "bash"], + }), + }); + if (missingChannelResponse.status !== 201) throw new Error(`missing_provider_channel:${missingChannelResponse.text}`); + const missingChannelId = missingChannelResponse.body.channel.id; + for (const teammateId of ["teammate_builtin_engineer", "teammate_builtin_qa"]) { + const membership = await request(baseUrl, `/teammates/${encodeURIComponent(teammateId)}/channels${cwdQuery}`, { + method: "PUT", + headers: jsonHeaders(), + body: JSON.stringify({ memberships: [{ channelId: missingChannelId }] }), + }); + if (membership.status !== 200) throw new Error(`missing_provider_membership:${teammateId}:${membership.text}`); + } + const missingTurnResponse = await request(baseUrl, `/channels/${encodeURIComponent(missingChannelId)}/turns${cwdQuery}`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + body: "@Engineer @QA 请协作完成 missing-provider task:Engineer 创建 missing-provider.txt;QA 执行精确命令 `node -e \"process.exit(0)\"` 验证。", + mentions: [ + { id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }, + { id: "teammate_builtin_qa", label: "QA", kind: "ai-teammate" }, + ], + requestAiReply: true, + idempotencyKey: "packaged-continuity-missing-provider-1", + }), + }); + if (missingTurnResponse.status < 200 || missingTurnResponse.status >= 300) throw new Error(`missing_provider_turn:${missingTurnResponse.text}`); + const missingItemId = missingTurnResponse.body.message?.id ?? missingTurnResponse.body.item?.id; + const missingDraftProposal = await waitFor(async () => { + const suffix = missingItemId ? `&sourceMessageId=${encodeURIComponent(missingItemId)}` : ""; + const response = await request(baseUrl, `/channels/${encodeURIComponent(missingChannelId)}/proposals${cwdQuery}${suffix}`); + const candidate = response.body.proposals?.find((proposal) => proposal.status === "draft"); + return candidate; + }, "missing_provider_draft_timeout"); + const confirmMissing = await request(baseUrl, `/channel-proposals/${encodeURIComponent(missingDraftProposal.id)}/confirm-workflow${cwdQuery}`, { + method: "POST", + headers: jsonHeaders(), + }); + if (confirmMissing.status !== 400 && confirmMissing.status !== 409) throw new Error(`missing_provider_confirm_unexpected:${confirmMissing.status}:${confirmMissing.text}`); + const missingProposal = await waitFor(async () => { + const suffix = missingItemId ? `&sourceMessageId=${encodeURIComponent(missingItemId)}` : ""; + const response = await request(baseUrl, `/channels/${encodeURIComponent(missingChannelId)}/proposals${cwdQuery}${suffix}`); + return response.body.proposals?.find((proposal) => proposal.status === "blocked"); + }, "missing_provider_blocker_timeout"); + const missingWorkflowsResponse = await request(baseUrl, `/channels/${encodeURIComponent(missingChannelId)}/workflows${cwdQuery}`); + if ((missingWorkflowsResponse.body.workflows ?? []).length !== 0) throw new Error(`missing_provider_created_workflow:${missingWorkflowsResponse.text}`); + let missingFileExists = false; + try { + await access(join(workspace, "missing-provider.txt")); + missingFileExists = true; + } catch { + // The provider blocker must happen before any Host side effect. + } + if (missingFileExists) throw new Error("missing_provider_created_side_effect"); + check("missing_provider_blocker", "passed", { + proposalId: missingProposal.id, + status: missingProposal.status, + blocker: missingProposal.blocker, + workflowCount: 0, + runCount: 0, + sideEffectCount: 0, + }); + + await stop(session); + const databasePath = join(workspace, ".ling-stack", "ling-stack.sqlite"); + const auditIds = { channelId, sourceItemId: userItems[0].id, workflowId: workflow.id, runIds: workflowSteps.map((step) => step.runId) }; + const beforeRestartAudit = readSqliteAudit(databasePath, auditIds); + check("sqlite_lineage_before_restart", "passed", beforeRestartAudit); + + session = launch(baseUrl, workspace); + const recoveredHealth = await waitForHealth(baseUrl); + const recoveredItemsResponse = await request(baseUrl, `/channels/${encodeURIComponent(channelId)}/items${cwdQuery}`); + const recoveredItems = recoveredItemsResponse.body.items ?? []; + const recoveredUsers = recoveredItems.filter((item) => item.kind === "user_message"); + if (recoveredUsers.length !== 1 || recoveredUsers[0].turnId !== sourceTurnId) throw new Error("restart_lineage_not_recovered"); + const recoveredReplay = await request(baseUrl, `/channels/${encodeURIComponent(channelId)}/turns/stream${cwdQuery}`, { + method: "POST", + headers: { ...jsonHeaders(), accept: "text/event-stream" }, + body: turnBody, + }); + if (recoveredReplay.status !== 200 || !recoveredReplay.text.includes("event: ready")) throw new Error(`restart_replay:${recoveredReplay.status}`); + const afterRestartAudit = readSqliteAudit(databasePath, auditIds); + if (JSON.stringify(afterRestartAudit) !== JSON.stringify(beforeRestartAudit)) throw new Error("restart_replay_changed_canonical_lineage"); + check("daemon_restart_recovery", "passed", { + readiness: recoveredHealth.readiness, + itemCount: recoveredItems.length, + userItemCount: recoveredUsers.length, + sourceTurnId, + duplicateUserItem: false, + replayIdempotent: true, + sqlite: afterRestartAudit, + }); + + report.status = "passed"; + report.finishedAt = new Date().toISOString(); + await writeFile(join(runRoot, "report.json"), `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify({ type: "result", ...report })); +} catch (error) { + report.status = "failed"; + report.error = { message: error instanceof Error ? error.message : String(error) }; + report.finishedAt = new Date().toISOString(); + await writeFile(join(runRoot, "report.json"), `${JSON.stringify(report, null, 2)}\n`).catch(() => undefined); + console.error(JSON.stringify({ type: "result", ...report })); + process.exitCode = 1; +} finally { + await stop(session); + await mockProvider?.close().catch(() => undefined); + if (!options.keep && !options.output) await rm(runRoot, { recursive: true, force: true }).catch(() => undefined); +} diff --git a/docs/design/channel-conversation-continuity-v2.md b/docs/design/channel-conversation-continuity-v2.md index 638012ea4..3b346b140 100644 --- a/docs/design/channel-conversation-continuity-v2.md +++ b/docs/design/channel-conversation-continuity-v2.md @@ -1,22 +1,37 @@ # Channel Conversation Continuity v2 -Status: semantic completion/recovery is implemented and has historical isolated packaged -macOS acceptance. Delivery was reopened at the Node 22 SQLite transaction-composition -boundary, and the supplemental Attempt-fence, typed-outcome, required-coverage, and -single-terminal-projection hardening is now present in the active WIP. Earlier packaged -evidence proves the behavior contract, but it predates those corrections and is not -release evidence for the current source. Focused S3.5 source regressions are green; the -corrected full Daemon aggregate, repository-wide source gates, and fresh packaged -naked-ready, Engineer + QA fan-in, daemon restart, SSE reconnect, and canonical -SQLite-lineage acceptance remain. Current release verdict: source implementation exists, -but S3.5 verification, packaged behavior, and delivery are not cleared. The current -release can close only after the active S3.5/S4/S5/S6 gates in the execution plan pass on -one post-hardening artifact. +Status: semantic completion/recovery is implemented. The current post-hardening source +matrix is green, and one fresh macOS arm64 artifact has passed configured Engineer + QA +fan-in, real Host write/command evidence, naked-`ready` blocking, missing-provider +blocking, daemon restart, SSE disconnect/reconnect, replay idempotency, and canonical +SQLite-lineage checks. The no-checkpoint/effect-unknown packaged restart windows and +old-schema upgrade/rollback fixture remain before delivery is cleared. Historical +package hashes are not reused as current release evidence. Date: 2026-08-08 -Last verified: 2026-08-18 +Last verified: 2026-08-25 Scope: channel conversation persistence, Thread/Turn/Item topology, context segmentation, context projection, rolling summaries, multi-agent recovery, and Channel Intent Decision v2 +## Current release checkpoint (2026-08-25) + +The clean delivery worktree is based on `origin/main` at `90635c83e6ec6645699a45c114c59905c996ec12`. +Source gates pass: Contracts `46/46`, Core `444/444`, and Daemon `1090/1090`. +The fresh packaged smoke runner is `apps/desktop/tests/run-packaged-continuity-smoke.mjs` +(`pnpm --filter @ling-stack/desktop smoke:continuity:mac`). Its post-build evidence is: + +- ZIP SHA-256: `9ef68c72589d3e59cd1235eaa7fff718ef73f83a73c7c15ec73c91e9e539938a`; +- DMG SHA-256: `bbb3aa92e64e166afb42a2c34a7f2cc52e5fe1681876dd2b69f6783fc78917ef`; +- bundle `codesign --verify --deep --strict`: passed; +- packaged smoke: passed, including launch/health, SSE disconnect/reconnect, real + Engineer write, exact QA command, required-participant fan-in, one TerminalReport and + final Item, naked-`ready` and missing-provider blockers, daemon restart, replay + idempotency, foreign-key check, and integrity check. + +The package uses an Apple Development identity and is not notarized; `spctl` rejection +(`exit 3`) is expected for this local QA artifact and is not a distribution-readiness +claim. The packaged no-checkpoint/effect-unknown restart windows and S5.1 old-schema +migration/rollback remain open in the active execution plan. + Related docs: - [Ling Agent Session Control Plane](ling-agent-session.md) diff --git a/docs/design/channel-execution-completion-implementation-plan.md b/docs/design/channel-execution-completion-implementation-plan.md index 0938a0779..0ed3bc3c3 100644 --- a/docs/design/channel-execution-completion-implementation-plan.md +++ b/docs/design/channel-execution-completion-implementation-plan.md @@ -2,11 +2,11 @@ Status: the existing semantic source implementation and deterministic verification are green. The supplemental Runtime fence, typed delegated-outcome, required-coverage, and -single-terminal-projection hardening is now present in the active WIP and its focused -source regressions are green. S3.5 remains open until the corrected full Daemon aggregate, -repository-wide source matrix, and diff review pass. One pre-hardening signed macOS -artifact exists only as reproduction evidence; packaged behavior, SQLite-lineage -acceptance, and delivery remain open. +single-terminal-projection hardening is present, and the corrected full source matrix +passes. A fresh macOS arm64 artifact also passes configured Engineer + QA fan-in, real +Host evidence, naked-`ready` and missing-provider blockers, restart/reconnect, replay, +and canonical SQLite-lineage smoke. The no-checkpoint/effect-unknown restart windows +and old-schema upgrade/rollback fixture remain open, so delivery is not yet cleared. The existing delegated-outcome, required-participant fan-in, Run/Attempt recovery, and reconnect paths are implemented; the new contract amendments make their missing fencing, negative coverage, and terminal-projection assertions explicit. Earlier focused/package @@ -15,10 +15,11 @@ source: Node 22 Daemon aggregation exposed nested transactions and incomplete ro the publication/workflow composition path. The explicit-ownership fault matrix, full official Node 22 Core and Daemon suites, repository gates, and Channel E2E were green before S3.5. The old artifacts -remain stale; the post-hardening aggregate rerun and fresh packaged macOS acceptance are -still open. +remain stale; current post-hardening source and packaged evidence is recorded below. The +no-checkpoint/effect-unknown restart windows and old-schema upgrade/rollback fixture are +the remaining acceptance gates. Owner: channel intent, workflow orchestration, terminal report, and continuity maintainers -Last verified: 2026-08-18 +Last verified: 2026-08-25 Canonical contract: [Channel Conversation Continuity v2](channel-conversation-continuity-v2.md) Code anchors: `packages/contracts/src/conversation-continuity.ts`, `packages/core/src/storage/sqlite/ling-turn-store.ts`, @@ -146,24 +147,24 @@ not add a second completion/recovery path. | S1: transaction composition [complete] | Keep the explicit standalone/within-transaction control-plane API, TerminalReport/approval/workflow joined wiring, removal of runtime `isTransaction` ownership decisions, and owner rollback helper; prove aggregate rollback plus same-connection reuse | S0 | focused transaction matrix passes 150/150 under official Node 22; a later transaction starts successfully after every injected fault | no savepoint workaround, ambient transaction probing, or compatibility alias | | S2: deterministic matrix [complete] | Run focused Daemon publication/workflow cases, Node 22 full Core and Daemon suites, then API/Web/contracts and repository static gates | S1 | Core 429/429, Daemon 1052/1052 twice, Contracts 41/41, API 49/49, Web 47/47, Channel E2E 116/116, typecheck/build/static/CI package matrix green | no packaged QA on a red source matrix | | S3: fresh package [complete] | Build, sign, hash, and preflight one new macOS artifact in isolated home/workspace/ports; retain old artifacts only as reproduction evidence | S2 | ZIP/DMG identity, bundle signature, resource root, isolated daemon health, workspace default provider, and provider readiness are frozen and green | no production daemon/app termination; no reuse of pre-fix hashes | -| S3.5: contract hardening [verification in progress] | Close the implemented Attempt fencing, typed outcome binding/codes, required-participant coverage, and single terminal projection through the full source matrix and diff review | S3 | old callbacks cannot mutate current Attempt; empty/unknown outcomes fail closed; missing required coverage persists; no second terminal publication; full Daemon aggregate and repository gates are green | fix only failures owned by these contracts; no new authority or second recovery path | -| S4: packaged behavior [pending] | Run missing-default blocker, configured Engineer + QA, naked-ready, restart/approval resume, and SSE reconnect | S3.5 | typed blockers; same Run/new Attempt recovery; all required participants verified before fan-in; one final report/Turn settlement | never parse ready prose or resubmit Turns | -| S5: SQLite lineage [pending] | Audit Thread/Turn/Item, decision, manifest, authority, graph, Run/Attempt, approval, actions, outcomes, Completion Gate, TerminalReport, event, and transition rows | S4 | one source lineage; no half-publication, leaked transaction, duplicate event, or cancellation impersonation; integrity green | data mismatch reopens the earliest truth-layer owner | +| S3.5: contract hardening [complete] | Close the implemented Attempt fencing, typed outcome binding/codes, required-participant coverage, and single terminal projection through the full source matrix and diff review | S3 | old callbacks cannot mutate current Attempt; empty/unknown outcomes fail closed; missing required coverage persists; no second terminal publication; full Daemon aggregate and repository gates are green | fix only failures owned by these contracts; no new authority or second recovery path | +| S4: packaged behavior [partial] | Run missing-default blocker, configured Engineer + QA, naked-ready, restart/approval resume, and SSE reconnect | S3.5 | typed blockers; same Run/new Attempt recovery; all required participants verified before fan-in; one final report/Turn settlement | never parse ready prose or resubmit Turns | +| S5: SQLite lineage [partial] | Audit Thread/Turn/Item, decision, manifest, authority, graph, Run/Attempt, approval, actions, outcomes, Completion Gate, TerminalReport, event, and transition rows | S4 | one source lineage; no half-publication, leaked transaction, duplicate event, or cancellation impersonation; integrity green | data mismatch reopens the earliest truth-layer owner | | S6: delivery [pending] | Run final diff review, PR/CI, merge, verify actual remote `main` merge commit, then clean only exact QA resources | S5 | merged reviewed change and remote-main parity | no merge while packaged or lineage evidence is incomplete | -Current S3.5 checkpoint: Daemon build, Contracts 41/41, Core 432/432, focused -Daemon 181/181, Web 47/47, and the corrected tool-surface cases 5/5 are green. The first -full Daemon aggregate after the hardening passed 1048/1052; all four failures were test -expectations that counted the new `work_unit_claimed` audit event as the target event. -Those assertions now filter by event type and pass focused. The official full Daemon -aggregate has not yet been rerun, so S3.5 remains open and none of these counts are -packaged release evidence. +Current S3.5 checkpoint: Contracts `46/46`, Core `444/444`, and the official full Daemon +aggregate `1090/1090` are green on Node 22. The clean worktree also builds a fresh arm64 +artifact and the packaged continuity smoke passes configured multi-agent fan-in, naked- +`ready` blocking, missing-provider blocking, SSE, restart/replay, Host evidence, +TerminalReport, and SQLite integrity checks. The remaining packaged gap is the +no-checkpoint/effect-unknown restart matrix; S5.1 still needs the old-schema +upgrade/rollback fixture. ### Active release checkpoint and phase authority The table above is the only active release phase namespace. The current checkpoint is -`S3` complete, `S3.5` implemented but still under aggregate verification, `S4` blocked -on that verification, and `S5/S6` pending. The older `A*`, `R*`, `T8*`, +`S3.5` complete, `S4` partially complete on the fresh artifact, `S5` partially complete +for the canonical happy-path lineage, and `S6` pending. The older `A*`, `R*`, `T8*`, `T9*`, `H*`, and Delivery S0-S5 labels below are historical implementation/audit references. They explain why the source reached this checkpoint, but they cannot close a current release gate and their historical `complete/green/verified` wording must not be @@ -173,14 +174,14 @@ The active release DAG is intentionally explicit: ```text S3 pre-hardening artifact - -> S3.5 implemented contract hardening + full source closeout - -> rebuild post-hardening artifact - -> S4.1 naked-ready + missing-provider packaged blockers - -> S4.2 Engineer + QA required fan-in - -> S4.3 daemon restart matrix (checkpoint and no-checkpoint boundaries) - -> S4.4 SSE/RPC reconnect recovery and single publication - -> S5.1 packaged SQLite upgrade/reopen fixture - -> S5.2 canonical Thread/Turn/Item/Run/Attempt/evidence lineage audit + -> S3.5 implemented contract hardening + full source closeout [green] + -> rebuild post-hardening artifact [green] + -> S4.1 naked-ready + missing-provider packaged blockers [green on current artifact] + -> S4.2 Engineer + QA required fan-in [green on current artifact] + -> S4.3 daemon restart matrix (checkpoint and no-checkpoint boundaries) [partial: current artifact replay/restart green] + -> S4.4 SSE/RPC reconnect recovery and single publication [green on current artifact] + -> S5.1 packaged SQLite upgrade/reopen fixture [open] + -> S5.2 canonical Thread/Turn/Item/Run/Attempt/evidence lineage audit [green for current happy-path artifact] -> S6 review -> PR/CI -> merge -> actual remote-main commit verification -> exact cleanup ``` @@ -1725,13 +1726,13 @@ phases: | Task | Status | Priority | Effort (human / Codex) | Concrete change | Depends on | Exit evidence | | --- | --- | --- | --- | --- | --- | --- | -| S3.5 | verification in progress | P1 | ~1-2d / ~2-4h | Retain migration 121, Runtime snapshot v23 and full Attempt fence CAS, closed delegated-outcome binding/codes, required coverage, and one terminal projection; rerun the full Daemon/repository matrix and fix only contract-owned regressions | S3 | focused suites remain green; full Daemon/typecheck/build/static gates pass; diff review finds no fence or publication bypass | -| S4.1 | pending | P1 | packaged runtime | Fresh packaged naked-ready and missing-provider blockers; no ready-text completion, no partial workflow/Run | S3.5 | typed blocker lineage, zero side-effect/workflow counts, one source-Turn settlement | -| S4.2 | pending | P1 | packaged runtime | Engineer + QA required fan-in and TerminalReport required-coverage projection; no callback-count quorum or duplicate terminal card | S4.1 | every required participant has a typed outcome/evidence or negative row; one final report/Item/Turn | -| S4.3 | pending | P1 | packaged runtime | Restart matrix with no checkpoint, safe approval checkpoint, pre/post `tool_start`, and stale old-callback fencing | S4.2 | same Run/new fenced Attempt where safe; typed no-checkpoint/effect-unknown blockers; no duplicate side effect | -| S4.4 | pending | P1 | packaged runtime | SSE/RPC reconnect and snapshot/replay recovery from `lastAppliedSeq` | S4.3 | refresh-before-cursor, no resubmit, unchanged Run/report/Turn counts, one terminal publication | -| S5.1 | pending | P1 | ~2h / ~15m plus fixture | Packaged old-schema upgrade through migrations 119-121, injected rollback, same-connection reopen, foreign-key/integrity checks | S4.4 | no leaked transaction, half-publication, or migration lineage mismatch | -| S5.2 | pending | P1 | ~2h / ~15m | Audit canonical Thread/Turn/Item/decision/manifest/graph/Run/Attempt/action/outcome/gate/report/event lineage | S5.1 | all ids/fingerprints/statuses agree; no cancellation impersonation or duplicate event | +| S3.5 | complete | P1 | verified | Retain migration 121, Runtime snapshot v23 and full Attempt fence CAS, closed delegated-outcome binding/codes, required coverage, and one terminal projection | S3 | Contracts 46/46, Core 444/444, and Daemon 1090/1090 pass; diff review finds no fence or publication bypass | +| S4.1 | complete on current artifact | P1 | verified | Fresh packaged naked-ready and missing-provider blockers; no ready-text completion, no partial workflow/Run | S3.5 | typed blocker lineage, zero side-effect/workflow counts, one source-Turn settlement | +| S4.2 | complete on current artifact | P1 | verified | Engineer + QA required fan-in and TerminalReport required-coverage projection; no callback-count quorum or duplicate terminal card | S4.1 | every required participant has a typed outcome/evidence or negative row; one final report/Item/Turn | +| S4.3 | partial | P1 | packaged runtime | Restart matrix with no checkpoint, safe approval checkpoint, pre/post `tool_start`, and stale old-callback fencing | S4.2 | ordinary daemon restart/replay is green; no-checkpoint/effect-unknown windows remain | +| S4.4 | complete on current artifact | P1 | verified | SSE/RPC reconnect and snapshot/replay recovery from `lastAppliedSeq` | S4.3 | refresh-before-cursor, no resubmit, unchanged Run/report/Turn counts, one terminal publication | +| S5.1 | open | P1 | ~2h / ~15m plus fixture | Packaged old-schema upgrade through migrations 119-121, injected rollback, same-connection reopen, foreign-key/integrity checks | S4.4 | no leaked transaction, half-publication, or migration lineage mismatch | +| S5.2 | complete for current happy path | P1 | verified | Audit canonical Thread/Turn/Item/decision/manifest/graph/Run/Attempt/action/outcome/gate/report/event lineage | S5.1 | all ids/fingerprints/statuses agree; no cancellation impersonation or duplicate event | | S6 | pending | P1 | ~2h / ~15m plus CI | Review and precisely stage only this slice, create PR, wait for CI, merge, verify actual remote-main merge commit, and clean exact QA resources | S5.2 | PR merged, remote `main` contains the verified commit, protected WIP and production Ling remain untouched | The table below is historical Continuity v2 work retained for traceability. Its completed @@ -2369,12 +2370,12 @@ would create more conflicts than useful concurrency across the current large WIP | H4 | T8d fresh packaged macOS scenarios [complete] | isolated artifact/runtime, GUI, SSE, SQLite | artifact, restart/fan-in/reconnect, and lineage green | | I | T9.1-T9.2 explicit transaction ownership, wiring, rollback, and fault injection [complete] | Core SQLite control-plane/publication/workflow/recovery stores | H4 historical evidence plus Node 22 reproduction | | J | T9.3-T9.4 focused and broad deterministic verification [complete] | Core, Daemon, API, Web, repository gates | I green | -| K0 | S3.5 Runtime fence/outcome/coverage/terminal-projection aggregate verification [in progress] | Contracts, Core Runtime/SQLite, daemon finalizer/gate, TerminalReport/Web | J green and hardening implemented | -| K1 | S4.1 naked-ready and missing-provider packaged blockers [pending] | isolated artifact/runtime, GUI, daemon finalizer | K0 green | -| K2 | S4.2 Engineer + QA fan-in and required coverage [pending] | workflow gate, TerminalReport, GUI, SQLite | K1 green | -| K3 | S4.3 daemon restart checkpoint/no-checkpoint and Attempt fencing [pending] | Runtime Control Plane, approval resume, GUI, SQLite | K2 green | -| K4 | S4.4 SSE/RPC reconnect and one terminal publication [pending] | replay transport, Web, GUI, SQLite | K3 green | -| K5 | S5.1-S5.2 packaged SQLite upgrade/reopen and canonical lineage [pending] | Core migrations/stores, packaged data root, audit | K4 green | +| K0 | S3.5 Runtime fence/outcome/coverage/terminal-projection aggregate verification [complete] | Contracts, Core Runtime/SQLite, daemon finalizer/gate, TerminalReport/Web | J green and hardening implemented | +| K1 | S4.1 naked-ready and missing-provider packaged blockers [complete on current artifact] | isolated artifact/runtime, GUI, daemon finalizer | K0 green | +| K2 | S4.2 Engineer + QA fan-in and required coverage [complete on current artifact] | workflow gate, TerminalReport, GUI, SQLite | K1 green | +| K3 | S4.3 daemon restart checkpoint/no-checkpoint and Attempt fencing [partial] | Runtime Control Plane, approval resume, GUI, SQLite | K2 green | +| K4 | S4.4 SSE/RPC reconnect and one terminal publication [complete on current artifact] | replay transport, Web, SQLite | K3 green | +| K5 | S5.1-S5.2 packaged SQLite upgrade/reopen and canonical lineage [partial] | Core migrations/stores, packaged data root, audit | K4 green | | K6 | S6 review/PR/CI/merge/remote-main/cleanup [pending] | repository delivery and exact QA resources | K5 green | Lanes A-C are complete, D is green, T8b captured the packaged failure, E/F/G/G2 are @@ -2406,15 +2407,18 @@ not optional hardening. ACTIVE S4/S5 ACCEPTANCE MATRIX fresh post-S3.5 packaged artifact - ├── S4.1 [GAP] naked `ready` -> typed blocker; no workflow/Run/side effect - ├── S4.2 [GAP] Engineer + QA -> both required coverage rows before verified fan-in - ├── S4.3 [GAP] no checkpoint -> typed interruption; safe checkpoint -> same Run/new fenced Attempt + ├── S4.1 [PASS] naked `ready` -> typed blocker; missing provider -> zero workflow/Run/side effect + ├── S4.2 [PASS] Engineer + QA -> both required coverage rows before verified fan-in + ├── S4.3 [PARTIAL] no checkpoint -> typed interruption; safe checkpoint -> same Run/new fenced Attempt │ ├── before `tool_start` -> one retry │ ├── `tool_start` without `tool_end` -> effect-unknown, never retry │ └── after `tool_end` -> finalize existing evidence once - ├── S4.4 [GAP] SSE/RPC reconnect -> lastAppliedSeq refresh/replay, no resubmit/publication duplicate - ├── S5.1 [GAP] old SQLite fixture -> migrations 119-121 -> rollback/reopen/integrity checks - └── S5.2 [GAP] canonical Thread/Turn/Item/Run/Attempt/evidence lineage matches final report + ├── S4.4 [PASS] SSE/RPC reconnect -> lastAppliedSeq refresh/replay, no resubmit/publication duplicate + ├── S5.1 [OPEN] old SQLite fixture -> migrations 119-121 -> rollback/reopen/integrity checks + └── S5.2 [PASS*] canonical Thread/Turn/Item/Run/Attempt/evidence lineage matches final report + +*S5.2 is green for the current happy-path artifact; it does not close S5.1's upgrade and +rollback requirement.* ACCEPTANCE / AVAILABILITY accepted Item + canonical mentions From c0fe025588fe4a1ea6c01ced5f471c1a23fd2eca Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Tue, 25 Aug 2026 19:08:51 +0800 Subject: [PATCH 2/8] feat: bind runs to context view lineage --- .../channel-conversation-continuity-v2.md | 23 +++++ .../src/conversation-continuity-validation.ts | 6 +- .../contracts/src/conversation-continuity.ts | 4 + packages/contracts/src/runtime.ts | 2 + packages/core/src/run-types.ts | 5 + packages/core/src/storage/sqlite-run-store.ts | 14 ++- .../run-context-view-lineage-migration.ts | 35 +++++++ .../core/src/storage/sqlite/run-mappers.ts | 2 + .../src/storage/sqlite/run-session-store.ts | 10 +- .../core/src/storage/sqlite/select-sql.ts | 2 + packages/core/src/workspace-types.ts | 1 + .../test/channel-intent-v2-migration.test.mjs | 9 +- .../core/test/channel-runtime-store.test.mjs | 4 + .../channel-reply-continuation-context.ts | 3 + .../src/features/channels/context-packs.ts | 39 +++++++- .../channels/context-processing/condenser.ts | 93 ++++++++++++++++++- .../conversation-context-adapter.ts | 2 + .../channels/context-processing/inspect.ts | 2 + .../context-processing/private-chat.ts | 1 + .../channels/context-processing/proposals.ts | 2 + .../channels/context-processing/types.ts | 8 +- .../channels/context-processing/views.ts | 35 ++++++- .../context-processing/workflow-steps.ts | 10 ++ .../daemon/src/features/channels/workflows.ts | 3 + .../daemon/src/features/runs/orchestrator.ts | 2 + packages/daemon/src/features/runs/queue.ts | 2 + .../run-intervention-continuation-request.ts | 8 ++ .../daemon/src/features/runs/thread-routes.ts | 10 +- .../test/channel-context-processing.test.mjs | 81 +++++++++++++++- 29 files changed, 397 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/storage/sqlite/run-context-view-lineage-migration.ts diff --git a/docs/design/channel-conversation-continuity-v2.md b/docs/design/channel-conversation-continuity-v2.md index 3b346b140..d0ae397a1 100644 --- a/docs/design/channel-conversation-continuity-v2.md +++ b/docs/design/channel-conversation-continuity-v2.md @@ -12,6 +12,29 @@ Last verified: 2026-08-25 Scope: channel conversation persistence, Thread/Turn/Item topology, context segmentation, context projection, rolling summaries, multi-agent recovery, and Channel Intent Decision v2 +## Harness Integration Boundary + +The Ling product model and the execution harness are deliberately adjacent, not +interchangeable. Adapters may translate between them, but neither side may silently +become the other's authority: + +1. **Timeline authority.** The Ling timeline is the only conversation authority. A + harness transcript is execution evidence and a message projection only; it must not + be replayed as canonical conversation history. +2. **Immutable run input.** `ChannelContextView` is the immutable projection captured + before a Run is created. It is not mutable Channel state, and later Channel changes + must not rewrite the Run's input lineage. +3. **Product versus execution semantics.** `LingThread` and `LingTurn` describe product + conversation semantics. Codex Thread and Turn describe harness execution semantics. + The adapter preserves both identities and does not merge the two models. +4. **Approval protocol bridge.** Ling approval is an asynchronous, durable, + event-driven product event. Harness approval is a synchronous, blocking RPC. The + approval adapter bridges the wait and wake-up semantics while keeping Ling's event + as the source of truth. +5. **Durable approval ownership.** Durable approvals are managed by Ling. The harness + only implements the approval adapter and reports request/decision evidence; it does + not own approval persistence or lifecycle authority. + ## Current release checkpoint (2026-08-25) The clean delivery worktree is based on `origin/main` at `90635c83e6ec6645699a45c114c59905c996ec12`. diff --git a/packages/contracts/src/conversation-continuity-validation.ts b/packages/contracts/src/conversation-continuity-validation.ts index 6051a1f1c..eb1aebd44 100644 --- a/packages/contracts/src/conversation-continuity-validation.ts +++ b/packages/contracts/src/conversation-continuity-validation.ts @@ -175,6 +175,8 @@ const PROJECTION_KEYS = new Set([ "sourceRefs", "policyVersion", "visibilityFingerprint", + "contextViewId", + "contextViewCreatedAt", ]); const INTENT_KEYS = new Set([ @@ -411,7 +413,9 @@ export function isConversationContextProjectionMetadata( && isTextArray(value.summaryIds) && isArrayOf(value.sourceRefs, isConversationDomainRef) && isText(value.policyVersion) - && isText(value.visibilityFingerprint); + && isText(value.visibilityFingerprint) + && isOptionalText(value.contextViewId) + && isOptionalCanonicalTimestamp(value.contextViewCreatedAt); } export function isChannelIntentDecisionV2(value: unknown): value is ChannelIntentDecisionV2 { diff --git a/packages/contracts/src/conversation-continuity.ts b/packages/contracts/src/conversation-continuity.ts index 2c552042d..cf4563058 100644 --- a/packages/contracts/src/conversation-continuity.ts +++ b/packages/contracts/src/conversation-continuity.ts @@ -354,6 +354,10 @@ export interface ConversationContextProjectionMetadata { readonly sourceRefs: readonly ConversationDomainRef[]; readonly policyVersion: string; readonly visibilityFingerprint: string; + /** Immutable ChannelContextView identity used to materialize a run. */ + readonly contextViewId?: string; + /** Creation timestamp for the immutable ChannelContextView projection. */ + readonly contextViewCreatedAt?: string; } export const channelIntentDecisionV2Actions = [ diff --git a/packages/contracts/src/runtime.ts b/packages/contracts/src/runtime.ts index 4338c4c06..357fdb2aa 100644 --- a/packages/contracts/src/runtime.ts +++ b/packages/contracts/src/runtime.ts @@ -487,6 +487,8 @@ export interface RunSession { readonly parentRunId?: string; readonly interventionId?: string; readonly continuationReason?: RunContinuationReason; + readonly contextViewId?: string; + readonly contextViewCreatedAt?: string; readonly summaryPath?: string; readonly errorMessage?: string; readonly createdAt: string; diff --git a/packages/core/src/run-types.ts b/packages/core/src/run-types.ts index 71544c25b..6fa086601 100644 --- a/packages/core/src/run-types.ts +++ b/packages/core/src/run-types.ts @@ -1118,6 +1118,9 @@ export interface RunSession { readonly parentRunId?: string; readonly interventionId?: string; readonly continuationReason?: RunContinuationReason; + /** Immutable context projection lineage captured before execution started. */ + readonly contextViewId?: string; + readonly contextViewCreatedAt?: string; readonly summaryPath?: string; readonly errorMessage?: string; readonly createdAt: string; @@ -1156,6 +1159,8 @@ export interface CreateRunInput { readonly parentRunId?: string; readonly interventionId?: string; readonly continuationReason?: RunContinuationReason; + readonly contextViewId?: string; + readonly contextViewCreatedAt?: string; readonly createdAt: string; } diff --git a/packages/core/src/storage/sqlite-run-store.ts b/packages/core/src/storage/sqlite-run-store.ts index 3b2022eba..9641c16da 100644 --- a/packages/core/src/storage/sqlite-run-store.ts +++ b/packages/core/src/storage/sqlite-run-store.ts @@ -518,6 +518,7 @@ import { applyApprovalResumeBasisMigration } from "./sqlite/approval-resume-basi import { applyChannelParticipantAuthorityMigration } from "./sqlite/channel-participant-authority-migration.js"; import { applyChannelProposalBlockerMigration } from "./sqlite/channel-proposal-blocker-migration.js"; import { applyRuntimeAttemptFenceMigration } from "./sqlite/runtime-attempt-fence-migration.js"; +import { applyRunContextViewLineageMigration } from "./sqlite/run-context-view-lineage-migration.js"; import { isJsonRecord, isRecord, @@ -1247,15 +1248,22 @@ export class SqliteRunStore implements RunStore { hasColumn: (tableName, columnName) => this.hasColumn(tableName, columnName), recordMigration: (version, name) => this.recordMigration(version, name), }); - // Runtime fencing is the final migration in the current clean-break line. - // It must run after the base runtime tables and after the Continuity/approval - // migrations so every existing Attempt projection can be backfilled atomically. + // Runtime fencing runs after the base runtime tables and after the + // Continuity/approval migrations so every existing Attempt projection can be + // backfilled atomically. Run context lineage is applied immediately after it + // so the run-session query shape is complete before the store is exposed. applyRuntimeAttemptFenceMigration({ database: this.database, hasMigration: (version) => this.hasMigration(version), hasColumn: (tableName, columnName) => this.hasColumn(tableName, columnName), recordMigration: (version, name) => this.recordMigration(version, name), }); + applyRunContextViewLineageMigration({ + database: this.database, + hasMigration: (version) => this.hasMigration(version), + hasColumn: (tableName, columnName) => this.hasColumn(tableName, columnName), + recordMigration: (version, name) => this.recordMigration(version, name), + }); }); } catch (error) { this.database.close(); diff --git a/packages/core/src/storage/sqlite/run-context-view-lineage-migration.ts b/packages/core/src/storage/sqlite/run-context-view-lineage-migration.ts new file mode 100644 index 000000000..94b4c7a34 --- /dev/null +++ b/packages/core/src/storage/sqlite/run-context-view-lineage-migration.ts @@ -0,0 +1,35 @@ +import type { DatabaseSync } from "node:sqlite"; + +export const RUN_CONTEXT_VIEW_LINEAGE_MIGRATION_VERSION = 122; +export const RUN_CONTEXT_VIEW_LINEAGE_MIGRATION_NAME = "add_run_context_view_lineage"; + +export interface RunContextViewLineageMigrationInput { + readonly database: DatabaseSync; + readonly hasMigration: (version: number) => boolean; + readonly hasColumn: (tableName: string, columnName: string) => boolean; + readonly recordMigration: (version: number, name: string) => void; +} + +/** Adds nullable lineage columns so pre-lineage runs remain readable. */ +export function applyRunContextViewLineageMigration(input: RunContextViewLineageMigrationInput): boolean { + const needsColumns = !input.hasColumn("run_sessions", "context_view_id") + || !input.hasColumn("run_sessions", "context_view_created_at"); + if (!needsColumns && input.hasMigration(RUN_CONTEXT_VIEW_LINEAGE_MIGRATION_VERSION)) { + return false; + } + + if (!input.hasColumn("run_sessions", "context_view_id")) { + input.database.exec("ALTER TABLE run_sessions ADD COLUMN context_view_id TEXT;"); + } + if (!input.hasColumn("run_sessions", "context_view_created_at")) { + input.database.exec("ALTER TABLE run_sessions ADD COLUMN context_view_created_at TEXT;"); + } + input.database.exec(` + CREATE INDEX IF NOT EXISTS idx_run_sessions_context_view + ON run_sessions(context_view_id, context_view_created_at); + `); + if (!input.hasMigration(RUN_CONTEXT_VIEW_LINEAGE_MIGRATION_VERSION)) { + input.recordMigration(RUN_CONTEXT_VIEW_LINEAGE_MIGRATION_VERSION, RUN_CONTEXT_VIEW_LINEAGE_MIGRATION_NAME); + } + return true; +} diff --git a/packages/core/src/storage/sqlite/run-mappers.ts b/packages/core/src/storage/sqlite/run-mappers.ts index 36965eebb..4455d85de 100644 --- a/packages/core/src/storage/sqlite/run-mappers.ts +++ b/packages/core/src/storage/sqlite/run-mappers.ts @@ -224,6 +224,8 @@ export function mapRunSession(row: Record): RunSession { parentRunId: readNullableString(row, "parent_run_id"), interventionId: readNullableString(row, "intervention_id"), continuationReason: readNullableRunContinuationReason(row, "continuation_reason"), + contextViewId: readNullableString(row, "context_view_id"), + contextViewCreatedAt: readNullableString(row, "context_view_created_at"), summaryPath: readNullableString(row, "summary_path"), errorMessage: readNullableString(row, "error_message"), createdAt: readString(row, "created_at"), diff --git a/packages/core/src/storage/sqlite/run-session-store.ts b/packages/core/src/storage/sqlite/run-session-store.ts index 2b9cedb8f..bda290c4f 100644 --- a/packages/core/src/storage/sqlite/run-session-store.ts +++ b/packages/core/src/storage/sqlite/run-session-store.ts @@ -75,13 +75,19 @@ export class SqliteRunSessionStore { parent_run_id, intervention_id, continuation_reason, + context_view_id, + context_view_created_at, summary_path, error_message, created_at, started_at, ended_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ? + ) `) .run( input.id, @@ -104,6 +110,8 @@ export class SqliteRunSessionStore { input.parentRunId ?? null, input.interventionId ?? null, input.continuationReason ?? null, + input.contextViewId ?? null, + input.contextViewCreatedAt ?? null, null, null, input.createdAt, diff --git a/packages/core/src/storage/sqlite/select-sql.ts b/packages/core/src/storage/sqlite/select-sql.ts index 69d243b3a..236305cd5 100644 --- a/packages/core/src/storage/sqlite/select-sql.ts +++ b/packages/core/src/storage/sqlite/select-sql.ts @@ -152,6 +152,8 @@ export function runSessionSelectSql(whereClause: string): string { parent_run_id, intervention_id, continuation_reason, + context_view_id, + context_view_created_at, summary_path, error_message, created_at, diff --git a/packages/core/src/workspace-types.ts b/packages/core/src/workspace-types.ts index 753a75871..9483169f9 100644 --- a/packages/core/src/workspace-types.ts +++ b/packages/core/src/workspace-types.ts @@ -2261,6 +2261,7 @@ export interface WorkflowStepContextPackSnapshot { readonly id: string; readonly role: string; readonly priority: string; + readonly compactionSurvival?: string; readonly estimatedTokens?: number; readonly sourceRefIds?: readonly string[]; readonly metadata?: Record; diff --git a/packages/core/test/channel-intent-v2-migration.test.mjs b/packages/core/test/channel-intent-v2-migration.test.mjs index c7563da7d..2192e675d 100644 --- a/packages/core/test/channel-intent-v2-migration.test.mjs +++ b/packages/core/test/channel-intent-v2-migration.test.mjs @@ -21,9 +21,10 @@ import { APPROVAL_RESUME_BASIS_MIGRATION_VERSION } from "../dist/storage/sqlite/ import { CHANNEL_PARTICIPANT_AUTHORITY_MIGRATION_VERSION } from "../dist/storage/sqlite/channel-participant-authority-migration.js"; import { CHANNEL_PROPOSAL_BLOCKER_MIGRATION_VERSION } from "../dist/storage/sqlite/channel-proposal-blocker-migration.js"; import { RUNTIME_ATTEMPT_FENCE_MIGRATION_VERSION } from "../dist/storage/sqlite/runtime-attempt-fence-migration.js"; +import { RUN_CONTEXT_VIEW_LINEAGE_MIGRATION_VERSION } from "../dist/storage/sqlite/run-context-view-lineage-migration.js"; import { createRequiredParticipantManifestV1, createWorkflowParticipantAuthorityV1, workflowGraphFingerprintV1 } from "../../contracts/dist/index.js"; -test("fresh database applies migrations 113 through 121 with reopen and integrity invariants", async (t) => { +test("fresh database applies migrations 113 through 122 with reopen and integrity invariants", async (t) => { const cwd = await mkdtemp(join(tmpdir(), "ling-channel-completion-fresh-migrations-")); t.after(() => rm(cwd, { recursive: true, force: true })); createSqliteRunStore(cwd).close(); @@ -34,7 +35,7 @@ test("fresh database applies migrations 113 through 121 with reopen and integrit SELECT version FROM schema_migrations WHERE version BETWEEN ? AND ? ORDER BY version ASC - `).all(CHANNEL_INTENT_EXECUTION_PLAN_MIGRATION_VERSION, RUNTIME_ATTEMPT_FENCE_MIGRATION_VERSION) + `).all(CHANNEL_INTENT_EXECUTION_PLAN_MIGRATION_VERSION, RUN_CONTEXT_VIEW_LINEAGE_MIGRATION_VERSION) .map((row) => row.version); assert.deepEqual(versions, [ CHANNEL_INTENT_EXECUTION_PLAN_MIGRATION_VERSION, @@ -46,7 +47,11 @@ test("fresh database applies migrations 113 through 121 with reopen and integrit CHANNEL_PARTICIPANT_AUTHORITY_MIGRATION_VERSION, CHANNEL_PROPOSAL_BLOCKER_MIGRATION_VERSION, RUNTIME_ATTEMPT_FENCE_MIGRATION_VERSION, + RUN_CONTEXT_VIEW_LINEAGE_MIGRATION_VERSION, ]); + const runColumns = new Set(database.prepare("PRAGMA table_info(run_sessions)").all().map((row) => row.name)); + assert.equal(runColumns.has("context_view_id"), true); + assert.equal(runColumns.has("context_view_created_at"), true); for (const table of ["work_unit_attempts", "runtime_events", "runtime_heartbeats", "runtime_semaphore_slots", "resource_leases"]) { assert.ok(database.prepare(`SELECT 1 FROM pragma_table_info(?) WHERE name = 'fencing_token'`).get(table)); } diff --git a/packages/core/test/channel-runtime-store.test.mjs b/packages/core/test/channel-runtime-store.test.mjs index 1b8a6505f..59a5bf512 100644 --- a/packages/core/test/channel-runtime-store.test.mjs +++ b/packages/core/test/channel-runtime-store.test.mjs @@ -1236,8 +1236,12 @@ test("sqlite work units link run sessions and enforce CAS transitions", async () taskSnapshot: task, providerSnapshot: { provider: "test-provider", model: "test-model" }, requestSnapshot: { cwd }, + contextViewId: "context_view_runtime", + contextViewCreatedAt: "2026-06-04T00:00:00.000Z", createdAt: now, }); + assert.equal(store.getRun("run_linked_runtime")?.contextViewId, "context_view_runtime"); + assert.equal(store.getRun("run_linked_runtime")?.contextViewCreatedAt, "2026-06-04T00:00:00.000Z"); const linked = store.linkRunToWorkUnit("run_linked_runtime", workUnit.id, "2026-06-04T00:00:01.000Z"); assert.equal(linked?.workUnitId, workUnit.id); assert.equal(store.getWorkUnit(workUnit.id)?.linkedRunId, "run_linked_runtime"); diff --git a/packages/daemon/src/features/channels/channel-reply-continuation-context.ts b/packages/daemon/src/features/channels/channel-reply-continuation-context.ts index 4df432434..fbf3f332f 100644 --- a/packages/daemon/src/features/channels/channel-reply-continuation-context.ts +++ b/packages/daemon/src/features/channels/channel-reply-continuation-context.ts @@ -1,4 +1,5 @@ import type { ChannelMessage, RunIntervention, RunSession, RunStore } from "@ling-stack/core"; +import type { ConversationContextProjectionMetadata } from "@ling-stack/contracts"; import { buildChannelReplyContextPack, renderContextPack } from "./context-packs.js"; import { ensureChannelReplyThread } from "./channel-reply-execution.js"; import { resolveChannelReplyExecutionTarget } from "./channel-reply-execution-target.js"; @@ -14,6 +15,7 @@ export interface ChannelReplyContinuationContext { readonly providerPresetId: string; readonly threadId: string; readonly turnId: string; + readonly contextProjection?: ConversationContextProjectionMetadata; readonly runnerId?: string; } @@ -96,6 +98,7 @@ export function buildChannelReplyContinuationContext(input: { providerPresetId: target.providerPresetId, threadId, turnId: sourceItem.turnId, + contextProjection: contextPack.contextProjection, ...(target.teammate?.runnerId ? { runnerId: target.teammate.runnerId } : {}), }; } diff --git a/packages/daemon/src/features/channels/context-packs.ts b/packages/daemon/src/features/channels/context-packs.ts index da50bd731..d1b497fe8 100644 --- a/packages/daemon/src/features/channels/context-packs.ts +++ b/packages/daemon/src/features/channels/context-packs.ts @@ -44,6 +44,7 @@ import { } from "./context-processing/workflow-steps.js"; import type { ChannelContextSection, + ChannelContextSectionCompactionSurvival, ChannelContextSource, ChannelContextSourceRef, ChannelContextView, @@ -105,6 +106,7 @@ export interface ContextSection { readonly id: string; readonly role: ContextSectionRole; readonly priority: ContextSectionPriority; + readonly compactionSurvival?: ChannelContextSectionCompactionSurvival; readonly text: string; readonly sourceRefIds?: readonly string[]; readonly estimatedTokens?: number; @@ -233,9 +235,11 @@ export function buildContextPack(input: BuildContextPackInput): ContextPack { const budget = resolveProviderContextBudget(input.providerPreset, input.kind); const sections = input.sections.map((section) => ({ ...section, + compactionSurvival: section.compactionSurvival ?? defaultCompactionSurvivalForRole(section.role), estimatedTokens: section.estimatedTokens ?? estimateTextTokens(section.text), })); const droppedSectionIds: string[] = []; + const compactionIds = [...(input.compactionIds ?? [])]; const keptSections = [...sections]; let estimatedInputTokens = sumEstimatedTokens(keptSections); @@ -246,6 +250,7 @@ export function buildContextPack(input: BuildContextPackInput): ContextPack { } const [dropped] = keptSections.splice(dropIndex, 1); droppedSectionIds.push(dropped.id); + compactionIds.push(stableSectionCompactionId("drop", dropped)); estimatedInputTokens = sumEstimatedTokens(keptSections); } @@ -257,7 +262,7 @@ export function buildContextPack(input: BuildContextPackInput): ContextPack { sections: keptSections, sourceRefs: input.sourceRefs ?? [], droppedSectionIds, - compactionIds: input.compactionIds ?? [], + compactionIds: [...new Set(compactionIds)], estimatedInputTokens, overBudget: estimatedInputTokens > budget.inputBudgetTokens, contextProjection: input.contextProjection, @@ -385,6 +390,7 @@ function contextSectionToChannelContextSection(section: ContextSection): Channel id: section.id, role: section.role, priority: section.priority, + compactionSurvival: section.compactionSurvival ?? defaultCompactionSurvivalForRole(section.role), title: section.id, text: section.text, sourceRefIds: section.sourceRefIds ?? [], @@ -400,6 +406,7 @@ function channelContextSectionToContextSection(section: ChannelContextSection): id: section.id, role: section.role, priority: section.priority, + compactionSurvival: section.compactionSurvival, text: section.text, sourceRefIds: section.sourceRefIds, estimatedTokens: section.estimatedTokens, @@ -862,12 +869,34 @@ function contextBudgetModeForCapabilities(capabilities: ProviderContextCapabilit } function findDropSectionIndex(sections: readonly ContextSection[]): number { - for (const priority of ["low", "medium", "high"] as const) { - for (let index = sections.length - 1; index >= 0; index -= 1) { - if (sections[index].priority === priority) { - return index; + for (const survival of ["discard", "summarize"] as const) { + for (const priority of ["low", "medium", "high", "must_keep"] as const) { + for (let index = sections.length - 1; index >= 0; index -= 1) { + if (sections[index].compactionSurvival === survival && sections[index].priority === priority) { + return index; + } } } } return -1; } + +function defaultCompactionSurvivalForRole(role: ContextSectionRole): ChannelContextSectionCompactionSurvival { + switch (role) { + case "system": + case "instruction": + return "rebuild"; + case "user": + case "memory": + case "evidence": + return "summarize"; + } +} + +function stableSectionCompactionId(kind: "text" | "drop", section: ContextSection): string { + let hash = 0; + for (const character of `${kind}:${section.id}:${section.text.length}`) { + hash = ((hash << 5) - hash + character.charCodeAt(0)) | 0; + } + return `context_condenser_section_${kind}_${Math.abs(hash).toString(36)}`; +} diff --git a/packages/daemon/src/features/channels/context-processing/condenser.ts b/packages/daemon/src/features/channels/context-processing/condenser.ts index 41ee43c10..8f67be7a0 100644 --- a/packages/daemon/src/features/channels/context-processing/condenser.ts +++ b/packages/daemon/src/features/channels/context-processing/condenser.ts @@ -1,6 +1,7 @@ import { redactEvidenceText } from "./evidence.js"; import type { ChannelContextDroppedSource, + ChannelContextSection, ChannelContextSource, ChannelContextSourceKind, } from "./types.js"; @@ -19,6 +20,18 @@ export interface CondenseChannelContextSourcesResult { readonly condensedSourceIds: readonly string[]; } +export interface CondenseChannelContextSectionsInput { + readonly sections: readonly ChannelContextSection[]; + readonly maxSectionTextChars?: number; +} + +export interface CondenseChannelContextSectionsResult { + readonly sections: readonly ChannelContextSection[]; + readonly compactionIds: readonly string[]; + readonly condensedSectionIds: readonly string[]; + readonly discardedSectionIds: readonly string[]; +} + const PROTECTED_SOURCE_KINDS = new Set([ "turn", "channel_state", @@ -31,6 +44,58 @@ const DEFAULT_MAX_SOURCE_TEXT_CHARS = 2_400; const MIN_SOURCE_TEXT_CHARS = 600; const DEFAULT_MAX_SOURCES = 24; +const DEFAULT_MAX_SECTION_TEXT_CHARS = 2_400; +const MIN_SECTION_TEXT_CHARS = 600; + +/** + * Applies the section lifecycle dimension during an explicit compaction pass. + * Priority remains a separate concern owned by the legacy ContextPack budgeter. + */ +export function condenseChannelContextSections( + input: CondenseChannelContextSectionsInput, +): CondenseChannelContextSectionsResult { + const maxTextChars = normalizeMaxSectionTextChars(input.maxSectionTextChars); + const compactionIds: string[] = []; + const condensedSectionIds: string[] = []; + const discardedSectionIds: string[] = []; + const sections: ChannelContextSection[] = []; + + for (const section of input.sections) { + if (section.compactionSurvival === "discard") { + discardedSectionIds.push(section.id); + compactionIds.push(stableCondenserId("section_drop", section.id, "discard")); + continue; + } + if ( + section.compactionSurvival === "summarize" + && section.text.length > maxTextChars + ) { + const text = renderCondensedSectionText(section, maxTextChars); + condensedSectionIds.push(section.id); + compactionIds.push(stableCondenserId("section_text", section.id, `${section.text.length}:${maxTextChars}`)); + sections.push({ + ...section, + text, + metadata: { + ...section.metadata, + condensed: true, + originalTextChars: section.text.length, + condensedTextChars: text.length, + }, + }); + continue; + } + sections.push(section); + } + + return { + sections, + compactionIds: uniqueStrings(compactionIds), + condensedSectionIds, + discardedSectionIds, + }; +} + export function condenseChannelContextSources( input: CondenseChannelContextSourcesInput, ): CondenseChannelContextSourcesResult { @@ -91,6 +156,32 @@ function normalizeMaxSourceTextChars(input: CondenseChannelContextSourcesInput): return undefined; } +function normalizeMaxSectionTextChars(value: number | undefined): number { + if (value === undefined) { + return DEFAULT_MAX_SECTION_TEXT_CHARS; + } + return Math.max(MIN_SECTION_TEXT_CHARS, Math.floor(value)); +} + +function renderCondensedSectionText(section: ChannelContextSection, maxTextChars: number): string { + const targetBodyChars = Math.max(160, maxTextChars - 180); + const headChars = Math.ceil(targetBodyChars * 0.58); + const tailChars = Math.max(120, targetBodyChars - headChars); + const redacted = redactEvidenceText(section.text).trim(); + const head = redacted.slice(0, headChars).trim(); + const tail = redacted.slice(Math.max(0, redacted.length - tailChars)).trim(); + return [ + "Context section condensed for prompt budget.", + `sectionId=${section.id}`, + `title=${section.title}`, + `originalTextChars=${section.text.length}`, + "boundedPreview:", + head, + tail && tail !== head ? "[middle omitted]" : undefined, + tail && tail !== head ? tail : undefined, + ].filter((line): line is string => line !== undefined && line.length > 0).join("\n"); +} + function normalizeMaxSources(maxSources: number | undefined): number | undefined { if (maxSources === undefined) { return undefined; @@ -171,7 +262,7 @@ function isProtectedSource(source: ChannelContextSource): boolean { return PROTECTED_SOURCE_KINDS.has(source.kind) || source.freshness === "current_turn"; } -function stableCondenserId(kind: "text" | "drop", sourceId: string, seed: string): string { +function stableCondenserId(kind: "text" | "drop" | "section_text" | "section_drop", sourceId: string, seed: string): string { let hash = 0; for (const character of `${kind}:${sourceId}:${seed}`) { hash = ((hash << 5) - hash + character.charCodeAt(0)) | 0; diff --git a/packages/daemon/src/features/channels/context-processing/conversation-context-adapter.ts b/packages/daemon/src/features/channels/context-processing/conversation-context-adapter.ts index 860ba600a..f90355008 100644 --- a/packages/daemon/src/features/channels/context-processing/conversation-context-adapter.ts +++ b/packages/daemon/src/features/channels/context-processing/conversation-context-adapter.ts @@ -46,6 +46,7 @@ export function buildProjectedConversationContextSlice(input: { id: "conversation_projection_items", role: "evidence", priority: "high", + compactionSurvival: "summarize", title: "Conversation projection items", text: [ "Conversation context selected by Continuity v2 for the current segment:", @@ -96,6 +97,7 @@ function buildProjectionSharedCoreSection( id: "conversation_projection_shared_core", role: "evidence", priority: "high", + compactionSurvival: "summarize", title: "Conversation projection shared core", text: [ `Shared context core for segment ${projection.segment.label ?? projection.segment.id}:`, diff --git a/packages/daemon/src/features/channels/context-processing/inspect.ts b/packages/daemon/src/features/channels/context-processing/inspect.ts index cb2bbe85a..1b08af782 100644 --- a/packages/daemon/src/features/channels/context-processing/inspect.ts +++ b/packages/daemon/src/features/channels/context-processing/inspect.ts @@ -31,6 +31,7 @@ export interface WorkflowContextInspectSummary { readonly id: string; readonly role: string; readonly priority: string; + readonly compactionSurvival?: string; readonly estimatedTokens?: number; readonly sourceRefIds: readonly string[]; readonly metadataKeys: readonly string[]; @@ -91,6 +92,7 @@ export function buildWorkflowContextInspectSummary(input: { id: section.id, role: section.role, priority: section.priority, + compactionSurvival: section.compactionSurvival, estimatedTokens: section.estimatedTokens, sourceRefIds: section.sourceRefIds ?? [], metadataKeys: section.metadata ? Object.keys(section.metadata).sort() : [], diff --git a/packages/daemon/src/features/channels/context-processing/private-chat.ts b/packages/daemon/src/features/channels/context-processing/private-chat.ts index b12b797a9..c26980f01 100644 --- a/packages/daemon/src/features/channels/context-processing/private-chat.ts +++ b/packages/daemon/src/features/channels/context-processing/private-chat.ts @@ -69,6 +69,7 @@ export function buildPrivateChatContextView(input: BuildPrivateChatContextViewIn id: "private_chat_prompt", role: "user", priority: "must_keep", + compactionSurvival: "summarize", title: "private_chat_prompt", text: prompt, sourceRefIds: promptSources.map((source) => source.id), diff --git a/packages/daemon/src/features/channels/context-processing/proposals.ts b/packages/daemon/src/features/channels/context-processing/proposals.ts index 0a273fb02..f41c13ae7 100644 --- a/packages/daemon/src/features/channels/context-processing/proposals.ts +++ b/packages/daemon/src/features/channels/context-processing/proposals.ts @@ -50,6 +50,7 @@ export function buildWorkflowProposalContextView(input: BuildWorkflowProposalCon id: "proposal_current_turn", role: "user", priority: "must_keep", + compactionSurvival: "summarize", title: "Current proposal turn", text: [`Channel: ${input.channel.name}`, "Latest user turn:", renderLingAgentTurnEnvelopeForPrompt(envelope)].join("\n"), sourceRefIds: [input.sourceMessage.id, input.channel.id], @@ -61,6 +62,7 @@ export function buildWorkflowProposalContextView(input: BuildWorkflowProposalCon id: "proposal_active_work", role: "evidence", priority: "medium", + compactionSurvival: "discard", title: "Active work", text: activeWorkSources.length > 0 ? ["Active work:", ...activeWorkSources.map((source) => `${source.title} (${String(source.metadata.entityType ?? source.kind)}:${String(source.metadata.status ?? "unknown")})`)].join("\n") diff --git a/packages/daemon/src/features/channels/context-processing/types.ts b/packages/daemon/src/features/channels/context-processing/types.ts index 937efd94f..b8225c935 100644 --- a/packages/daemon/src/features/channels/context-processing/types.ts +++ b/packages/daemon/src/features/channels/context-processing/types.ts @@ -16,7 +16,8 @@ export type ChannelContextSourceKind = | "artifact" | "workflow" | "workflow_step" - | "workflow_packet"; + | "workflow_packet" + | "workspace_knowledge"; export type ChannelContextSensitivity = "normal" | "private" | "secret_adjacent"; export type ChannelContextFreshness = "current_turn" | "recent" | "durable" | "archival"; @@ -25,6 +26,7 @@ export type ChannelContextQuotePolicy = "public_ok" | "influence_only" | "inspec export type ChannelContextDroppedReason = "budget" | "scope" | "sensitivity" | "raw_evidence" | "unrelated"; export type ChannelContextSectionRole = "system" | "instruction" | "user" | "memory" | "evidence"; export type ChannelContextSectionPriority = "must_keep" | "high" | "medium" | "low"; +export type ChannelContextSectionCompactionSurvival = "rebuild" | "summarize" | "discard"; export type ChannelContextViewKind = | "ling_agent_channel_view" @@ -59,6 +61,8 @@ export interface ChannelContextSection { readonly id: string; readonly role: ChannelContextSectionRole; readonly priority: ChannelContextSectionPriority; + /** Lifecycle is independent from priority: it controls what compaction may do. */ + readonly compactionSurvival: ChannelContextSectionCompactionSurvival; readonly title: string; readonly text: string; readonly sourceRefIds: readonly string[]; @@ -87,6 +91,8 @@ export interface ChannelContextCondenserMetadata { readonly maxSources?: number; readonly condensedSourceCount: number; readonly droppedSourceCount: number; + readonly condensedSectionCount?: number; + readonly discardedSectionCount?: number; } export interface ChannelContextViewMetadata { diff --git a/packages/daemon/src/features/channels/context-processing/views.ts b/packages/daemon/src/features/channels/context-processing/views.ts index 8f032adda..ce32572e0 100644 --- a/packages/daemon/src/features/channels/context-processing/views.ts +++ b/packages/daemon/src/features/channels/context-processing/views.ts @@ -1,4 +1,5 @@ -import { condenseChannelContextSources } from "./condenser.js"; +import { randomUUID } from "node:crypto"; +import { condenseChannelContextSections, condenseChannelContextSources } from "./condenser.js"; import { sourceDropReasonForView } from "./policies.js"; import type { ChannelContextBudgetSnapshot, @@ -34,6 +35,8 @@ export interface BuildChannelContextViewInput { } export function buildChannelContextView(input: BuildChannelContextViewInput): ChannelContextView { + const createdAt = input.now ?? new Date().toISOString(); + const id = createChannelContextViewId(); const keptSources: ChannelContextSource[] = []; const dropped: ChannelContextDroppedSource[] = [...(input.dropped ?? [])]; for (const source of input.sources ?? []) { @@ -58,13 +61,28 @@ export function buildChannelContextView(input: BuildChannelContextViewInput): Ch dropped.push(...condenserResult.dropped); } + const sectionCondenserResult = input.condenser?.enabled + ? condenseChannelContextSections({ + sections: input.sections, + maxSectionTextChars: input.condenser.maxSourceTextChars, + }) + : undefined; + const viewSections = sectionCondenserResult?.sections ?? input.sections; + const viewContextProjection = input.contextProjection + ? { + ...input.contextProjection, + contextViewId: id, + contextViewCreatedAt: createdAt, + } + : undefined; + return { - id: `${input.kind}:${input.channelId ?? input.workspaceId}`, + id, kind: input.kind, workspaceId: input.workspaceId, channelId: input.channelId, consumerTeammateId: input.consumerTeammateId, - sections: input.sections, + sections: viewSections, sourceRefs: uniqueSourceRefs([ ...input.sourceRefs, ...viewSources.flatMap((source) => source.sourceRefs), @@ -72,12 +90,13 @@ export function buildChannelContextView(input: BuildChannelContextViewInput): Ch sources: viewSources, dropped, budget: input.budget, - contextProjection: input.contextProjection, + contextProjection: viewContextProjection, metadata: { - createdAt: input.now ?? new Date().toISOString(), + createdAt, compactionIds: uniqueStrings([ ...(input.compactionIds ?? []), ...(condenserResult?.compactionIds ?? []), + ...(sectionCondenserResult?.compactionIds ?? []), ]), sourceCount: viewSources.length, condenser: condenserResult ? { @@ -86,11 +105,17 @@ export function buildChannelContextView(input: BuildChannelContextViewInput): Ch maxSources: input.condenser?.maxSources, condensedSourceCount: condenserResult.condensedSourceIds.length, droppedSourceCount: condenserResult.dropped.length, + condensedSectionCount: sectionCondenserResult?.condensedSectionIds.length ?? 0, + discardedSectionCount: sectionCondenserResult?.discardedSectionIds.length ?? 0, } : undefined, }, }; } +export function createChannelContextViewId(): string { + return `context_view_${randomUUID()}`; +} + function uniqueSourceRefs(refs: readonly ChannelContextSourceRef[]): readonly ChannelContextSourceRef[] { const seen = new Set(); const unique: ChannelContextSourceRef[] = []; diff --git a/packages/daemon/src/features/channels/context-processing/workflow-steps.ts b/packages/daemon/src/features/channels/context-processing/workflow-steps.ts index 534784036..8057111ab 100644 --- a/packages/daemon/src/features/channels/context-processing/workflow-steps.ts +++ b/packages/daemon/src/features/channels/context-processing/workflow-steps.ts @@ -75,6 +75,7 @@ export function buildWorkflowStepContextView(input: BuildWorkflowStepContextView id: "workflow_runtime_boundary", role: "instruction", priority: "must_keep", + compactionSurvival: "rebuild", title: "workflow_runtime_boundary", text: [ "Workflow step runtime boundary:", @@ -92,6 +93,7 @@ export function buildWorkflowStepContextView(input: BuildWorkflowStepContextView id: "workflow_objective", role: "user", priority: "must_keep", + compactionSurvival: "summarize", title: "workflow_objective", text: [ "Workflow objective:", @@ -114,6 +116,7 @@ export function buildWorkflowStepContextView(input: BuildWorkflowStepContextView id: "workflow_current_step", role: "user", priority: "must_keep", + compactionSurvival: "summarize", title: "workflow_current_step", text: [ "Current workflow step:", @@ -135,6 +138,7 @@ export function buildWorkflowStepContextView(input: BuildWorkflowStepContextView id: "workflow_approval_gates", role: "instruction", priority: "high", + compactionSurvival: "rebuild", title: "workflow_approval_gates", text: [ "Approval gates:", @@ -153,6 +157,7 @@ export function buildWorkflowStepContextView(input: BuildWorkflowStepContextView id: "workflow_context_summary", role: "evidence" as const, priority: "medium" as const, + compactionSurvival: "summarize" as const, title: "workflow_context_summary", text: [ "Channel context summary:", @@ -206,6 +211,7 @@ function buildCompactedWorkflowDependencySection(summary: WorkflowStepCompactedD id: "workflow_dependency_summary", role: "evidence", priority: "high", + compactionSurvival: "summarize", title: "workflow_dependency_summary", text: summary.text, sourceRefIds: summary.sourceRefIds, @@ -224,6 +230,7 @@ function buildWorkflowStepDependencySections(input: WorkflowStepContextPackInput id: "workflow_dependencies", role: "evidence", priority: "high", + compactionSurvival: "summarize", title: "workflow_dependencies", text: "Dependencies:\n- none", sourceRefIds: [input.step.id], @@ -241,6 +248,7 @@ function buildWorkflowStepDependencySections(input: WorkflowStepContextPackInput id: `workflow_dependency_${index + 1}`, role: "evidence", priority: "high", + compactionSurvival: "summarize", title: `workflow_dependency_${index + 1}`, text: [ "Dependency output:", @@ -273,6 +281,7 @@ function formatWorkflowDecisionAndHandoffSections(input: WorkflowStepContextPack id: "workflow_selector_decisions", role: "evidence", priority: "high", + compactionSurvival: "summarize", title: "workflow_selector_decisions", text: ["Workflow selector decisions:", ...lines].join("\n"), sourceRefIds: selectorDecisionIds, @@ -291,6 +300,7 @@ function formatWorkflowDecisionAndHandoffSections(input: WorkflowStepContextPack id: "workflow_handoff_intents", role: "evidence", priority: "high", + compactionSurvival: "summarize", title: "workflow_handoff_intents", text: ["Workflow handoff intents:", ...lines].join("\n"), sourceRefIds: handoffIntentIds, diff --git a/packages/daemon/src/features/channels/workflows.ts b/packages/daemon/src/features/channels/workflows.ts index 0e1f57d13..d206498d6 100644 --- a/packages/daemon/src/features/channels/workflows.ts +++ b/packages/daemon/src/features/channels/workflows.ts @@ -125,6 +125,7 @@ export interface WorkflowStepRunContextPackSnapshot { readonly id: string; readonly role: string; readonly priority: string; + readonly compactionSurvival?: string; readonly estimatedTokens?: number; readonly sourceRefIds?: readonly string[]; readonly metadata?: Record; @@ -896,6 +897,7 @@ export function prepareWorkflowStepStart( id: "workflow_role_evidence", role: "memory" as const, priority: "medium" as const, + compactionSurvival: "summarize" as const, title: "workflow_role_evidence", text: renderedMemory.prompt, sourceRefIds: renderedMemory.memoryIds, @@ -1311,6 +1313,7 @@ function snapshotWorkflowStepContextPack(pack: ContextPack): WorkflowStepRunCont id: section.id, role: section.role, priority: section.priority, + compactionSurvival: section.compactionSurvival, estimatedTokens: section.estimatedTokens, sourceRefIds: section.sourceRefIds, metadata: section.metadata, diff --git a/packages/daemon/src/features/runs/orchestrator.ts b/packages/daemon/src/features/runs/orchestrator.ts index f5c21166e..703866b9c 100644 --- a/packages/daemon/src/features/runs/orchestrator.ts +++ b/packages/daemon/src/features/runs/orchestrator.ts @@ -587,6 +587,8 @@ export async function executeRunRequest( parentRunId: runRequest.parentRunId, interventionId: runRequest.interventionId, continuationReason: runRequest.continuationReason, + contextViewId: runRequest.contextProjection?.contextViewId, + contextViewCreatedAt: runRequest.contextProjection?.contextViewCreatedAt, createdAt, }); store.linkRunToWorkUnit(run.id, workUnit.id, createdAt); diff --git a/packages/daemon/src/features/runs/queue.ts b/packages/daemon/src/features/runs/queue.ts index 55e84a714..694e3bb3a 100644 --- a/packages/daemon/src/features/runs/queue.ts +++ b/packages/daemon/src/features/runs/queue.ts @@ -55,6 +55,8 @@ export function createQueuedRunForConflict(input: { threadId: input.thread?.id, turnId: input.turn?.id, turnRunRole: input.turnRunRole, + contextViewId: input.runRequest.contextProjection?.contextViewId, + contextViewCreatedAt: input.runRequest.contextProjection?.contextViewCreatedAt, createdAt: input.queuedAt, }); input.store.linkRunToWorkUnit(run.id, input.workUnit.id, input.queuedAt); diff --git a/packages/daemon/src/features/runs/run-intervention-continuation-request.ts b/packages/daemon/src/features/runs/run-intervention-continuation-request.ts index 0407643ac..28c942db6 100644 --- a/packages/daemon/src/features/runs/run-intervention-continuation-request.ts +++ b/packages/daemon/src/features/runs/run-intervention-continuation-request.ts @@ -13,6 +13,7 @@ import { type SkillApprovalProfile, } from "@ling-stack/core"; import type { PiRunnerProviderConfig } from "@ling-stack/runner-pi"; +import { isConversationContextProjectionMetadata, type ConversationContextProjectionMetadata } from "@ling-stack/contracts"; import { isRecord } from "../../shared/validation.js"; import { buildChannelReplyContinuationContext } from "../channels/channel-reply-continuation-context.js"; import type { RunRequest } from "./schemas.js"; @@ -84,6 +85,7 @@ export function buildRunInterventionContinuationRequest( thinkingLevel: readSnapshotThinkingLevel(snapshot, "thinkingLevel"), systemPrompt: channelContinuation?.systemPromptInstruction ?? inheritedSystemPrompt, appendSystemPrompt, + contextProjection: channelContinuation?.contextProjection ?? readSnapshotContextProjection(snapshot), agentDir: readSnapshotString(snapshot, "agentDir"), skipMemoryRecall: channelContinuation ? true : readSnapshotBoolean(snapshot, "skipMemoryRecall"), conversationAttachmentIds: intervention.messagePayload?.attachmentIds ?? [], @@ -98,6 +100,12 @@ export function buildRunInterventionContinuationRequest( }; } +function readSnapshotContextProjection(snapshot: Record): ConversationContextProjectionMetadata | undefined { + return isConversationContextProjectionMetadata(snapshot.contextProjection) + ? snapshot.contextProjection + : undefined; +} + function renderRunInterventionContinuationPrompt(input: { readonly sourceRun: RunSession; readonly originalTitle: string; diff --git a/packages/daemon/src/features/runs/thread-routes.ts b/packages/daemon/src/features/runs/thread-routes.ts index 202029441..3e86273ae 100644 --- a/packages/daemon/src/features/runs/thread-routes.ts +++ b/packages/daemon/src/features/runs/thread-routes.ts @@ -35,7 +35,7 @@ import { type UpdateProposedPlanArtifactInput, } from "@ling-stack/core"; import type { PiRunnerProviderConfig } from "@ling-stack/runner-pi"; -import { createWorkflowParticipantAuthorityV1 } from "@ling-stack/contracts"; +import { createWorkflowParticipantAuthorityV1, isConversationContextProjectionMetadata } from "@ling-stack/contracts"; import { HttpError } from "../../errors.js"; import type { RunStoreRegistry } from "../../runtime/store-registry.js"; import { readOptionalIntegerQuery, readOptionalJsonBody } from "../../shared/request.js"; @@ -1234,9 +1234,16 @@ function buildForkRunRunRequest(input: { systemPrompt: readSnapshotString(requestSnapshot, "systemPrompt"), appendSystemPrompt: readSnapshotStringArray(requestSnapshot, "appendSystemPrompt"), agentDir: readSnapshotString(requestSnapshot, "agentDir"), + contextProjection: readSnapshotContextProjection(requestSnapshot), }; } +function readSnapshotContextProjection(record: Record) { + return isConversationContextProjectionMetadata(record.contextProjection) + ? record.contextProjection + : undefined; +} + function buildForkRunOperationKey(input: { readonly sourceThreadId: string; readonly sourceTurnId?: string; @@ -1575,6 +1582,7 @@ function buildLingThreadResumeContext(input: { systemPrompt: readSnapshotString(requestSnapshot, "systemPrompt"), appendSystemPrompt: readSnapshotStringArray(requestSnapshot, "appendSystemPrompt"), agentDir: readSnapshotString(requestSnapshot, "agentDir"), + contextProjection: readSnapshotContextProjection(requestSnapshot), parentRunId: sourceRun.id, continuationReason: "manual_resume", }, diff --git a/packages/daemon/test/channel-context-processing.test.mjs b/packages/daemon/test/channel-context-processing.test.mjs index 51d1076ba..3bd58cfd1 100644 --- a/packages/daemon/test/channel-context-processing.test.mjs +++ b/packages/daemon/test/channel-context-processing.test.mjs @@ -14,7 +14,7 @@ import { summarizeArtifactEvidence, workflowPacketKindForArtifact, } from "../dist/features/channels/context-processing/evidence.js"; -import { condenseChannelContextSources } from "../dist/features/channels/context-processing/condenser.js"; +import { condenseChannelContextSections, condenseChannelContextSources } from "../dist/features/channels/context-processing/condenser.js"; import { buildWorkflowContextInspectSummary } from "../dist/features/channels/context-processing/inspect.js"; import { buildWorkflowProposalContextView, @@ -466,6 +466,85 @@ test("context view opt-in condenser records metadata without changing prompt sec assert.equal(view.metadata.sourceCount, 1); }); +test("context section lifecycle keeps rebuild, summarizes summarize, and discards discard", () => { + const result = condenseChannelContextSections({ + sections: [ + { + id: "base_instructions", + role: "instruction", + priority: "must_keep", + compactionSurvival: "rebuild", + title: "Base instructions", + text: "base ".repeat(800), + sourceRefIds: [], + visibility: "prompt", + quotePolicy: "public_ok", + }, + { + id: "user_history", + role: "user", + priority: "medium", + compactionSurvival: "summarize", + title: "User history", + text: "history ".repeat(800), + sourceRefIds: [], + visibility: "prompt", + quotePolicy: "public_ok", + }, + { + id: "transient_developer", + role: "instruction", + priority: "low", + compactionSurvival: "discard", + title: "Transient developer message", + text: "discard me", + sourceRefIds: [], + visibility: "prompt", + quotePolicy: "public_ok", + }, + ], + maxSectionTextChars: 600, + }); + + assert.deepEqual(result.sections.map((section) => section.id), ["base_instructions", "user_history"]); + assert.equal(result.sections[0].text.startsWith("base "), true); + assert.equal(result.sections[1].metadata.condensed, true); + assert.deepEqual(result.discardedSectionIds, ["transient_developer"]); + assert.deepEqual(result.condensedSectionIds, ["user_history"]); + assert.equal(result.compactionIds.length, 2); +}); + +test("context views create independent snapshot identities and stamp projection lineage", () => { + const projection = { + projectionVersion: "projection_1", + sourceStateFingerprint: "state_1", + threadId: "thread_1", + turnId: "turn_1", + segmentId: "segment_1", + throughItemSequence: 4, + summaryIds: [], + sourceRefs: [], + policyVersion: "policy_1", + visibilityFingerprint: "visibility_1", + }; + const input = { + kind: "ling_agent_channel_view", + workspaceId: "workspace_1", + channelId: "channel_1", + now, + sourceRefs: [], + contextProjection: projection, + sections: [], + }; + const first = buildChannelContextView(input); + const second = buildChannelContextView(input); + assert.notEqual(first.id, second.id); + assert.equal(first.metadata.createdAt, now); + assert.equal(first.contextProjection.contextViewId, first.id); + assert.equal(first.contextProjection.contextViewCreatedAt, now); + assert.equal(second.contextProjection.contextViewId, second.id); +}); + test("workflow context inspect summary exposes metadata without raw prompt text", () => { const missing = buildWorkflowContextInspectSummary({ step: createWorkflowStep({ metadata: {} }), From 091ef1733f17c1e754467969b5aadd6f45c54b28 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Tue, 25 Aug 2026 20:27:44 +0800 Subject: [PATCH 3/8] feat: type run events and project timeline items --- .../channel-conversation-continuity-v2.md | 65 ++++ packages/contracts/src/index.ts | 1 + .../contracts/src/run-event-validation.ts | 99 +++++ packages/contracts/src/runtime.ts | 200 +++++++++- .../contracts/test/runtime-events.test.mjs | 19 + .../conversation-item-run-event-projection.ts | 357 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/run-types.ts | 54 +-- packages/core/src/storage/sqlite-run-store.ts | 6 +- .../src/storage/sqlite/run-event-store.ts | 28 +- .../core/src/storage/sqlite/run-mappers.ts | 21 +- .../sqlite/runtime-recovery-coordinator.ts | 3 +- ...rsation-item-run-event-projection.test.mjs | 87 +++++ .../core/test/run-event-store-typed.test.mjs | 54 +++ .../channels/channel-reply-dispatch.ts | 3 +- .../src/features/runs/action-artifacts.ts | 3 +- .../runs/approval-resume-coordinator.ts | 7 +- .../daemon/src/features/runs/approvals.ts | 11 +- .../daemon/src/features/runs/interventions.ts | 3 +- .../daemon/src/features/runs/orchestrator.ts | 18 +- .../src/features/runs/permission-requests.ts | 9 +- .../src/features/runs/shell-snapshots.ts | 5 +- .../src/features/runs/stale-completion.ts | 5 +- .../daemon/src/features/runs/unified-exec.ts | 13 +- 24 files changed, 986 insertions(+), 86 deletions(-) create mode 100644 packages/contracts/src/run-event-validation.ts create mode 100644 packages/contracts/test/runtime-events.test.mjs create mode 100644 packages/core/src/conversation-item-run-event-projection.ts create mode 100644 packages/core/test/conversation-item-run-event-projection.test.mjs create mode 100644 packages/core/test/run-event-store-typed.test.mjs diff --git a/docs/design/channel-conversation-continuity-v2.md b/docs/design/channel-conversation-continuity-v2.md index d0ae397a1..19b5a02a8 100644 --- a/docs/design/channel-conversation-continuity-v2.md +++ b/docs/design/channel-conversation-continuity-v2.md @@ -35,6 +35,71 @@ become the other's authority: only implements the approval adapter and reports request/decision evidence; it does not own approval persistence or lifecycle authority. +### Typed RunEvent boundary + +`RunEventEnvelope` is execution evidence, not a second conversation record. Its +`type` is now a discriminant for `data`: producers append a payload from the matching +`RunEventDataByType[K]`, and consumers narrow on `event.type` before reading fields. +Provider-specific details may remain in bounded opaque fields (`args`, `result`, or +metadata), but the envelope itself may not fall back to `unknown`. + +The SQLite event log is a replay boundary. Rows are validated as JSON objects when +read, malformed payloads fail closed, and replay consumers must be idempotent. A +replayed event may update a deterministic projection, but it must not create a second +Ling turn, approval authority, artifact authority, or canonical history entry. + +### Conversation Item projection boundary + +The existing `ConversationItemV1` model is the Item layer; no second Item table or +mutable execution store is introduced: + +```text +Ling timeline authority + ├── LingThread + ├── LingTurn + └── ConversationItem + +execution evidence + └── RunEventEnvelope + +typed, read-only projection + └── rich timeline / activity UI +``` + +The core projection maps assistant deltas, tool start/end, approval request/decision, +artifact creation, and terminal/intervention state into typed Items. Item identity is +deterministic (`run + event domain key`), tool and approval pairs are matched by their +IDs, and incomplete pairs remain renderable rather than crashing replay. The projection +owns neither Run, Approval, Artifact, nor workspace state; it stores only domain refs. +Its sequence is projection-local and must not be written directly into the canonical +SQLite Item sequence allocator. UI code must render the typed kind/payload instead of +inferring tools or approvals from prose. + +### `workspace_knowledge` provider boundary (reserved) + +`ChannelContextSourceKind` reserves `workspace_knowledge` for a future provider that +can inject bounded, structured workspace facts such as `AGENTS.md`, design documents, +quality levels, and workspace policy. This slice intentionally provides no source +provider or automatic injection path. A future provider must return source refs, +freshness, quality metadata, and bounded text; pass through the same sensitivity, +permission, and `ChannelContextView` filtering as every other source; and define a +refresh/revalidation policy for long-running autonomous Runs. + +### Post-compaction undo boundary (reserved) + +Compaction may reduce prompt context, but it must never delete workspace-change +provenance. Undo semantics are deferred until the Continuity v2 compaction implementation +freezes these facts: + +- the exact workspace snapshot or patch basis to which undo returns; +- a durable chain from compacted Turns to `RunArtifact`, patch plan, Run lineage, and + source refs; +- a fail-closed rule when the target snapshot cannot be verified. + +A condenser summary is context evidence only; it is never the authority for undo. A +future implementation must answer how changes from compacted Turns are traced before +exposing an undo action. + ## Current release checkpoint (2026-08-25) The clean delivery worktree is based on `origin/main` at `90635c83e6ec6645699a45c114c59905c996ec12`. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ec824b554..bac03e0e2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,6 +3,7 @@ export type * from "./retention.js"; export type * from "./approvals.js"; export type * from "./artifacts.js"; export type * from "./runtime.js"; +export * from "./run-event-validation.js"; export type * from "./runner-readiness.js"; export * from "./threads.js"; export type * from "./conversations.js"; diff --git a/packages/contracts/src/run-event-validation.ts b/packages/contracts/src/run-event-validation.ts new file mode 100644 index 000000000..1325094ce --- /dev/null +++ b/packages/contracts/src/run-event-validation.ts @@ -0,0 +1,99 @@ +import type { RunEventDataByType, RunEventType } from "./runtime.js"; + +const RUN_EVENT_TYPES = new Set([ + "run_created", + "run_start", + "turn_start", + "turn_end", + "assistant_text_delta", + "assistant_thinking_delta", + "tool_hydration_request", + "tool_hydration_end", + "message_end", + "tool_start", + "tool_update", + "tool_end", + "run_end", + "run_error", + "run_cancelled", + "approval_requested", + "approval_approved", + "approval_denied", + "approval_expired", + "approval_cancelled", + "artifact_created", + "workspace_change_recovery_started", + "workspace_changes_undone", + "workspace_changes_reapplied", + "run_intervention_created", + "run_followup_queued", + "run_interrupt_requested", + "run_continuation_starting", + "run_continuation_started", + "run_intervention_failed", + "stale_runner_completion_ignored", + "result", + "progress", +]); + +export function isRunEventType(value: unknown): value is RunEventType { + return typeof value === "string" && RUN_EVENT_TYPES.has(value as RunEventType); +} + +export function isRunEventData( + type: K, + value: unknown, +): value is RunEventDataByType[K] { + if (!isRecord(value)) return false; + switch (type) { + case "assistant_text_delta": + case "assistant_thinking_delta": + return typeof value.text === "string"; + case "tool_start": + return typeof value.toolName === "string" + && optionalString(value.toolCallId); + case "tool_update": + return typeof value.toolName === "string" + && optionalString(value.toolCallId); + case "tool_end": + return typeof value.toolName === "string" + && typeof value.isError === "boolean" + && optionalString(value.toolCallId); + case "run_error": + return typeof value.message === "string"; + case "approval_requested": + return typeof value.approvalId === "string" + && optionalString(value.toolCallId) + && optionalString(value.title) + && optionalString(value.description) + && optionalString(value.expiresAt); + case "approval_approved": + case "approval_denied": + case "approval_expired": + case "approval_cancelled": + return typeof value.approvalId === "string" + && optionalString(value.decisionReason); + case "run_cancelled": + return optionalString(value.reason) + && (value.active === undefined || typeof value.active === "boolean"); + case "message_end": + return optionalString(value.role) + && optionalString(value.text) + && optionalString(value.stopReason) + && optionalString(value.errorMessage) + && (value.contentKinds === undefined + || (Array.isArray(value.contentKinds) && value.contentKinds.every((item) => typeof item === "string"))); + case "progress": + return optionalString(value.message); + default: + return true; + } +} + +function optionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/contracts/src/runtime.ts b/packages/contracts/src/runtime.ts index 357fdb2aa..7e0fa2a19 100644 --- a/packages/contracts/src/runtime.ts +++ b/packages/contracts/src/runtime.ts @@ -440,17 +440,199 @@ export type RunEventType = | "run_continuation_started" | "run_intervention_failed" | "stale_runner_completion_ignored" - | "result"; - -export interface RunEventEnvelope { - readonly id: string; - readonly runId: string; - readonly seq: number; - readonly ts: string; - readonly type: RunEventType; - readonly data: unknown; + | "result" + /** Legacy generic progress rows retained for old event logs. */ + | "progress"; + +/** + * Typed payloads for durable run events. + * + * The index signature on each payload is intentional for forward-compatible + * provider metadata. The envelope itself remains a discriminated union on + * `type`, so consumers can narrow the payload before reading event-specific + * fields instead of treating the entire event as arbitrary JSON. + */ +export interface RunEventDataByType { + readonly run_created: { + readonly taskId?: string; + readonly workUnitId?: string; + readonly threadId?: string; + readonly cwd?: string; + readonly providerName?: string; + readonly providerModel?: string; + readonly queued?: boolean; + readonly [key: string]: unknown; + }; + readonly run_start: { + readonly taskId?: string; + readonly cwd?: string; + readonly provider?: string; + readonly model?: string; + readonly [key: string]: unknown; + }; + readonly turn_start: { readonly [key: string]: unknown }; + readonly turn_end: { readonly [key: string]: unknown }; + readonly assistant_text_delta: { + readonly text: string; + readonly [key: string]: unknown; + }; + readonly assistant_thinking_delta: { + readonly text: string; + readonly [key: string]: unknown; + }; + readonly tool_hydration_request: { + readonly runId?: string; + readonly runnerAttemptId?: string; + readonly scope?: unknown; + readonly [key: string]: unknown; + }; + readonly tool_hydration_end: { + readonly runId?: string; + readonly runnerAttemptId?: string; + readonly rejected?: boolean; + readonly code?: string; + readonly [key: string]: unknown; + }; + readonly message_end: { + readonly role?: string; + readonly text?: string; + readonly stopReason?: string; + readonly errorMessage?: string; + readonly contentKinds?: readonly string[]; + readonly [key: string]: unknown; + }; + readonly tool_start: { + readonly toolCallId?: string; + readonly toolName: string; + readonly args?: unknown; + readonly [key: string]: unknown; + }; + readonly tool_update: { + readonly toolCallId?: string; + readonly toolName: string; + readonly partialResult?: unknown; + readonly [key: string]: unknown; + }; + readonly tool_end: { + readonly toolCallId?: string; + readonly toolName: string; + readonly result?: unknown; + readonly isError: boolean; + readonly [key: string]: unknown; + }; + readonly run_end: { + readonly taskId?: string; + readonly messageCount?: number; + readonly [key: string]: unknown; + }; + readonly run_error: { + readonly message: string; + readonly [key: string]: unknown; + }; + readonly run_cancelled: { + readonly reason?: string; + readonly active?: boolean; + readonly [key: string]: unknown; + }; + readonly approval_requested: { + readonly approvalId: string; + readonly toolCallId?: string; + readonly title?: string; + readonly description?: string; + readonly expiresAt?: string; + readonly [key: string]: unknown; + }; + readonly approval_approved: { + readonly approvalId: string; + readonly decisionReason?: string; + readonly [key: string]: unknown; + }; + readonly approval_denied: { + readonly approvalId: string; + readonly decisionReason?: string; + readonly [key: string]: unknown; + }; + readonly approval_expired: { + readonly approvalId: string; + readonly decisionReason?: string; + readonly [key: string]: unknown; + }; + readonly approval_cancelled: { + readonly approvalId: string; + readonly decisionReason?: string; + readonly [key: string]: unknown; + }; + readonly artifact_created: { + readonly artifactId?: string; + readonly toolCallId?: string; + readonly toolName?: string; + readonly artifact?: unknown; + readonly [key: string]: unknown; + }; + readonly workspace_change_recovery_started: { + readonly [key: string]: unknown; + }; + readonly workspace_changes_undone: { + readonly [key: string]: unknown; + }; + readonly workspace_changes_reapplied: { + readonly [key: string]: unknown; + }; + readonly run_intervention_created: { + readonly interventionId?: string; + readonly mode?: string; + readonly [key: string]: unknown; + }; + readonly run_followup_queued: { + readonly interventionId?: string; + readonly [key: string]: unknown; + }; + readonly run_interrupt_requested: { + readonly interventionId?: string; + readonly [key: string]: unknown; + }; + readonly run_continuation_starting: { + readonly interventionId?: string; + readonly [key: string]: unknown; + }; + readonly run_continuation_started: { + readonly interventionId?: string; + readonly continuationRunId?: string; + readonly [key: string]: unknown; + }; + readonly run_intervention_failed: { + readonly interventionId?: string; + readonly reason?: string; + readonly [key: string]: unknown; + }; + readonly stale_runner_completion_ignored: { + readonly reason?: string; + readonly reportedStatus?: string; + readonly [key: string]: unknown; + }; + readonly result: { + readonly text?: string; + readonly [key: string]: unknown; + }; + readonly progress: { + readonly message?: string; + readonly [key: string]: unknown; + }; } +export type RunEventData = RunEventDataByType[RunEventType]; + +export type RunEventEnvelope = { + readonly [K in RunEventType]: { + readonly id: string; + readonly runId: string; + readonly seq: number; + readonly ts: string; + readonly type: K; + readonly data: RunEventDataByType[K]; + }; +}[RunEventType]; + export type ConversationActiveReplyStatus = "queued" | "running" | "waiting_for_approval"; export interface ConversationActiveReplyTarget { diff --git a/packages/contracts/test/runtime-events.test.mjs b/packages/contracts/test/runtime-events.test.mjs new file mode 100644 index 000000000..a4ef1fbed --- /dev/null +++ b/packages/contracts/test/runtime-events.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isRunEventData, isRunEventType } from "../dist/index.js"; + +test("typed run event validation accepts required payload fields", () => { + assert.equal(isRunEventType("tool_start"), true); + assert.equal(isRunEventData("tool_start", { toolCallId: "call-1", toolName: "bash", args: { command: "pwd" } }), true); + assert.equal(isRunEventData("tool_end", { toolName: "bash", isError: false, result: "ok" }), true); + assert.equal(isRunEventData("approval_requested", { approvalId: "approval-1", title: "Confirm" }), true); + assert.equal(isRunEventData("run_end", { messageCount: 1 }), true); +}); + +test("typed run event validation rejects malformed or unknown rows", () => { + assert.equal(isRunEventType("not-a-run-event"), false); + assert.equal(isRunEventData("assistant_text_delta", { text: 42 }), false); + assert.equal(isRunEventData("tool_end", { toolName: "bash", isError: "false" }), false); + assert.equal(isRunEventData("approval_requested", { title: "missing id" }), false); + assert.equal(isRunEventData("run_error", "not an object"), false); +}); diff --git a/packages/core/src/conversation-item-run-event-projection.ts b/packages/core/src/conversation-item-run-event-projection.ts new file mode 100644 index 000000000..4ea353b04 --- /dev/null +++ b/packages/core/src/conversation-item-run-event-projection.ts @@ -0,0 +1,357 @@ +import { createHash } from "node:crypto"; +import type { + ConversationDomainRef, + ConversationItemV1, + ConversationAssistantMessagePayloadV1, + ConversationItemVisibilityV1, +} from "@ling-stack/contracts"; +import type { LingTurn, RunEventEnvelope, RunSession } from "./run-types.js"; + +/** + * Read-only projection input for execution evidence. + * + * Run events remain the execution authority and Ling turns/items remain the + * conversation authority. This function only builds a deterministic view for + * rich timeline consumers; it never appends or mutates canonical items. + */ +export interface ProjectRunEventsToConversationItemsInput { + readonly run: RunSession; + readonly turn: LingTurn; + readonly events: readonly RunEventEnvelope[]; + /** Existing projected items are used to make replay/update idempotent. */ + readonly existingItems?: readonly ConversationItemV1[]; +} + +/** + * Projects typed run evidence into the existing ConversationItemV1 model. + * + * The returned sequence is projection-local and ordered by event sequence. A + * caller that persists canonical items must use the ConversationItem store's + * own sequence allocator instead of writing these values back directly. + */ +export function projectRunEventsToConversationItems( + input: ProjectRunEventsToConversationItemsInput, +): readonly ConversationItemV1[] { + const items = new Map(); + for (const item of input.existingItems ?? []) { + if (item.threadId === input.turn.threadId && (!item.turnId || item.turnId === input.turn.id)) { + items.set(item.id, item); + } + } + + const toolCallItemIds = new Map(); + const approvalItemIds = new Map(); + let assistantItem: Extract | undefined; + let assistantBody = ""; + let assistantSequence = Number.POSITIVE_INFINITY; + let assistantUpdatedAt = input.turn.updatedAt; + + for (const event of [...input.events].sort((left, right) => left.seq - right.seq)) { + switch (event.type) { + case "assistant_text_delta": { + assistantBody += event.data.text; + assistantSequence = Math.min(assistantSequence, event.seq); + assistantUpdatedAt = event.ts; + assistantItem = upsertAssistantItem({ + input, + items, + existing: assistantItem, + body: assistantBody, + sequence: assistantSequence, + updatedAt: assistantUpdatedAt, + streaming: true, + }); + break; + } + case "message_end": { + if (event.data.role === undefined || event.data.role === "assistant") { + assistantSequence = Math.min(assistantSequence, event.seq); + assistantUpdatedAt = event.ts; + assistantItem = upsertAssistantItem({ + input, + items, + existing: assistantItem, + body: event.data.text !== undefined ? event.data.text : assistantBody, + sequence: assistantSequence, + updatedAt: assistantUpdatedAt, + streaming: false, + errorMessage: event.data.errorMessage, + }); + } + break; + } + case "tool_start": { + const toolCallId = event.data.toolCallId ?? `event_${event.seq}`; + const id = deterministicItemId(input.run.id, "tool_call", toolCallId); + toolCallItemIds.set(toolCallId, id); + items.set(id, makeItem({ + input, + id, + sequence: event.seq, + kind: "tool_call", + payload: { + toolCallId, + toolName: event.data.toolName, + title: event.data.toolName, + ...(event.data.args !== undefined ? { inputSummary: summarizeOpaque(event.data.args) } : {}), + }, + domainRefs: [runRef(input.run.id), turnRef(input.turn.id)], + createdAt: event.ts, + updatedAt: event.ts, + })); + break; + } + case "tool_end": { + const toolCallId = event.data.toolCallId ?? `event_${event.seq}`; + const id = deterministicItemId(input.run.id, "tool_result", toolCallId); + const toolCallIdRef = toolCallItemIds.get(toolCallId); + items.set(id, makeItem({ + input, + id, + sequence: event.seq, + kind: "tool_result", + payload: { + toolCallId, + status: event.data.isError ? "failed" : "succeeded", + ...(event.data.result !== undefined ? { outputSummary: summarizeOpaque(event.data.result) } : {}), + }, + domainRefs: [runRef(input.run.id), turnRef(input.turn.id), ...(toolCallIdRef ? [itemRef(toolCallIdRef)] : [])], + createdAt: event.ts, + updatedAt: event.ts, + })); + break; + } + case "approval_requested": { + const id = deterministicItemId(input.run.id, "approval_request", event.data.approvalId); + approvalItemIds.set(event.data.approvalId, id); + items.set(id, makeItem({ + input, + id, + sequence: event.seq, + kind: "approval_request", + payload: { + approvalRequestId: event.data.approvalId, + title: event.data.title ?? "Approval required", + ...(event.data.description ? { summary: event.data.description } : {}), + }, + domainRefs: [runRef(input.run.id), turnRef(input.turn.id), approvalRef(event.data.approvalId)], + createdAt: event.ts, + updatedAt: event.ts, + })); + break; + } + case "approval_approved": + case "approval_denied": + case "approval_cancelled": + case "approval_expired": { + const approvalId = event.data.approvalId; + const id = deterministicItemId(input.run.id, "approval_decision", approvalId); + items.set(id, makeItem({ + input, + id, + sequence: event.seq, + kind: "approval_decision", + payload: { + approvalRequestId: approvalId, + decision: event.type === "approval_approved" ? "approved" : event.type === "approval_cancelled" ? "cancelled" : "denied", + ...(event.data.decisionReason ? { reason: event.data.decisionReason } : {}), + }, + domainRefs: [runRef(input.run.id), turnRef(input.turn.id), approvalRef(approvalId), ...(approvalItemIds.has(approvalId) ? [itemRef(approvalItemIds.get(approvalId)!)] : [])], + createdAt: event.ts, + updatedAt: event.ts, + })); + break; + } + case "artifact_created": { + const artifactId = readArtifactId(event.data.artifactId, event.data.artifact); + if (!artifactId) break; + const id = deterministicItemId(input.run.id, "artifact_ref", artifactId); + items.set(id, makeItem({ + input, + id, + sequence: event.seq, + kind: "artifact_ref", + payload: { + artifactId, + ...(event.data.toolName ? { title: event.data.toolName } : {}), + ...(readArtifactKind(event.data.artifact) ? { artifactKind: readArtifactKind(event.data.artifact) } : {}), + }, + domainRefs: [runRef(input.run.id), turnRef(input.turn.id), { type: "artifact", id: artifactId }], + createdAt: event.ts, + updatedAt: event.ts, + })); + break; + } + case "run_end": { + const item = makeOperationStateItem(input, event.seq, event.ts, "succeeded", "Run completed"); + items.set(item.id, item); + break; + } + case "run_cancelled": { + const item = makeOperationStateItem(input, event.seq, event.ts, "cancelled", event.data.reason ?? "Run cancelled"); + items.set(item.id, item); + break; + } + case "run_error": { + const item = makeOperationStateItem(input, event.seq, event.ts, "failed", event.data.message); + items.set(item.id, item); + break; + } + case "run_intervention_created": + case "run_followup_queued": + case "run_interrupt_requested": + case "run_continuation_starting": + case "run_continuation_started": + case "run_intervention_failed": { + const operationId = event.data.interventionId ?? `event_${event.seq}`; + const state = event.type === "run_intervention_failed" ? "failed" : event.type === "run_continuation_started" ? "running" : "pending"; + items.set(deterministicItemId(input.run.id, "operation_state", operationId), makeOperationStateItem(input, event.seq, event.ts, state, event.type, operationId)); + break; + } + default: + // run_created/start, thinking, hydration, tool_update, result and + // stale diagnostics remain execution evidence rather than timeline + // items. They are still available through the typed event stream. + break; + } + } + + return [...items.values()].sort((left, right) => left.sequence - right.sequence || left.id.localeCompare(right.id)); +} + +function upsertAssistantItem(input: { + readonly input: ProjectRunEventsToConversationItemsInput; + readonly items: Map; + readonly existing?: Extract; + readonly body: string; + readonly sequence: number; + readonly updatedAt: string; + readonly streaming: boolean; + readonly errorMessage?: string; +}): Extract { + const id = deterministicItemId(input.input.run.id, "assistant_message", "run"); + const existing = input.existing ?? input.items.get(id); + const deliveryStatus: ConversationAssistantMessagePayloadV1["deliveryStatus"] = input.errorMessage + ? "failed" + : input.streaming + ? "pending" + : "ready"; + const item = makeItem({ + input: input.input, + id, + sequence: Number.isFinite(input.sequence) ? input.sequence : 1, + kind: "assistant_message", + payload: { + body: input.body, + format: "markdown", + streaming: input.streaming, + deliveryStatus, + ...(input.errorMessage ? { errorMessage: input.errorMessage } : {}), + }, + domainRefs: [runRef(input.input.run.id), turnRef(input.input.turn.id)], + createdAt: existing?.createdAt ?? input.updatedAt, + updatedAt: input.updatedAt, + revision: existing?.revision ?? 0, + }); + input.items.set(id, item); + return item; +} + +function makeOperationStateItem( + input: ProjectRunEventsToConversationItemsInput, + sequence: number, + updatedAt: string, + state: "pending" | "running" | "succeeded" | "failed" | "cancelled", + summary: string, + operationId = input.run.id, +): Extract { + return makeItem({ + input, + id: deterministicItemId(input.run.id, "operation_state", operationId), + sequence, + kind: "operation_state", + payload: { operationId, state, title: "Run operation", summary }, + domainRefs: [runRef(input.run.id), turnRef(input.turn.id)], + createdAt: updatedAt, + updatedAt, + }); +} + +function makeItem(input: { + readonly input: ProjectRunEventsToConversationItemsInput; + readonly id: string; + readonly sequence: number; + readonly kind: K; + readonly payload: Extract["payload"]; + readonly domainRefs: readonly ConversationDomainRef[]; + readonly createdAt: string; + readonly updatedAt: string; + readonly revision?: number; +}): Extract { + const channelId = input.input.turn.channelId ?? input.input.run.channelId; + const visibility: ConversationItemVisibilityV1 = channelId ? "channel" : "thread_participants"; + return { + schemaVersion: 1, + id: input.id, + workspaceId: input.input.turn.workspaceId, + ...(channelId ? { channelId } : {}), + threadId: input.input.turn.threadId, + turnId: input.input.turn.id, + sequence: input.sequence, + kind: input.kind, + status: "committed", + actorType: "ling_agent", + visibility, + payload: input.payload, + domainRefs: input.domainRefs, + revision: input.revision ?? 0, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + committedAt: input.updatedAt, + } as Extract; +} + +function deterministicItemId(runId: string, kind: string, key: string): string { + const digest = createHash("sha256").update(`${runId}\0${kind}\0${key}`).digest("hex").slice(0, 24); + return `projection_${kind}_${digest}`; +} + +function runRef(id: string): ConversationDomainRef { + return { type: "run", id }; +} + +function turnRef(id: string): ConversationDomainRef { + return { type: "turn", id }; +} + +function approvalRef(id: string): ConversationDomainRef { + return { type: "approval", id }; +} + +function itemRef(id: string): ConversationDomainRef { + return { type: "conversation_item", id }; +} + +function summarizeOpaque(value: unknown): string { + if (typeof value === "string") return value.slice(0, 500); + try { + return JSON.stringify(value).slice(0, 500); + } catch { + return "[unserializable]"; + } +} + +function readArtifactId(explicitId: string | undefined, artifact: unknown): string | undefined { + if (explicitId) return explicitId; + if (typeof artifact === "object" && artifact !== null && !Array.isArray(artifact)) { + const id = (artifact as Record).id; + return typeof id === "string" && id.length > 0 ? id : undefined; + } + return undefined; +} + +function readArtifactKind(artifact: unknown): string | undefined { + if (typeof artifact !== "object" || artifact === null || Array.isArray(artifact)) return undefined; + const type = (artifact as Record).type; + return typeof type === "string" ? type : undefined; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 31bd11fb2..c37a8c301 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -83,6 +83,7 @@ export * from "./terminal-report-publication-types.js"; export * from "./workflow-completion-read-set.js"; export * from "./conversation-revision-types.js"; export * from "./conversation-item-store-types.js"; +export * from "./conversation-item-run-event-projection.js"; export * from "./conversation-turn-ingest-types.js"; export * from "./conversation-thread-topology-types.js"; export * from "./conversation-context-store-types.js"; diff --git a/packages/core/src/run-types.ts b/packages/core/src/run-types.ts index 6fa086601..710b76ce4 100644 --- a/packages/core/src/run-types.ts +++ b/packages/core/src/run-types.ts @@ -3,6 +3,9 @@ import type { ConversationItemV1, LingThreadRelationKindV2, LingThreadSurfaceKindV2, + RunEventDataByType as ContractRunEventDataByType, + RunEventEnvelope as ContractRunEventEnvelope, + RunEventType as ContractRunEventType, } from "@ling-stack/contracts"; import type { ApprovalActionType, @@ -598,39 +601,13 @@ export interface TransitionLingTurnStatusInput { readonly now: string; } -export type RunEventType = - | "run_created" - | "run_start" - | "turn_start" - | "turn_end" - | "assistant_text_delta" - | "assistant_thinking_delta" - | "tool_hydration_request" - | "tool_hydration_end" - | "message_end" - | "tool_start" - | "tool_update" - | "tool_end" - | "run_end" - | "run_error" - | "run_cancelled" - | "approval_requested" - | "approval_approved" - | "approval_denied" - | "approval_expired" - | "approval_cancelled" - | "artifact_created" - | "workspace_change_recovery_started" - | "workspace_changes_undone" - | "workspace_changes_reapplied" - | "run_intervention_created" - | "run_followup_queued" - | "run_interrupt_requested" - | "run_continuation_starting" - | "run_continuation_started" - | "run_intervention_failed" - | "stale_runner_completion_ignored" - | "result"; +export type RunEventType = ContractRunEventType; +export type RunEventDataByType = ContractRunEventDataByType; +export type RunEventData = RunEventDataByType[RunEventType]; +export type RunEventEmitter = ( + type: K, + data: RunEventDataByType[K], +) => RunEventEnvelope | undefined; export const runInterventionModes = ["queue_followup", "interrupt_redirect"] as const; export type RunInterventionMode = (typeof runInterventionModes)[number]; @@ -1129,14 +1106,7 @@ export interface RunSession { readonly updatedAt: string; } -export interface RunEventEnvelope { - readonly id: string; - readonly runId: string; - readonly seq: number; - readonly ts: string; - readonly type: RunEventType; - readonly data: unknown; -} +export type RunEventEnvelope = ContractRunEventEnvelope; export interface CreateRunInput { readonly id: string; @@ -1502,7 +1472,7 @@ export interface RunStore { markRunSucceeded(runId: string, endedAt: string, summaryPath?: string): void; markRunFailed(runId: string, endedAt: string, errorMessage: string, summaryPath?: string): void; markRunCancelled(runId: string, endedAt: string, summaryPath?: string): void; - appendEvent(runId: string, type: RunEventType, data: unknown): RunEventEnvelope; + appendEvent(runId: string, type: K, data: RunEventDataByType[K]): Extract; appendControlPlaneEvent(input: AppendControlPlaneEventInput, now: string): ControlPlaneEvent; getControlPlaneEventByIdempotency(workspaceId: string, idempotencyKey: string): ControlPlaneEvent | undefined; listControlPlaneEvents(options: ListControlPlaneEventsOptions): ControlPlaneEvent[]; diff --git a/packages/core/src/storage/sqlite-run-store.ts b/packages/core/src/storage/sqlite-run-store.ts index 9641c16da..6a98efba5 100644 --- a/packages/core/src/storage/sqlite-run-store.ts +++ b/packages/core/src/storage/sqlite-run-store.ts @@ -1625,7 +1625,11 @@ export class SqliteRunStore implements RunStore { this.recordRunSkillUsage(this.getRun(runId), "cancelled", endedAt); } - appendEvent(runId: string, type: RunEventType, data: unknown): RunEventEnvelope { + appendEvent( + runId: string, + type: K, + data: import("../run-types.js").RunEventDataByType[K], + ): Extract { return this.runEvents.appendEvent(runId, type, data); } diff --git a/packages/core/src/storage/sqlite/run-event-store.ts b/packages/core/src/storage/sqlite/run-event-store.ts index 8b5726a8d..20d7e0950 100644 --- a/packages/core/src/storage/sqlite/run-event-store.ts +++ b/packages/core/src/storage/sqlite/run-event-store.ts @@ -1,6 +1,8 @@ import type { SQLOutputValue } from "node:sqlite"; +import { isRunEventData } from "@ling-stack/contracts"; import { type ListRunEventsOptions, + type RunEventDataByType, type RunEventEnvelope, type RunEventType, type RunSession, @@ -23,20 +25,28 @@ export class SqliteRunEventStore { private readonly ports: SqliteRunEventStorePorts, ) {} - appendEvent(runId: string, type: RunEventType, data: unknown): RunEventEnvelope { + appendEvent( + runId: string, + type: K, + data: RunEventDataByType[K], + ): Extract { const run = this.ports.getRun(runId); if (!run) { throw new Error(`Run session not found: ${runId}`); } - const envelope: RunEventEnvelope = { + const redactedData = normalizeLegacyEventPayload(type, redactSecrets(data)); + if (!isRunEventData(type, redactedData)) { + throw new Error(`Invalid run event payload for ${type}`); + } + const envelope = { id: createEventId(), runId, seq: this.nextEventSeq(runId), ts: new Date().toISOString(), type, - data: redactSecrets(data), - }; + data: redactedData, + } as unknown as Extract; this.context.database .prepare("INSERT INTO run_events (id, run_id, seq, ts, type, data_json) VALUES (?, ?, ?, ?, ?, ?)") @@ -100,3 +110,13 @@ export class SqliteRunEventStore { return readNumber(row as Record, "next_seq"); } } + +function normalizeLegacyEventPayload(type: K, data: unknown): RunEventDataByType[K] { + // Older callers emitted tool_end without an explicit success flag. Preserve + // those rows while making the persisted typed payload total and deterministic. + if (type === "tool_end" && typeof data === "object" && data !== null && !Array.isArray(data)) { + const record = data as Record; + return { ...record, isError: record.isError ?? false } as RunEventDataByType[K]; + } + return data as RunEventDataByType[K]; +} diff --git a/packages/core/src/storage/sqlite/run-mappers.ts b/packages/core/src/storage/sqlite/run-mappers.ts index 4455d85de..102eee6d6 100644 --- a/packages/core/src/storage/sqlite/run-mappers.ts +++ b/packages/core/src/storage/sqlite/run-mappers.ts @@ -1,6 +1,6 @@ import type { SQLOutputValue } from "node:sqlite"; -import type { LingThreadRelationKindV2, LingThreadSurfaceKindV2 } from "@ling-stack/contracts"; -import { conversationModeKinds, runContinuationReasons, type ConversationModeKind, type ConversationModeState, type GoalContinuationEvidenceDelta, type GoalContinuationStopReason, type GoalEvidenceRef, type GoalUsageSource, type LingThread, type LingThreadGoal, type LingThreadGoalCompletionActor, type LingThreadGoalStatus, type LingThreadKind, type LingThreadMetadata, type LingThreadStatus, type LingTurn, type LingTurnKind, type LingTurnRun, type LingTurnRunRole, type LingTurnStatus, type ProposedPlanArtifact, type ProposedPlanArtifactStatus, type RunContinuationReason, type RunEventEnvelope, type RunEventType, type RunSession, type RunStatus } from "../../run-types.js"; +import { isRunEventData, isRunEventType, type LingThreadRelationKindV2, type LingThreadSurfaceKindV2 } from "@ling-stack/contracts"; +import { conversationModeKinds, runContinuationReasons, type ConversationModeKind, type ConversationModeState, type GoalContinuationEvidenceDelta, type GoalContinuationStopReason, type GoalEvidenceRef, type GoalUsageSource, type LingThread, type LingThreadGoal, type LingThreadGoalCompletionActor, type LingThreadGoalStatus, type LingThreadKind, type LingThreadMetadata, type LingThreadStatus, type LingTurn, type LingTurnKind, type LingTurnRun, type LingTurnRunRole, type LingTurnStatus, type ProposedPlanArtifact, type ProposedPlanArtifactStatus, type RunContinuationReason, type RunEventEnvelope, type RunSession, type RunStatus } from "../../run-types.js"; import { omitUndefined, parseJson, readNullableNumber, readNullableString, readNumber, readString } from "./helpers.js"; export function mapLingThread(row: Record): LingThread { @@ -236,14 +236,25 @@ export function mapRunSession(row: Record): RunSession { } export function mapRunEvent(row: Record): RunEventEnvelope { + const type = readString(row, "type"); + if (!isRunEventType(type)) { + throw new Error(`Unknown run event type from SQLite: ${type}`); + } + const data = parseJson(readString(row, "data_json")); + if (!isRunEventData(type, data)) { + throw new Error(`Invalid run event payload from SQLite: ${type}`); + } return { id: readString(row, "id"), runId: readString(row, "run_id"), seq: readNumber(row, "seq"), ts: readString(row, "ts"), - type: readString(row, "type") as RunEventType, - data: parseJson(readString(row, "data_json")), - }; + type, + // SQLite stores the envelope payload as JSON. The boundary check above + // rejects malformed rows; the discriminated union is preserved for all + // typed consumers after the dynamic type column has been read. + data, + } as RunEventEnvelope; } function parseOptionalLingThreadMetadata(value: string | undefined): LingThreadMetadata | undefined { diff --git a/packages/core/src/storage/sqlite/runtime-recovery-coordinator.ts b/packages/core/src/storage/sqlite/runtime-recovery-coordinator.ts index 1dde5a11b..d43bca31e 100644 --- a/packages/core/src/storage/sqlite/runtime-recovery-coordinator.ts +++ b/packages/core/src/storage/sqlite/runtime-recovery-coordinator.ts @@ -2,6 +2,7 @@ import type { LingApprovalCheckpoint } from "../../approval-checkpoints.js"; import type { ApprovalRequest, ListApprovalRequestsOptions } from "../../approval-types.js"; import { type LingThread, + type RunEventDataByType, type RunEventType, type RunSession, type UpdateLingThreadInput, @@ -26,7 +27,7 @@ export interface SqliteRuntimeRecoveryCoordinatorPorts { readonly expireResourceLeases: (now: string) => number; readonly listRecoverableRunIds: () => string[]; readonly hasTerminalRunEvent: (runId: string) => boolean; - readonly appendRunEvent: (runId: string, type: RunEventType, data: unknown) => unknown; + readonly appendRunEvent: (runId: string, type: K, data: RunEventDataByType[K]) => unknown; readonly markRunFailed: (runId: string, endedAt: string, errorMessage: string) => void; readonly getRun: (runId: string) => RunSession | undefined; readonly getLingThread: (threadId: string) => LingThread | undefined; diff --git a/packages/core/test/conversation-item-run-event-projection.test.mjs b/packages/core/test/conversation-item-run-event-projection.test.mjs new file mode 100644 index 000000000..d2164e500 --- /dev/null +++ b/packages/core/test/conversation-item-run-event-projection.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { projectRunEventsToConversationItems } from "../dist/index.js"; + +const run = { + id: "run_projection_1", + status: "running", + cwd: "/tmp/workspace", + taskId: "task_projection_1", + providerName: "test", + providerModel: "test-model", + taskSnapshot: {}, + providerSnapshot: {}, + requestSnapshot: {}, + eventLogPath: "/tmp/run-events.jsonl", + createdAt: "2026-08-25T00:00:00.000Z", + updatedAt: "2026-08-25T00:00:00.000Z", +}; + +const turn = { + id: "turn_projection_1", + workspaceId: "workspace_default", + threadId: "thread_projection_1", + kind: "conversation", + status: "running", + title: "Projection", + userInstruction: "Inspect the workspace", + createdByActor: "local-user", + createdAt: "2026-08-25T00:00:00.000Z", + updatedAt: "2026-08-25T00:00:00.000Z", +}; + +function event(seq, type, data) { + return { + id: `event-${seq}`, + runId: run.id, + seq, + ts: `2026-08-25T00:00:0${seq}.000Z`, + type, + data, + }; +} + +test("run evidence projects to deterministic typed items and replays idempotently", () => { + const events = [ + event(1, "assistant_text_delta", { text: "Inspect " }), + event(2, "assistant_text_delta", { text: "complete." }), + event(3, "message_end", { role: "assistant" }), + event(4, "tool_start", { toolCallId: "call-1", toolName: "bash", args: { command: "pwd" } }), + event(5, "tool_end", { toolCallId: "call-1", toolName: "bash", result: "/tmp/workspace", isError: false }), + event(6, "approval_requested", { approvalId: "approval-1", title: "Confirm write" }), + event(7, "approval_denied", { approvalId: "approval-1", decisionReason: "not now" }), + event(8, "artifact_created", { artifactId: "artifact-1", toolName: "bash", artifact: { type: "file" } }), + event(9, "run_end", { messageCount: 1 }), + ]; + + const first = projectRunEventsToConversationItems({ run, turn, events }); + const replay = projectRunEventsToConversationItems({ run, turn, events, existingItems: first }); + + assert.deepEqual(replay, first); + assert.deepEqual(first.map((item) => item.kind), [ + "assistant_message", + "tool_call", + "tool_result", + "approval_request", + "approval_decision", + "artifact_ref", + "operation_state", + ]); + const assistant = first.find((item) => item.kind === "assistant_message"); + assert.equal(assistant.payload.body, "Inspect complete."); + assert.equal(assistant.payload.streaming, false); + const approval = first.find((item) => item.kind === "approval_decision"); + assert.equal(approval.payload.decision, "denied"); + assert.ok(first.every((item) => item.domainRefs.some((ref) => ref.type === "run" && ref.id === run.id))); +}); + +test("unpaired tool completion remains renderable", () => { + const items = projectRunEventsToConversationItems({ + run, + turn, + events: [event(1, "tool_end", { toolCallId: "missing-start", toolName: "bash", isError: true, result: "failed" })], + }); + assert.equal(items.length, 1); + assert.equal(items[0].kind, "tool_result"); + assert.equal(items[0].payload.status, "failed"); +}); diff --git a/packages/core/test/run-event-store-typed.test.mjs b/packages/core/test/run-event-store-typed.test.mjs new file mode 100644 index 000000000..2466dee6c --- /dev/null +++ b/packages/core/test/run-event-store-typed.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; +import { createSqliteRunStore } from "../dist/index.js"; + +test("sqlite run events keep typed payloads across reopen and reject malformed payloads", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-typed-events-")); + const now = "2026-08-25T00:00:00.000Z"; + try { + const store = createSqliteRunStore(cwd); + store.createRun({ + id: "run_typed_events", + cwd, + taskId: "task_typed_events", + providerName: "test", + providerModel: "test-model", + taskSnapshot: {}, + providerSnapshot: {}, + requestSnapshot: {}, + createdAt: now, + }); + store.appendEvent("run_typed_events", "assistant_text_delta", { text: "hello" }); + store.appendEvent("run_typed_events", "tool_end", { + toolCallId: "call-1", + toolName: "bash", + result: { stdout: "ok" }, + isError: false, + }); + assert.throws( + () => store.appendEvent("run_typed_events", "assistant_text_delta", { text: 42 }), + /Invalid run event payload/, + ); + assert.deepEqual(store.listEvents("run_typed_events", { limit: 10 }).map((event) => [event.type, event.data]), [ + ["assistant_text_delta", { text: "hello" }], + ["tool_end", { toolCallId: "call-1", toolName: "bash", result: { stdout: "ok" }, isError: false }], + ]); + store.close(); + + const reopened = createSqliteRunStore(cwd); + try { + const events = reopened.listEvents("run_typed_events", { limit: 10 }); + assert.equal(events[0].type, "assistant_text_delta"); + assert.equal(events[0].data.text, "hello"); + assert.equal(events[1].type, "tool_end"); + assert.equal(events[1].data.isError, false); + } finally { + reopened.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/daemon/src/features/channels/channel-reply-dispatch.ts b/packages/daemon/src/features/channels/channel-reply-dispatch.ts index e33222dc7..e04b5c3d2 100644 --- a/packages/daemon/src/features/channels/channel-reply-dispatch.ts +++ b/packages/daemon/src/features/channels/channel-reply-dispatch.ts @@ -3,6 +3,7 @@ import type { ChannelMessage, RunInterventionMessagePayload, RunSession, + RunEventDataByType, RunStore, } from "@ling-stack/core"; import { resolveAiTeammateReference } from "../teammates/identity.js"; @@ -122,7 +123,7 @@ function appendInterventionEvent( store: RunStore, runId: string, type: "run_intervention_created" | "run_followup_queued", - data: unknown, + data: RunEventDataByType[typeof type], ): void { try { store.appendEvent(runId, type, data); diff --git a/packages/daemon/src/features/runs/action-artifacts.ts b/packages/daemon/src/features/runs/action-artifacts.ts index 42f2cb45a..1022bd6ff 100644 --- a/packages/daemon/src/features/runs/action-artifacts.ts +++ b/packages/daemon/src/features/runs/action-artifacts.ts @@ -7,6 +7,7 @@ import { type RunExecutionStoreFacet, type RunArtifact, type RunEventEnvelope, + type RunEventEmitter, type RunStorePathsFacet, type RunSession, } from "@ling-stack/core"; @@ -96,7 +97,7 @@ export function appendExecutionActionArtifactEvents(input: { readonly artifacts: readonly RunArtifact[]; readonly approval: ApprovalRequest; readonly role: "preview" | "result"; - readonly appendAndMaybeStream: (type: "artifact_created", data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; }): void { for (const artifact of input.artifacts) { input.appendAndMaybeStream("artifact_created", { diff --git a/packages/daemon/src/features/runs/approval-resume-coordinator.ts b/packages/daemon/src/features/runs/approval-resume-coordinator.ts index 8c228c866..6cc36b2bb 100644 --- a/packages/daemon/src/features/runs/approval-resume-coordinator.ts +++ b/packages/daemon/src/features/runs/approval-resume-coordinator.ts @@ -5,6 +5,8 @@ import { type ApprovalRequest, type LingApprovalCheckpoint, type RunEventEnvelope, + type RunEventDataByType, + type RunEventEmitter, type RunSession, type RunStore, type WorkUnit, @@ -276,7 +278,10 @@ export class ApprovalResumeCoordinator { }): Promise { const payload = input.approval.payload; if (payload.type !== "bash") return; - const append = (type: Parameters[1], data: unknown): RunEventEnvelope => { + const append: RunEventEmitter = [1]>( + type: K, + data: RunEventDataByType[K], + ): RunEventEnvelope => { const envelope = input.store.appendEvent(input.run.id, type, data); syncWorkUnitForRunEvent(input.store, input.workUnit.id, input.attempt.id, type, envelope); appendRuntimeProgressForRunEvent(input.store, input.workUnit.id, input.attempt.id, type, envelope); diff --git a/packages/daemon/src/features/runs/approvals.ts b/packages/daemon/src/features/runs/approvals.ts index bf4c779a9..c9bf2e364 100644 --- a/packages/daemon/src/features/runs/approvals.ts +++ b/packages/daemon/src/features/runs/approvals.ts @@ -17,6 +17,8 @@ import { type EffectiveRunPolicySnapshot, type ExternalWorkStoreFacet, type RunEventEnvelope, + type RunEventDataByType, + type RunEventEmitter, type RunEventType, type RunExecutionStoreFacet, type RunSession, @@ -147,7 +149,7 @@ export function createRunApprovalHandler(options: { readonly executionOperationBindings?: readonly ChannelIntentExecutionOperationV1[]; readonly approvalPolicy: ApprovalPolicy; readonly effectivePolicySnapshot?: EffectiveRunPolicySnapshot; - readonly appendAndMaybeStream: (type: RunEventType, data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; readonly streamEnvelope: (envelope: RunEventEnvelope) => void; readonly signal?: AbortSignal; readonly extraSecrets?: readonly (string | undefined)[]; @@ -757,7 +759,10 @@ function approvalDecisionEventType(approval: ApprovalRequest): Extract { +function approvalRequestEventPayload( + approval: ApprovalRequest, + previewArtifacts: readonly { readonly id: string }[] = [], +): RunEventDataByType["approval_requested"] { return { approvalId: approval.id, toolCallId: approval.toolCallId, @@ -1033,7 +1038,7 @@ export function streamOrAppendApprovalDecisionEvent(options: { readonly store: RunStore; readonly approval: ApprovalRequest; readonly afterSeq: number; - readonly appendAndMaybeStream: (type: RunEventType, data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; readonly streamEnvelope: (envelope: RunEventEnvelope) => void; readonly onExistingEvent?: (envelope: RunEventEnvelope) => void; }): void { diff --git a/packages/daemon/src/features/runs/interventions.ts b/packages/daemon/src/features/runs/interventions.ts index 4dd59c4cf..757981d16 100644 --- a/packages/daemon/src/features/runs/interventions.ts +++ b/packages/daemon/src/features/runs/interventions.ts @@ -6,6 +6,7 @@ import { type GoalContinuationStopReason, type LingThreadGoal, type RunEventType, + type RunEventDataByType, type RunIntervention, type RunInterventionAttachmentPayload, type RunInterventionLinkPreviewPayload, @@ -898,7 +899,7 @@ function streamRunEventIfPossible( activeRuns: ActiveRunRegistry, runId: string, type: RunEventType, - data: unknown, + data: RunEventDataByType[typeof type], ): void { try { const envelope = store.appendEvent(runId, type, data); diff --git a/packages/daemon/src/features/runs/orchestrator.ts b/packages/daemon/src/features/runs/orchestrator.ts index 703866b9c..cb20c6b3e 100644 --- a/packages/daemon/src/features/runs/orchestrator.ts +++ b/packages/daemon/src/features/runs/orchestrator.ts @@ -15,6 +15,8 @@ import { type LingThread, type LingTurn, type RunEventEnvelope, + type RunEventDataByType, + type RunEventEmitter, type RunEventType, type RunArtifact, type RunSession, @@ -1021,7 +1023,10 @@ export async function executeRunRequest( requestRuntimeAbort("hard_timeout"); return true; }; - const appendAndMaybeStream = (type: RunEventType, data: unknown): RunEventEnvelope | undefined => { + const appendAndMaybeStream: RunEventEmitter = ( + type: K, + data: RunEventDataByType[K], + ): RunEventEnvelope | undefined => { if (persistenceError) { return undefined; } @@ -1033,7 +1038,11 @@ export async function executeRunRequest( } try { - const envelope = store.appendEvent(run.id, type, redactSecrets(data, resolvedSecretValues)); + const envelope = store.appendEvent( + run.id, + type, + redactSecrets(data, resolvedSecretValues) as RunEventDataByType[K], + ); lastRuntimeActivityAt = Date.now(); if (type === "approval_requested") { // The approval handler starts this pause before persistence. Keep the @@ -1221,7 +1230,10 @@ export async function executeRunRequest( if (event.type === "tool_start") { toolStartArgsById.set(event.toolCallId, event.args); } - const eventEnvelope = appendAndMaybeStream(event.type, event); + const eventEnvelope = appendAndMaybeStream( + event.type, + { ...event } as RunEventDataByType[typeof event.type], + ); if (eventEnvelope && event.type === "tool_end") { const sourceArgs = toolStartArgsById.get(event.toolCallId); try { diff --git a/packages/daemon/src/features/runs/permission-requests.ts b/packages/daemon/src/features/runs/permission-requests.ts index 0e5feb930..5fa7024ba 100644 --- a/packages/daemon/src/features/runs/permission-requests.ts +++ b/packages/daemon/src/features/runs/permission-requests.ts @@ -10,7 +10,8 @@ import { type CreateApprovalRequestInput, type EffectiveRunPolicySnapshot, type RunEventEnvelope, - type RunEventType, + type RunEventDataByType, + type RunEventEmitter, type RunSession, type RunStore, type RunTool, @@ -40,7 +41,7 @@ export function createRunPermissionRequestHandler(options: { readonly run: RunSession; readonly approvalPolicy: ApprovalPolicy; readonly effectivePolicySnapshot?: EffectiveRunPolicySnapshot; - readonly appendAndMaybeStream: (type: RunEventType, data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; readonly streamEnvelope: (envelope: RunEventEnvelope) => void; readonly signal?: AbortSignal; readonly extraSecrets?: readonly (string | undefined)[]; @@ -267,7 +268,7 @@ function createAutoDeniedPermissionApproval(options: { readonly extraSecrets?: readonly (string | undefined)[]; readonly controlPlaneEvents?: ControlPlaneEventHub; readonly workspaceCwd?: string; - readonly appendAndMaybeStream: (type: RunEventType, data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; readonly streamEnvelope: (envelope: RunEventEnvelope) => void; }): ApprovalRequest { const createdAt = new Date().toISOString(); @@ -353,7 +354,7 @@ function approvalDescription(payload: RuntimePermissionRequestPayload): string { return `The runner requested ${summarizeRuntimePermissionScope(payload.requestedScope)}.`; } -function approvalRequestEventPayload(approval: ApprovalRequest): Record { +function approvalRequestEventPayload(approval: ApprovalRequest): RunEventDataByType["approval_requested"] { return { approvalId: approval.id, toolCallId: approval.toolCallId, diff --git a/packages/daemon/src/features/runs/shell-snapshots.ts b/packages/daemon/src/features/runs/shell-snapshots.ts index 3bc9fb77f..b4d64a4da 100644 --- a/packages/daemon/src/features/runs/shell-snapshots.ts +++ b/packages/daemon/src/features/runs/shell-snapshots.ts @@ -5,6 +5,7 @@ import { type ArtifactType, type RunArtifact, type RunEventEnvelope, + type RunEventEmitter, type RunSession, type RunStore, type ShellOutputSnapshot, @@ -44,7 +45,7 @@ export function createShellSnapshotWriter(input: { readonly run: RunSession; readonly request: UnifiedExecRequest; readonly environmentSummary: UnifiedExecEnvironmentSummary; - readonly appendAndMaybeStream: (type: "artifact_created", data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; readonly extraSecrets?: readonly (string | undefined)[]; }): ShellSnapshotWriter { if (!input.enabled) { @@ -320,7 +321,7 @@ function writeSnapshotArtifact(input: { function appendSnapshotArtifactEvents(input: { readonly artifacts: readonly { readonly artifact: RunArtifact; readonly role: "shell_snapshot" | "shell_snapshot_text" }[]; readonly snapshot: ShellSnapshot; - readonly appendAndMaybeStream: (type: "artifact_created", data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; }): void { for (const item of input.artifacts) { input.appendAndMaybeStream("artifact_created", { diff --git a/packages/daemon/src/features/runs/stale-completion.ts b/packages/daemon/src/features/runs/stale-completion.ts index 80bf533db..7bb12a1a0 100644 --- a/packages/daemon/src/features/runs/stale-completion.ts +++ b/packages/daemon/src/features/runs/stale-completion.ts @@ -1,5 +1,6 @@ import type { RunEventEnvelope, + RunEventDataByType, RunnerFallbackReason, RunSession, RunStatus, @@ -18,7 +19,7 @@ export type StaleRunnerCompletionReason = | "work_unit_cancelled" | "work_unit_stale"; -export interface StaleRunnerCompletionIgnoredPayload { +export type StaleRunnerCompletionIgnoredPayload = RunEventDataByType["stale_runner_completion_ignored"] & { readonly runId: string; readonly workUnitId?: string; readonly attemptId?: string; @@ -31,7 +32,7 @@ export interface StaleRunnerCompletionIgnoredPayload { readonly effectiveRunnerId?: string; readonly fallbackReason?: RunnerFallbackReason; readonly reportedAt: string; -} +}; export function auditIgnoredRunnerCompletion(input: { readonly store: RunStore; diff --git a/packages/daemon/src/features/runs/unified-exec.ts b/packages/daemon/src/features/runs/unified-exec.ts index 200698879..f13e45c3c 100644 --- a/packages/daemon/src/features/runs/unified-exec.ts +++ b/packages/daemon/src/features/runs/unified-exec.ts @@ -6,6 +6,7 @@ import { type ArtifactType, type RunArtifact, type RunEventEnvelope, + type RunEventEmitter, type RunSession, type RunStore, type UnifiedExecArtifactSummary, @@ -35,7 +36,7 @@ export async function executeUnifiedBashTool(input: { readonly run: RunSession; readonly request: PiRunnerApprovalRequest; readonly approvalId?: string; - readonly appendAndMaybeStream: (type: "tool_update" | "artifact_created", data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; readonly signal?: AbortSignal; readonly extraSecrets?: readonly (string | undefined)[]; readonly shellSnapshotEnabled?: boolean; @@ -116,7 +117,7 @@ async function executeUnifiedExec(input: { readonly store: RunStore; readonly run: RunSession; readonly request: UnifiedExecRequest; - readonly appendAndMaybeStream: (type: "tool_update" | "artifact_created", data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; readonly signal?: AbortSignal; readonly extraSecrets?: readonly (string | undefined)[]; readonly shellSnapshotEnabled?: boolean; @@ -282,7 +283,7 @@ function writeSyntheticFailureResult(input: { readonly store: RunStore; readonly run: RunSession; readonly request: UnifiedExecRequest; - readonly appendAndMaybeStream: (type: "tool_update" | "artifact_created", data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; readonly extraSecrets?: readonly (string | undefined)[]; readonly message: string; readonly status: UnifiedExecStatus; @@ -317,7 +318,7 @@ function writeUnifiedExecResult(input: { readonly store: RunStore; readonly run: RunSession; readonly request: UnifiedExecRequest; - readonly appendAndMaybeStream: (type: "tool_update" | "artifact_created", data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; readonly extraSecrets?: readonly (string | undefined)[]; readonly environmentSummary: UnifiedExecEnvironmentSummary; readonly startedAt: string; @@ -447,7 +448,7 @@ function writeUnifiedExecResult(input: { function createStreamCapture( stream: "stdout" | "stderr", toolCallId: string, - appendAndMaybeStream: (type: "tool_update", data: unknown) => RunEventEnvelope | undefined, + appendAndMaybeStream: RunEventEmitter, extraSecrets: readonly (string | undefined)[], ): { readonly ingest: (chunk: Buffer) => void; @@ -607,7 +608,7 @@ function writeExecArtifact(input: { function appendExecArtifactEvents(input: { readonly artifacts: readonly { readonly artifact: RunArtifact; readonly role: "result" | "stdout" | "stderr" }[]; readonly toolCallId: string; - readonly appendAndMaybeStream: (type: "artifact_created", data: unknown) => RunEventEnvelope | undefined; + readonly appendAndMaybeStream: RunEventEmitter; }): void { for (const item of input.artifacts) { input.appendAndMaybeStream("artifact_created", { From b9cb728b4ed3ecb20f813c3cef84fc7773793c2a Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Tue, 25 Aug 2026 22:45:39 +0800 Subject: [PATCH 4/8] docs: design post-compaction workspace undo --- docs/design/post-compaction-undo.md | 540 ++++++++++++++++++++++++++++ 1 file changed, 540 insertions(+) create mode 100644 docs/design/post-compaction-undo.md diff --git a/docs/design/post-compaction-undo.md b/docs/design/post-compaction-undo.md new file mode 100644 index 000000000..c6e48b964 --- /dev/null +++ b/docs/design/post-compaction-undo.md @@ -0,0 +1,540 @@ +# Post-Compaction Workspace Undo + +Status: design frozen for a follow-up implementation slice. No runtime, schema, or +API implementation is included in this document's change. +Date: 2026-08-25 +Scope: define how Ling can undo workspace changes whose source `LingTurn` or raw +harness messages are no longer present in the active context after compaction. + +## Executive decision + +Post-compaction undo is a workspace-provenance operation, not a conversation-history +operation. + +The normative V1 behavior is: + +1. Keep `LingThread`, `LingTurn`, `ConversationItem`, `RunSession`, `RunEventEnvelope`, + approval records, artifacts, and patch provenance durable across context compaction. +2. Selectively apply inverse patch plans in reverse mutation order. The existing + `workspace-change-recovery.ts` path is the implementation base for this behavior. +3. Use workspace snapshots and fingerprints as validation anchors and recovery baselines, + not as an excuse to overwrite a changed workspace. +4. Permit whole-snapshot restore only as a future, explicitly selected optimization when + there are no later writes and the complete workspace fingerprint still matches. It is + never the default undo path. +5. Treat a condenser summary, harness transcript, or model inference as context evidence. + None of them is undo authority. + +The user-visible rule is simple: if Ling cannot prove which durable mutation records belong +to the target Turn and that the current workspace still matches their post-state, undo is +blocked with a reviewable reason. It must not guess from a summary and must not silently +overwrite newer edits. + +## Reference implementation: Codex upstream + +The comparison below was made against `openai/codex` at commit +[`6525b95d`](https://github.com/openai/codex/tree/6525b95dae2082ac9fee672b14c2cffdef172bb8) +(2026-08-25). These are design references, not dependencies. + +| Codex source | Observed behavior | Ling takeaway | +| --- | --- | --- | +| [`codex-rs/core/src/compact.rs`](https://github.com/openai/codex/blob/6525b95dae2082ac9fee672b14c2cffdef172bb8/codex-rs/core/src/compact.rs) lines 64-89, 352-389 | Compaction creates replacement history and records checkpoint metadata separately. Initial context may be re-injected at a defined boundary. | A compacted prompt is a replacement view. Preserve an explicit checkpoint and lineage rather than treating the summary as the original history. | +| [`codex-rs/core/tests/suite/compact_resume_fork.rs`](https://github.com/openai/codex/blob/6525b95dae2082ac9fee672b14c2cffdef172bb8/codex-rs/core/tests/suite/compact_resume_fork.rs) lines 508-590 | Rolling back behind a compaction replays append-only history and keeps the earlier compaction summary while removing the later Turn. | Rollback must resolve a durable boundary, not delete an arbitrary suffix of the current prompt. A compaction boundary survives historical navigation. | +| [`codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackParams.json`](https://github.com/openai/codex/blob/6525b95dae2082ac9fee672b14c2cffdef172bb8/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackParams.json) lines 3-7 | The protocol explicitly says rollback changes thread history and does not revert local file changes; clients are responsible for files. | Conversation rollback and workspace undo are separate authorities. Ling must make workspace recovery explicit instead of assuming harness rollback is enough. | +| [`codex-rs/thread-store/src/local/revert_thread.rs`](https://github.com/openai/codex/blob/6525b95dae2082ac9fee672b14c2cffdef172bb8/codex-rs/thread-store/src/local/revert_thread.rs) lines 15-18, 38-121 | Paginated revert writes a new immutable rollout, leaves old rollouts intact, and switches one SQLite pointer with compare-and-set semantics. | Prefer immutable provenance plus a narrow durable cutover. Never mutate or truncate the only copy of history while an undo is in flight. | +| [`codex-rs/rollout-trace/src/reducer/compaction.rs`](https://github.com/openai/codex/blob/6525b95dae2082ac9fee672b14c2cffdef172bb8/codex-rs/rollout-trace/src/reducer/compaction.rs) lines 78-82, 113-169 | A compaction request can complete as evidence before a separate install event changes the reduced conversation graph. | Do not infer a live checkpoint from a provider response. Install a durable Ling compaction checkpoint only after its source and replacement projection validate. | + +Codex's `thread/rollback` is therefore useful as a history-boundary reference, but it is +not a workspace undo implementation. Ling owns the product-level workspace decision and +must retain that authority after the harness has compacted or discarded prompt items. + +## Existing Ling foundation + +This design extends existing paths and does not introduce a second Item or patch store: + +- `LingThread` and `LingTurn` remain the canonical product timeline and source of user + semantics. +- `RunSession.contextViewId` and `contextViewCreatedAt` identify the immutable context + projection used to create a Run. They are useful for auditing context drift, but they do + not describe workspace state by themselves. +- `RunEventEnvelope` is typed execution evidence. Tool, approval, artifact, and terminal + events remain replayable after context text is condensed. +- `RunArtifact` and approval payloads already carry bounded patch plans and source refs. +- `packages/daemon/src/features/runs/workspace-change-recovery.ts` already provides: + inverse patch construction, reverse-order application, hash-guarded preflight, write-scope + checks, durable `workspace_change_recovery_started` / `workspace_changes_undone` / + `workspace_changes_reapplied` events, and interrupted-recovery observation. +- `ConversationItemV1` is a typed read-only projection of execution evidence. It does not + become a workspace mutation authority. + +The missing piece is a durable compaction-to-provenance watermark. Current patch recovery +can undo a terminal Run, but a future compaction-aware path must prove that the Run and its +patch records belong to the requested compacted Turn without consulting discarded prompt text. + +## Authority boundaries + +```text +Ling timeline (authoritative) + LingThread -> LingTurn -> ConversationItem + | | + | +--> LingTurnRun -> RunSession -> RunEventEnvelope + | | + | +--> Approval / RunArtifact / PatchPlan + | | + | +--> WorkspaceMutationRecord + | + +--> CompactionCheckpoint + | + +--> covered Turn/Item range + +--> context snapshot + summary refs + +--> workspace provenance watermark + +Condenser summary ----------------------> context reconstruction evidence only +Harness transcript ---------------------> execution evidence/message projection only +WorkspaceMutationRecord + PatchPlan ---> undo authority +WorkspaceSnapshotRef + fingerprint ----> validation / optional restore anchor +``` + +The following are never valid substitutes for `WorkspaceMutationRecord`: + +- a natural-language condenser summary; +- an assistant message saying that a file was changed; +- an unpaired `tool_start` or a provider transcript with no durable `tool_end`; +- a Git diff observed after the fact with no Run/Turn/approval provenance; +- a mutable `ChannelContextView` or a later channel state. + +## Domain model + +The names below are the proposed durable model for the implementation slice. They are +deliberately compatible with the existing Run/Artifact/PatchPlan records. + +### CompactionCheckpoint + +One immutable checkpoint is created only after a context compaction has installed its +replacement projection. + +```ts +interface CompactionCheckpoint { + readonly id: string; + readonly workspaceId: string; + readonly threadId: string; + readonly channelId?: string; + readonly sourceTurnId: string; + readonly contextViewId?: string; + readonly contextViewCreatedAt?: string; + readonly coveredItemSeqFrom: number; + readonly coveredItemSeqTo: number; + readonly coveredTurnIds: readonly string[]; + readonly summaryIds: readonly string[]; + readonly summaryContentSha256?: string; + readonly sourceRefs: readonly SourceRef[]; + readonly workspaceProvenanceWatermark: string; + readonly compactionPolicyVersion: string; + readonly createdAt: string; +} +``` + +Required invariants: + +- `sourceTurnId` belongs to `threadId`; every `coveredTurnId` belongs to the same thread. +- The covered Item range is a canonical `ConversationItem` sequence range, not a harness + event sequence and not a prompt token offset. +- `workspaceProvenanceWatermark` is the last committed mutation sequence visible when the + replacement context was installed. It is a high-water mark, not a claim that no future + mutation exists. +- Summary IDs and source refs are for context reconstruction and audit display. They do not + authorize file recovery. +- Checkpoints are append-only. A later compaction supersedes a view but does not rewrite the + earlier checkpoint. + +### WorkspaceMutationRecord + +One record represents one applied, reversible workspace operation. A patch plan may produce +multiple records, one per file operation, grouped by a stable `patchPlanId`. + +```ts +interface WorkspaceMutationRecord { + readonly id: string; + readonly workspaceId: string; + readonly cwd: string; + readonly threadId?: string; + readonly turnId?: string; + readonly runId: string; + readonly toolCallId?: string; + readonly approvalId?: string; + readonly artifactId?: string; + readonly patchPlanId: string; + readonly operationIndex: number; + readonly path: string; + readonly operation: "add" | "update" | "delete" | "move"; + readonly beforeSha256: string; + readonly afterSha256: string; + readonly inversePatchArtifactId: string; + readonly provenanceSeq: number; + readonly effectState: "applied" | "effect_unknown" | "failed"; + readonly createdAt: string; +} +``` + +The record is immutable. A later undo or reapply appends recovery events; it does not edit +`beforeSha256`, `afterSha256`, or the original patch artifact. + +### WorkspaceSnapshotRef + +Snapshots are content-addressed manifests, not an implicit `git reset`: + +```ts +interface WorkspaceSnapshotRef { + readonly id: string; + readonly workspaceId: string; + readonly cwd: string; + readonly scope: "turn" | "run" | "compaction"; + readonly sourceTurnId?: string; + readonly sourceRunId?: string; + readonly provenanceWatermark: string; + readonly manifestSha256: string; + readonly entries: readonly WorkspaceSnapshotEntry[]; + readonly storageArtifactId?: string; + readonly createdAt: string; +} +``` + +For the first implementation, a snapshot may contain only the touched-path manifest plus +content-addressed copies needed for recovery. A full repository snapshot is optional and +must never be silently substituted for a patch plan. + +### Undo request and durable state + +```ts +type PostCompactionUndoState = + | "available" + | "validating" + | "applied" + | "conflict" + | "provenance_missing" + | "snapshot_unverified" + | "expired" + | "failed"; + +interface PostCompactionUndoRecord { + readonly id: string; + readonly idempotencyKey: string; + readonly workspaceId: string; + readonly threadId: string; + readonly targetTurnId: string; + readonly targetRunIds: readonly string[]; + readonly checkpointId?: string; + readonly expectedWorkspaceFingerprint: string; + readonly action: "undo" | "reapply"; + readonly state: PostCompactionUndoState; + readonly affectedPaths: readonly string[]; + readonly lastEventSeq: number; + readonly createdAt: string; + readonly updatedAt: string; +} +``` + +`idempotencyKey` is required for every user or recovery-worker request. A duplicate request +returns the existing durable result; it never applies the inverse patch twice. + +## Provenance capture lifecycle + +The implementation should capture workspace evidence at four boundaries: + +```text +Turn accepted + -> capture pre-turn touched-path basis (or an empty lazy watermark) + -> Run created with immutable contextView lineage + -> tool/approval execution + -> before hash + after hash + inverse patch artifact + -> WorkspaceMutationRecord committed with Run/Turn/approval/artifact refs + -> Run terminal publication + -> capture post-run watermark/fingerprint + -> compaction install + -> create CompactionCheckpoint with covered Turn/Item range + and workspace provenance watermark +``` + +The order matters. A provider response or `tool_start` may describe an intended effect, but +only a validated patch application followed by a durable mutation record makes that effect +eligible for undo. If a tool ends with an unknown effect, the record remains visible for +audit and the target is blocked from automatic recovery. + +Compaction must not delete or rewrite any of these records. It may remove prompt sections, +raw context messages, or low-survival sections from the next `ChannelContextView`. + +## Selecting the undo target + +The public operation targets a Ling Turn, not a summary string and not an arbitrary harness +event sequence. + +Default selection: + +1. Resolve `targetTurnId` in the canonical `LingThread`. +2. Resolve all `LingTurnRun` rows for that Turn, including retries and resume attempts. +3. Select mutation records for those Runs whose `effectState` is `applied` and whose + provenance belongs to the target Turn. +4. Exclude records already covered by a later committed undo, or mark the request as an + idempotent replay of that undo. +5. Sort by durable mutation order descending. For a workflow, reverse the committed + operation order and preserve dependency fencing before a fan-in target is undone. + +No step reads a condenser summary to infer a path. If the Turn has no complete mutation +provenance, return `provenance_missing` even when the summary claims that a file changed. + +## Validation and CAS protocol + +Undo is a compare-and-set operation over both durable state and workspace bytes. + +```text +request(idempotencyKey, targetTurnId) + | + v +load canonical Turn + Run lineage + CompactionCheckpoint + mutation records + | + +-- missing/incomplete/effect_unknown --> provenance_missing + | + v +acquire workspace recovery lock (workspaceId, canonical cwd) + | + v +re-read current path hashes and policy + | + +-- any current hash != expected afterSha256 --> conflict, no mutation + +-- policy/path/symlink check fails --> conflict, no mutation + +-- active Run or competing recovery --> conflict, no mutation + | + v +append recovery_started with complete before/after intent + | + v +stage and apply inverse patches in reverse order + | + +-- crash/exception --> replay recovery_started and classify before/after/partial + | + v +verify all post-undo hashes and workspace fingerprint + | + +-- mismatch --> failed/conflict; never claim success + | + v +append recovery_applied + durable state transition to applied/undone +``` + +The current workspace must match every mutation's `afterSha256` before undo. This is the +minimal safe CAS condition. A later edit to an unrelated file may be tolerated when the +workspace identity and touched-path fingerprints still match; a later edit to a touched +path is a conflict. + +The lock is per canonical Ling workspace binding, not per Run. Two Runs touching the same +file therefore serialize even if their conversation Turns are unrelated. A lock does not +replace hash validation because external editors and processes can mutate files without +holding the Ling lock. + +## Snapshot restore policy + +Selective inverse patch is the default because it preserves unrelated user work. A snapshot +restore may be offered only when all conditions hold: + +- the target Turn is the only mutation after the snapshot watermark; +- every snapshot entry is available and checksum-verified; +- the current workspace fingerprint exactly matches the target Turn post-state; +- no active Run, approval, or recovery operation references the workspace; +- the user explicitly selects “restore snapshot” and the UI shows the affected paths. + +If any condition fails, downgrade to selective inverse patch or block. Never implement +snapshot restore as `git reset --hard`, directory replacement, or a broad delete. Missing +snapshot content produces `snapshot_unverified`; it does not fall back to summary text. + +## Durable events and state machine + +The existing run event names remain compatible. The compaction-aware implementation should +add a narrow typed domain envelope or a dedicated durable table for the following lifecycle; +the exact storage choice is an implementation task, not a reason to add another conversation +timeline. + +```text +available + | + +--> validating --+--> applied (undo committed) + | +--> conflict (CAS/policy/lock mismatch) + | +--> provenance_missing + | +--> snapshot_unverified + | +--> expired + | +--> failed (apply or verification failure) + | + +--> validating --+--> undone (reapply committed) +``` + +Each transition carries: + +- `idempotencyKey` and recovery record ID; +- target `threadId`, `turnId`, `runIds`, and optional `checkpointId`; +- expected and observed workspace fingerprints; +- affected paths and old/new hashes; +- source refs to Run, Approval, PatchPlan, Artifact, and CompactionCheckpoint; +- a monotonic event sequence and actor (`local-user`, `system-recovery`, or `daemon`); +- a bounded, user-safe reason. Raw provider transcripts and secrets are excluded. + +An interrupted `recovery_started` event is not success. On daemon restart, the recovery +reconciler compares the recorded before/after hashes for every path: + +- all paths at before-state: safe to retry with the same idempotency key; +- all paths at after-state: append the missing terminal event idempotently; +- mixed or unknown state: `conflict`, preserve files, require review; +- unreadable or missing provenance: `provenance_missing`. + +## Compaction linkage rules + +Every compaction install must persist the following checkpoint facts before exposing an undo +affordance: + +| Field | Purpose | +| --- | --- | +| `coveredItemSeqFrom` / `coveredItemSeqTo` | Identifies the canonical Item range represented by the replacement context. | +| `coveredTurnIds` | Keeps product Turn semantics independent of harness turn IDs. | +| `contextViewId` and creation time | Binds the prompt projection used by the Run and detects context drift. | +| `summaryIds` and content hash | Allows context inspection and audit without granting undo authority. | +| `workspaceProvenanceWatermark` | Finds mutation records visible at compaction install. | +| `sourceRefs` | Links the checkpoint to source Items, Turns, Runs, and artifacts. | +| `compactionPolicyVersion` | Makes future replay deterministic across policy changes. | + +The compaction installer must validate that each covered Turn belongs to the same thread and +that the watermark is monotonic. A failed or provider-only compaction request creates evidence +of the attempt but no installed checkpoint. + +## Concurrency and recovery cases + +| Situation | Required result | User-visible behavior | +| --- | --- | --- | +| Compaction followed by a new Turn editing a touched file | Block target undo because post-state no longer matches. | “Workspace changed after this Turn; review or choose a separately supported rebase flow.” | +| Two Runs edit the same file | Serialize by workspace lock, then hash-check in mutation order. | One target may undo; the other shows a conflict until the user chooses a target. | +| Daemon crashes before `recovery_started` | No durable mutation was attempted by Ling. | Request can be retried. | +| Crash after `recovery_started`, before all writes | Reconcile before/after hashes. | Mixed state is blocked, never silently rolled back. | +| Crash after all writes, before terminal event | Reconcile all after hashes and append the terminal event idempotently. | UI eventually shows completed undo. | +| Missing snapshot but complete inverse patch | Use selective inverse patch. | Snapshot restore is unavailable; undo remains available if CAS passes. | +| Missing inverse patch or mutation record | Block. | “Undo unavailable: durable workspace provenance is incomplete.” | +| Stale artifact or approval payload | Revalidate content hash and source refs. | Block with an artifact/provenance reason. | +| Repeated undo request | Return the existing idempotent result. | No second file mutation. | +| Undo and reapply race | One recovery lock and one idempotency/CAS winner. | Losing request receives current state/conflict. | +| `tool_start` without a known effect | Never infer success or inverse patch. | Show effect-unknown and disable automatic undo. | +| Approval-gated mutation denied or expired | No mutation record eligible for undo. | No undo affordance for the denied effect. | +| Compaction runs while a target Run is active | Checkpoint may install only after the active Turn boundary is durable. | Undo remains unavailable until terminal provenance settles. | +| Binary, symlink, or unsupported external mutation | No text inverse patch is fabricated. | `provenance_missing` or `snapshot_unverified`; offer manual review. | + +## User-visible contract + +The workspace change panel should show: + +- target Ling Turn title and timestamp; +- source Run IDs, retry/resume roles, and compaction checkpoint ID when applicable; +- affected paths with old/new hashes and operation type; +- whether the current workspace still matches the expected post-state; +- `Undo`, `Reapply`, or `Review conflict` actions with explicit disabled reasons; +- a link to the source Item/Run/Approval/Artifact evidence; +- a clear notice that undo changes workspace files, not the canonical conversation timeline. + +The following states must never be represented as a successful undo: + +- a summary-only claim; +- a partial file application; +- a provider transcript with no durable patch basis; +- a missing or unverifiable snapshot; +- a conflict hidden behind a generic HTTP 200. + +## Security and policy constraints + +Recovery reuses the Run's immutable write-scope snapshot and the current workspace policy. +Both must allow every touched path. The implementation must continue to enforce: + +- canonical workspace and path traversal checks; +- no symlink escape outside the approved workspace; +- no mutation of `.ling-stack` internal state through recovery; +- no raw secrets in snapshot manifests, patch artifacts, or event payloads; +- local workspace request authentication and workspace binding checks; +- bounded artifact sizes and retention metadata. + +Changing the current policy after a Run completes cannot expand the original Run's authority; +it can only keep recovery allowed when both the original and current policies permit it. + +## Implementation slices after this design + +This document intentionally stops before code. The follow-up should be split as follows: + +### S1: provenance and checkpoint persistence (P1) + +- Add durable `CompactionCheckpoint` and `WorkspaceMutationRecord` contracts/mappers. +- Capture patch operation provenance at successful apply, including Turn/Run/Approval/Artifact + refs and a monotonic workspace watermark. +- Install a checkpoint only after the replacement context projection validates. +- Add migration/reopen/idempotency tests. No UI affordance yet. + +### S2: compaction-aware recovery engine (P1) + +- Extend the existing run/workflow recovery path to resolve a target `LingTurn` through all + `LingTurnRun` attempts and compacted checkpoints. +- Keep selective inverse patch as the only automatic path. +- Add workspace lock, full preflight, post-apply verification, and restart reconciliation + using the durable state machine above. +- Keep existing run-level undo/reapply API behavior compatible. + +### S3: projection and API surface (P2) + +- Expose checkpoint ID, source Turn, provenance completeness, expected/current fingerprints, + and blocked reasons in the existing workspace-change response. +- Project recovery lifecycle into typed `ConversationItem`/activity views without creating a + second canonical timeline. +- Add explicit confirmation for any future snapshot-restore action. + +### S4: optional snapshot restore (P2, gated) + +- Add content-addressed snapshot storage only if selective inverse patch cannot cover a + demonstrated product case. +- Require exact fingerprint match and an explicit user action. +- Retain old snapshots and use a CAS pointer cutover; never overwrite the only source. + +## Verification matrix + +The implementation is not complete until the following cases have focused tests and at least +one SQLite reopen/replay path where applicable: + +| Case | Expected assertion | +| --- | --- | +| Undo the same Turn before and after compaction | Identical inverse patch result; summary presence does not change authority. | +| Raw messages compacted away | Undo still resolves through Turn -> Run -> mutation provenance. | +| No-op Turn | No undo affordance and no fabricated mutation record. | +| Tool starts, daemon crashes before effect is known | Effect remains unknown; no automatic undo. | +| Tool ends with an applied patch | Before/after hashes and inverse plan survive reopen. | +| Later Turn edits the same file | CAS conflict; zero writes from the blocked undo. | +| Later Turn edits an unrelated file | Selective undo succeeds if all target paths still match. | +| Missing snapshot, complete inverse patch | Selective undo succeeds; snapshot restore is unavailable. | +| Missing inverse patch/provenance | `provenance_missing`; no summary fallback. | +| Duplicate undo idempotency key | One mutation and one terminal result. | +| Daemon restart after `recovery_started` | Before/after/mixed classification is deterministic. | +| Undo then reapply | Reapply uses the original forward patch and hash guards. | +| Multiple Runs in one Turn | Reverse mutation order and one durable target result. | +| Multiple Turns in one workflow | Reverse committed operation/dependency order; fan-in remains fenced. | +| Approval-gated mutation | Only approved and successfully applied patch records are eligible. | +| Compaction request succeeds but install fails | Evidence exists; no installed checkpoint or undo target is claimed. | +| Invalid cross-thread checkpoint refs | Migration/replay fails closed without visible mutation. | +| SQLite foreign-key/integrity/replay checks | All provenance and recovery rows remain referentially valid and idempotent. | +| Path traversal, symlink escape, internal path, policy drift | Recovery is denied before any write. | + +## What this design does not do + +- It does not make the harness transcript the Ling conversation authority. +- It does not make compaction summaries reversible state. +- It does not add a second `ConversationItem` store. +- It does not change `RunSession.turnId`, `queue_followup`, or `interrupt_redirect` contracts. +- It does not implement a `workspace_knowledge` provider. +- It does not implement post-compaction undo code in this slice. +- It does not use `git reset`, directory replacement, force overwrite, or an implicit “force + undo” path. + +## Final design verdict + +The design has no unresolved authority decision: selective inverse patch backed by durable +Run/Turn mutation provenance is the default; snapshots are validation anchors and an optional, +explicitly gated optimization. The only remaining work is implementation and verification of +the slices above. Codex's history rollback and immutable rollout cutover are references for +lineage and CAS, not a replacement for Ling's product-owned workspace recovery authority. From 24f4248ec9807946d3f303026cefbecdc6aaa2c1 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Wed, 26 Aug 2026 00:52:45 +0800 Subject: [PATCH 5/8] feat: implement post-compaction workspace undo --- docs/design/post-compaction-undo.md | 67 +-- packages/api-client/src/endpoints/runs.ts | 13 + packages/contracts/src/index.ts | 2 +- packages/contracts/src/runtime.ts | 35 ++ packages/contracts/src/workspace-changes.ts | 128 ++++++ packages/core/src/run-types.ts | 14 + packages/core/src/storage/sqlite-run-store.ts | 43 ++ .../core/src/storage/sqlite/migrations.ts | 7 + .../sqlite/workspace-provenance-migration.ts | 99 ++++ .../sqlite/workspace-provenance-store.ts | 426 ++++++++++++++++++ .../core/test/workspace-provenance.test.mjs | 119 +++++ .../conversation-summary-coordinator.ts | 22 +- .../workflow-workspace-change-recovery.ts | 80 ++-- .../daemon/src/features/runs/approvals.ts | 11 + .../daemon/src/features/runs/patch-actions.ts | 123 +++++ .../runs/workspace-change-recovery.ts | 367 ++++++++++++++- packages/daemon/src/runtime/store-registry.ts | 15 + packages/daemon/src/server/router.ts | 26 ++ packages/daemon/src/server/routes.ts | 29 ++ .../conversation-summary-coordinator.test.mjs | 9 + .../test/post-compaction-undo-routes.test.mjs | 37 ++ .../daemon/test/post-compaction-undo.test.mjs | 113 +++++ 22 files changed, 1711 insertions(+), 74 deletions(-) create mode 100644 packages/core/src/storage/sqlite/workspace-provenance-migration.ts create mode 100644 packages/core/src/storage/sqlite/workspace-provenance-store.ts create mode 100644 packages/core/test/workspace-provenance.test.mjs create mode 100644 packages/daemon/test/post-compaction-undo-routes.test.mjs create mode 100644 packages/daemon/test/post-compaction-undo.test.mjs diff --git a/docs/design/post-compaction-undo.md b/docs/design/post-compaction-undo.md index c6e48b964..c703c9340 100644 --- a/docs/design/post-compaction-undo.md +++ b/docs/design/post-compaction-undo.md @@ -1,7 +1,7 @@ # Post-Compaction Workspace Undo -Status: design frozen for a follow-up implementation slice. No runtime, schema, or -API implementation is included in this document's change. +Status: V1 selective-inverse implementation landed; snapshot restore remains explicitly +gated and unimplemented. Date: 2026-08-25 Scope: define how Ling can undo workspace changes whose source `LingTurn` or raw harness messages are no longer present in the active context after compaction. @@ -67,9 +67,10 @@ This design extends existing paths and does not introduce a second Item or patch - `ConversationItemV1` is a typed read-only projection of execution evidence. It does not become a workspace mutation authority. -The missing piece is a durable compaction-to-provenance watermark. Current patch recovery -can undo a terminal Run, but a future compaction-aware path must prove that the Run and its -patch records belong to the requested compacted Turn without consulting discarded prompt text. +The compaction-to-provenance watermark and Turn-level recovery path are now durable. Patch +applications write immutable mutation records and sealed forward/inverse PatchPlan artifacts; +compaction install writes an immutable checkpoint; Turn recovery resolves all linked terminal +Runs without consulting discarded prompt text. ## Authority boundaries @@ -457,34 +458,36 @@ Both must allow every touched path. The implementation must continue to enforce: Changing the current policy after a Run completes cannot expand the original Run's authority; it can only keep recovery allowed when both the original and current policies permit it. -## Implementation slices after this design +## Implementation status -This document intentionally stops before code. The follow-up should be split as follows: +The following slices are implemented in the current daemon/core code: -### S1: provenance and checkpoint persistence (P1) +### S1: provenance and checkpoint persistence (P1) — implemented -- Add durable `CompactionCheckpoint` and `WorkspaceMutationRecord` contracts/mappers. -- Capture patch operation provenance at successful apply, including Turn/Run/Approval/Artifact - refs and a monotonic workspace watermark. -- Install a checkpoint only after the replacement context projection validates. -- Add migration/reopen/idempotency tests. No UI affordance yet. +- Durable `CompactionCheckpoint` and `WorkspaceMutationRecord` contracts, SQLite migration, + mappers, and reopen coverage are present. +- Successful patch apply seals forward/inverse PatchPlan artifacts and records each operation + with Turn/Run/Approval/Artifact refs plus a monotonic workspace watermark. +- A validated summary commit installs a checkpoint; stale summary candidates do not. -### S2: compaction-aware recovery engine (P1) +### S2: compaction-aware recovery engine (P1) — implemented -- Extend the existing run/workflow recovery path to resolve a target `LingTurn` through all - `LingTurnRun` attempts and compacted checkpoints. -- Keep selective inverse patch as the only automatic path. -- Add workspace lock, full preflight, post-apply verification, and restart reconciliation - using the durable state machine above. -- Keep existing run-level undo/reapply API behavior compatible. +- Turn recovery resolves all retry/resume `LingTurnRun` attempts and the matching checkpoint, + applies inverse plans in reverse provenance order, and reapplies in forward order. +- Selective inverse patch is the only automatic path. Workspace locks, policy/symlink checks, + hash-guarded preflight, post-write verification, and interrupted-recovery reconciliation are + enforced. Cross-workspace Run lineages fail closed. +- Existing run-level recovery remains compatible; legacy Runs without provenance retain the + old run-level fallback but are not eligible for Turn-level undo. -### S3: projection and API surface (P2) +### S3: projection and API surface (P2) — implemented (minimal) -- Expose checkpoint ID, source Turn, provenance completeness, expected/current fingerprints, - and blocked reasons in the existing workspace-change response. -- Project recovery lifecycle into typed `ConversationItem`/activity views without creating a - second canonical timeline. -- Add explicit confirmation for any future snapshot-restore action. +- `TurnWorkspaceChangesResponse` exposes checkpoint ID, Run lineage, provenance completeness, + state, capabilities, affected files, and blocked reasons. +- HTTP routes and API-client methods are available at `/turns/:turnId/workspace-changes` and + `/undo` / `/reapply` variants. Recovery lifecycle remains typed RunEvent evidence; it does + not create a second canonical timeline. +- Snapshot restore has no route or implicit fallback; any future action must be explicit. ### S4: optional snapshot restore (P2, gated) @@ -520,14 +523,15 @@ one SQLite reopen/replay path where applicable: | SQLite foreign-key/integrity/replay checks | All provenance and recovery rows remain referentially valid and idempotent. | | Path traversal, symlink escape, internal path, policy drift | Recovery is denied before any write. | -## What this design does not do +## What this implementation does not do - It does not make the harness transcript the Ling conversation authority. - It does not make compaction summaries reversible state. - It does not add a second `ConversationItem` store. - It does not change `RunSession.turnId`, `queue_followup`, or `interrupt_redirect` contracts. - It does not implement a `workspace_knowledge` provider. -- It does not implement post-compaction undo code in this slice. +- It does not implement full-workspace snapshot restore; selective inverse patch is the only + automatic recovery path. - It does not use `git reset`, directory replacement, force overwrite, or an implicit “force undo” path. @@ -535,6 +539,7 @@ one SQLite reopen/replay path where applicable: The design has no unresolved authority decision: selective inverse patch backed by durable Run/Turn mutation provenance is the default; snapshots are validation anchors and an optional, -explicitly gated optimization. The only remaining work is implementation and verification of -the slices above. Codex's history rollback and immutable rollout cutover are references for -lineage and CAS, not a replacement for Ling's product-owned workspace recovery authority. +explicitly gated optimization. The remaining work is broader verification, UI projection, and +the separately gated snapshot-restore slice. Codex's history rollback and immutable rollout +cutover are references for lineage and CAS, not a replacement for Ling's product-owned +workspace recovery authority. diff --git a/packages/api-client/src/endpoints/runs.ts b/packages/api-client/src/endpoints/runs.ts index 316768a08..844d12361 100644 --- a/packages/api-client/src/endpoints/runs.ts +++ b/packages/api-client/src/endpoints/runs.ts @@ -50,6 +50,7 @@ import type { ToolLoadRequestResponse, ToolSurfaceResponse, RunWorkspaceChangesResponse, + TurnWorkspaceChangesResponse, } from "@ling-stack/contracts"; import type { LingDaemonEndpointContext, PublicEndpointGroup } from "../transport.js"; @@ -286,6 +287,18 @@ export const runEndpoints = { return this._post(`/runs/${encodeURIComponent(runId)}/workspace-changes/reapply`, { cwd }, {}); }, + async getTurnWorkspaceChanges(this: LingDaemonEndpointContext, turnId: string, cwd?: string): Promise { + return this._get(`/turns/${encodeURIComponent(turnId)}/workspace-changes`, { cwd }); + }, + + async undoTurnWorkspaceChanges(this: LingDaemonEndpointContext, turnId: string, idempotencyKey: string, cwd?: string): Promise { + return this._post(`/turns/${encodeURIComponent(turnId)}/workspace-changes/undo`, { cwd, idempotencyKey }, {}); + }, + + async reapplyTurnWorkspaceChanges(this: LingDaemonEndpointContext, turnId: string, idempotencyKey: string, cwd?: string): Promise { + return this._post(`/turns/${encodeURIComponent(turnId)}/workspace-changes/reapply`, { cwd, idempotencyKey }, {}); + }, + async listRunArtifacts(this: LingDaemonEndpointContext, runId: string, cwd?: string): Promise { return this._get(`/runs/${encodeURIComponent(runId)}/artifacts`, { cwd }); }, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f73fa2b82..f52e7bd1b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -45,7 +45,7 @@ export type * from "./ai-councils.js"; export type * from "./settings.js"; export type * from "./workspace-directory.js"; export type * from "./workspace-terminal.js"; -export type * from "./workspace-changes.js"; +export * from "./workspace-changes.js"; export type * from "./terminal-reports.js"; export type * from "./startup.js"; export type * from "./profile.js"; diff --git a/packages/contracts/src/runtime.ts b/packages/contracts/src/runtime.ts index 7e0fa2a19..595cd9e44 100644 --- a/packages/contracts/src/runtime.ts +++ b/packages/contracts/src/runtime.ts @@ -570,12 +570,47 @@ export interface RunEventDataByType { readonly [key: string]: unknown; }; readonly workspace_change_recovery_started: { + readonly action?: "undo" | "reapply"; + readonly recoveryId?: string; + readonly idempotencyKey?: string; + readonly scope?: "turn" | "workflow"; + readonly turnId?: string; + readonly workflowId?: string; + readonly runIds?: readonly string[]; + readonly cwd?: string; + readonly files?: readonly { + readonly path: string; + readonly beforeSha256: string; + readonly afterSha256: string; + }[]; readonly [key: string]: unknown; }; readonly workspace_changes_undone: { + readonly action?: "undo"; + readonly recoveryId?: string; + readonly idempotencyKey?: string; + readonly scope?: "turn" | "workflow"; + readonly turnId?: string; + readonly workflowId?: string; + readonly runIds?: readonly string[]; + readonly cwd?: string; + readonly approvalIds?: readonly string[]; + readonly rawPatchSha256?: readonly string[]; + readonly touchedPaths?: readonly string[]; readonly [key: string]: unknown; }; readonly workspace_changes_reapplied: { + readonly action?: "reapply"; + readonly recoveryId?: string; + readonly idempotencyKey?: string; + readonly scope?: "turn" | "workflow"; + readonly turnId?: string; + readonly workflowId?: string; + readonly runIds?: readonly string[]; + readonly cwd?: string; + readonly approvalIds?: readonly string[]; + readonly rawPatchSha256?: readonly string[]; + readonly touchedPaths?: readonly string[]; readonly [key: string]: unknown; }; readonly run_intervention_created: { diff --git a/packages/contracts/src/workspace-changes.ts b/packages/contracts/src/workspace-changes.ts index bd8db209c..fbc11e2de 100644 --- a/packages/contracts/src/workspace-changes.ts +++ b/packages/contracts/src/workspace-changes.ts @@ -1,4 +1,5 @@ import type { WorkspaceGitFileKind, WorkspaceGitStatusState } from "./channels.js"; +import type { ConversationDomainRef } from "./conversation-continuity.js"; export interface WorkspaceChangeFile { readonly path: string; @@ -61,6 +62,133 @@ export interface RunWorkspaceChangesResponse { readonly updatedAt: string; } +export const workspaceMutationOperations = ["add", "update", "delete", "move"] as const; +export type WorkspaceMutationOperation = (typeof workspaceMutationOperations)[number]; + +export const workspaceMutationEffectStates = ["applied", "effect_unknown", "failed"] as const; +export type WorkspaceMutationEffectState = (typeof workspaceMutationEffectStates)[number]; + +/** Durable provenance for one reversible workspace operation. */ +export interface WorkspaceMutationRecord { + readonly id: string; + readonly workspaceId: string; + readonly cwd: string; + readonly threadId?: string; + readonly turnId?: string; + readonly runId: string; + readonly toolCallId?: string; + readonly approvalId?: string; + readonly artifactId?: string; + /** Artifact containing the forward PatchPlan used for reapply. */ + readonly forwardPatchArtifactId?: string; + readonly patchPlanId: string; + readonly operationIndex: number; + readonly path: string; + readonly operation: WorkspaceMutationOperation; + readonly beforeSha256: string; + readonly afterSha256: string; + readonly inversePatchArtifactId: string; + readonly provenanceSeq: number; + readonly effectState: WorkspaceMutationEffectState; + readonly sourceRefs: readonly ConversationDomainRef[]; + readonly createdAt: string; +} + +export interface CreateWorkspaceMutationRecordInput { + readonly id?: string; + readonly workspaceId?: string; + readonly cwd: string; + readonly threadId?: string; + readonly turnId?: string; + readonly runId: string; + readonly toolCallId?: string; + readonly approvalId?: string; + readonly artifactId?: string; + readonly forwardPatchArtifactId?: string; + readonly patchPlanId: string; + readonly operationIndex: number; + readonly path: string; + readonly operation: WorkspaceMutationOperation; + readonly beforeSha256: string; + readonly afterSha256: string; + readonly inversePatchArtifactId: string; + readonly effectState?: WorkspaceMutationEffectState; + readonly sourceRefs?: readonly ConversationDomainRef[]; + readonly createdAt: string; +} + +export interface ListWorkspaceMutationRecordsOptions { + readonly workspaceId?: string; + readonly threadId?: string; + readonly turnId?: string; + readonly runId?: string; + readonly patchPlanId?: string; + readonly effectState?: WorkspaceMutationEffectState; + readonly limit?: number; +} + +/** Immutable install boundary for a replacement context projection. */ +export interface CompactionCheckpoint { + readonly id: string; + readonly workspaceId: string; + readonly threadId: string; + readonly channelId?: string; + readonly sourceTurnId: string; + readonly contextViewId?: string; + readonly contextViewCreatedAt?: string; + readonly coveredItemSeqFrom: number; + readonly coveredItemSeqTo: number; + readonly coveredTurnIds: readonly string[]; + readonly summaryIds: readonly string[]; + readonly summaryContentSha256?: string; + readonly sourceRefs: readonly ConversationDomainRef[]; + readonly workspaceProvenanceWatermark: string; + readonly compactionPolicyVersion: string; + readonly createdAt: string; +} + +export interface CreateCompactionCheckpointInput { + readonly id?: string; + readonly workspaceId?: string; + readonly threadId: string; + readonly channelId?: string; + readonly sourceTurnId: string; + readonly contextViewId?: string; + readonly contextViewCreatedAt?: string; + readonly coveredItemSeqFrom: number; + readonly coveredItemSeqTo: number; + readonly coveredTurnIds: readonly string[]; + readonly summaryIds?: readonly string[]; + readonly summaryContentSha256?: string; + readonly sourceRefs: readonly ConversationDomainRef[]; + readonly workspaceProvenanceWatermark: string; + readonly compactionPolicyVersion: string; + readonly createdAt: string; +} + +export interface ListCompactionCheckpointsOptions { + readonly workspaceId?: string; + readonly threadId?: string; + readonly sourceTurnId?: string; + readonly limit?: number; +} + +export interface TurnWorkspaceChangesResponse { + readonly cwd: string; + readonly threadId: string; + readonly turnId: string; + readonly runIds: readonly string[]; + readonly state: RunWorkspaceChangeState; + readonly canUndo: boolean; + readonly canReapply: boolean; + readonly files: readonly RunWorkspaceChangeFile[]; + readonly summary: RunWorkspaceChangeSummary; + readonly checkpointId?: string; + readonly provenanceComplete: boolean; + readonly unavailableReason?: string; + readonly updatedAt: string; +} + export interface WorkflowWorkspaceChangesResponse { readonly cwd: string; readonly workflowId: string; diff --git a/packages/core/src/run-types.ts b/packages/core/src/run-types.ts index 53f0c0afa..c21238a5e 100644 --- a/packages/core/src/run-types.ts +++ b/packages/core/src/run-types.ts @@ -1,11 +1,17 @@ import { randomUUID } from "node:crypto"; import type { ConversationItemV1, + CompactionCheckpoint, + CreateCompactionCheckpointInput, + CreateWorkspaceMutationRecordInput, LingThreadRelationKindV2, LingThreadSurfaceKindV2, + ListCompactionCheckpointsOptions, + ListWorkspaceMutationRecordsOptions, RunEventDataByType as ContractRunEventDataByType, RunEventEnvelope as ContractRunEventEnvelope, RunEventType as ContractRunEventType, + WorkspaceMutationRecord, } from "@ling-stack/contracts"; import type { ApprovalActionType, @@ -1515,6 +1521,14 @@ export interface RunStore { listRuns(options?: ListRunsOptions): RunSession[]; listEvents(runId: string, options?: ListRunEventsOptions): RunEventEnvelope[]; countEvents(runId: string): number; + createWorkspaceMutationRecord(input: CreateWorkspaceMutationRecordInput): WorkspaceMutationRecord; + createWorkspaceMutationRecords(inputs: readonly CreateWorkspaceMutationRecordInput[]): WorkspaceMutationRecord[]; + getWorkspaceMutationRecord(id: string): WorkspaceMutationRecord | undefined; + listWorkspaceMutationRecords(options?: ListWorkspaceMutationRecordsOptions): WorkspaceMutationRecord[]; + getLatestWorkspaceProvenanceWatermark(workspaceId?: string): string; + createCompactionCheckpoint(input: CreateCompactionCheckpointInput): CompactionCheckpoint; + getCompactionCheckpoint(id: string): CompactionCheckpoint | undefined; + listCompactionCheckpoints(options?: ListCompactionCheckpointsOptions): CompactionCheckpoint[]; createRunIntervention(input: CreateRunInterventionInput, now: string): RunIntervention; getRunIntervention(interventionId: string): RunIntervention | undefined; listRunInterventions(options?: ListRunInterventionsOptions): RunIntervention[]; diff --git a/packages/core/src/storage/sqlite-run-store.ts b/packages/core/src/storage/sqlite-run-store.ts index 94ae56b0d..14f36719f 100644 --- a/packages/core/src/storage/sqlite-run-store.ts +++ b/packages/core/src/storage/sqlite-run-store.ts @@ -313,6 +313,14 @@ import { type UpdateProposedPlanArtifactInput, type UpdateRunInterventionStatusInput, } from "../run-types.js"; +import type { + CompactionCheckpoint, + CreateCompactionCheckpointInput, + CreateWorkspaceMutationRecordInput, + ListCompactionCheckpointsOptions, + ListWorkspaceMutationRecordsOptions, + WorkspaceMutationRecord, +} from "@ling-stack/contracts"; import { type AcquireRuntimeSemaphoreSlotInput, type AppendRuntimeDiagnosticEventInput, @@ -611,6 +619,7 @@ import { SqliteExternalWorkCiTriageStore } from "./sqlite/external-work-ci-triag import { SqliteExternalWorkSchedulerStore } from "./sqlite/external-work-scheduler-store.js"; import { SqliteExternalWorkWorkflowCoordinator } from "./sqlite/external-work-workflow-coordinator.js"; import { SqliteWorkItemStore } from "./sqlite/work-item-store.js"; +import { SqliteWorkspaceProvenanceStore } from "./sqlite/workspace-provenance-store.js"; export interface SqliteRunStoreOptions { readonly cwd: string; @@ -701,6 +710,7 @@ export class SqliteRunStore implements RunStore { private readonly externalWorkScheduler: SqliteExternalWorkSchedulerStore; private readonly externalWorkWorkflows: SqliteExternalWorkWorkflowCoordinator; private readonly workItems: SqliteWorkItemStore; + private readonly workspaceProvenance: SqliteWorkspaceProvenanceStore; constructor(options: SqliteRunStoreOptions) { this.paths = options.paths ?? resolveRuntimePaths(options.cwd); @@ -709,6 +719,7 @@ export class SqliteRunStore implements RunStore { const existed = existsSync(this.paths.databasePath); this.database = new DatabaseSync(this.paths.databasePath); this.conversationItems = new SqliteConversationItemStore({ database: this.database }); + this.workspaceProvenance = new SqliteWorkspaceProvenanceStore({ database: this.database }); this.conversationTurnIngest = new SqliteConversationTurnIngestStore( { database: this.database }, this.conversationItems, @@ -1678,6 +1689,38 @@ export class SqliteRunStore implements RunStore { return this.runEvents.countEvents(runId); } + createWorkspaceMutationRecord(input: CreateWorkspaceMutationRecordInput): WorkspaceMutationRecord { + return this.workspaceProvenance.createWorkspaceMutationRecord(input); + } + + createWorkspaceMutationRecords(inputs: readonly CreateWorkspaceMutationRecordInput[]): WorkspaceMutationRecord[] { + return this.workspaceProvenance.createWorkspaceMutationRecords(inputs); + } + + getWorkspaceMutationRecord(id: string): WorkspaceMutationRecord | undefined { + return this.workspaceProvenance.getWorkspaceMutationRecord(id); + } + + listWorkspaceMutationRecords(options: ListWorkspaceMutationRecordsOptions = {}): WorkspaceMutationRecord[] { + return this.workspaceProvenance.listWorkspaceMutationRecords(options); + } + + getLatestWorkspaceProvenanceWatermark(workspaceId?: string): string { + return this.workspaceProvenance.getLatestWorkspaceProvenanceWatermark(workspaceId); + } + + createCompactionCheckpoint(input: CreateCompactionCheckpointInput): CompactionCheckpoint { + return this.workspaceProvenance.createCompactionCheckpoint(input); + } + + getCompactionCheckpoint(id: string): CompactionCheckpoint | undefined { + return this.workspaceProvenance.getCompactionCheckpoint(id); + } + + listCompactionCheckpoints(options: ListCompactionCheckpointsOptions = {}): CompactionCheckpoint[] { + return this.workspaceProvenance.listCompactionCheckpoints(options); + } + createRunIntervention(input: CreateRunInterventionInput, now: string): RunIntervention { return this.runInterventions.createRunIntervention(input, now); } diff --git a/packages/core/src/storage/sqlite/migrations.ts b/packages/core/src/storage/sqlite/migrations.ts index 05f60403d..a0120fafa 100644 --- a/packages/core/src/storage/sqlite/migrations.ts +++ b/packages/core/src/storage/sqlite/migrations.ts @@ -6,6 +6,7 @@ import { CONTINUITY_V2_STORAGE_MIGRATION_VERSION } from "./continuity-v2-migrati import { applyRunContextViewLineageMigration, } from "./run-context-view-lineage-migration.js"; +import { applyWorkspaceProvenanceMigration } from "./workspace-provenance-migration.js"; export interface SqliteRunStoreMigrationContext { readonly database: DatabaseSync; @@ -5651,4 +5652,10 @@ export function applySqliteRunStoreMigrations(context: SqliteRunStoreMigrationCo hasColumn: context.hasColumn.bind(context), recordMigration: context.recordMigration.bind(context), }); + applyWorkspaceProvenanceMigration({ + database, + hasMigration: context.hasMigration.bind(context), + hasTable: context.hasTable.bind(context), + recordMigration: context.recordMigration.bind(context), + }); } diff --git a/packages/core/src/storage/sqlite/workspace-provenance-migration.ts b/packages/core/src/storage/sqlite/workspace-provenance-migration.ts new file mode 100644 index 000000000..62eff866b --- /dev/null +++ b/packages/core/src/storage/sqlite/workspace-provenance-migration.ts @@ -0,0 +1,99 @@ +import type { DatabaseSync } from "node:sqlite"; + +export const WORKSPACE_PROVENANCE_MIGRATION_VERSION = 123; +export const WORKSPACE_PROVENANCE_MIGRATION_NAME = "create_workspace_provenance_and_compaction_checkpoints"; + +export interface WorkspaceProvenanceMigrationInput { + readonly database: DatabaseSync; + readonly hasMigration: (version: number) => boolean; + readonly hasTable: (tableName: string) => boolean; + readonly recordMigration: (version: number, name: string) => void; +} + +export function applyWorkspaceProvenanceMigration(input: WorkspaceProvenanceMigrationInput): boolean { + const complete = input.hasTable("workspace_mutation_records") + && input.hasTable("compaction_checkpoints") + && input.hasTable("workspace_provenance_sequences"); + if (complete && input.hasMigration(WORKSPACE_PROVENANCE_MIGRATION_VERSION)) { + return false; + } + + input.database.exec(` + CREATE TABLE IF NOT EXISTS workspace_provenance_sequences ( + workspace_id TEXT PRIMARY KEY, + last_seq INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS workspace_mutation_records ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + cwd TEXT NOT NULL, + thread_id TEXT, + turn_id TEXT, + run_id TEXT NOT NULL, + tool_call_id TEXT, + approval_id TEXT, + artifact_id TEXT, + forward_patch_artifact_id TEXT, + patch_plan_id TEXT NOT NULL, + operation_index INTEGER NOT NULL, + path TEXT NOT NULL, + operation TEXT NOT NULL CHECK(operation IN ('add', 'update', 'delete', 'move')), + before_sha256 TEXT NOT NULL, + after_sha256 TEXT NOT NULL, + inverse_patch_artifact_id TEXT NOT NULL, + provenance_seq INTEGER NOT NULL, + effect_state TEXT NOT NULL CHECK(effect_state IN ('applied', 'effect_unknown', 'failed')), + source_refs_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + FOREIGN KEY(thread_id) REFERENCES ling_threads(id) ON DELETE SET NULL, + FOREIGN KEY(turn_id) REFERENCES ling_turns(id) ON DELETE SET NULL, + FOREIGN KEY(run_id) REFERENCES run_sessions(id) ON DELETE CASCADE, + FOREIGN KEY(approval_id) REFERENCES approval_requests(id) ON DELETE SET NULL, + FOREIGN KEY(artifact_id) REFERENCES artifacts(id) ON DELETE SET NULL, + FOREIGN KEY(forward_patch_artifact_id) REFERENCES artifacts(id) ON DELETE SET NULL, + FOREIGN KEY(inverse_patch_artifact_id) REFERENCES artifacts(id) ON DELETE RESTRICT, + UNIQUE(workspace_id, provenance_seq), + UNIQUE(run_id, patch_plan_id, operation_index) + ); + + CREATE INDEX IF NOT EXISTS idx_workspace_mutations_turn_seq + ON workspace_mutation_records(workspace_id, turn_id, provenance_seq); + CREATE INDEX IF NOT EXISTS idx_workspace_mutations_run_seq + ON workspace_mutation_records(run_id, provenance_seq); + CREATE INDEX IF NOT EXISTS idx_workspace_mutations_plan + ON workspace_mutation_records(patch_plan_id, operation_index); + + CREATE TABLE IF NOT EXISTS compaction_checkpoints ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + channel_id TEXT, + source_turn_id TEXT NOT NULL, + context_view_id TEXT, + context_view_created_at TEXT, + covered_item_seq_from INTEGER NOT NULL, + covered_item_seq_to INTEGER NOT NULL, + covered_turn_ids_json TEXT NOT NULL, + summary_ids_json TEXT NOT NULL DEFAULT '[]', + summary_content_sha256 TEXT, + source_refs_json TEXT NOT NULL, + workspace_provenance_watermark TEXT NOT NULL, + compaction_policy_version TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(thread_id) REFERENCES ling_threads(id) ON DELETE CASCADE, + FOREIGN KEY(source_turn_id) REFERENCES ling_turns(id) ON DELETE RESTRICT, + FOREIGN KEY(channel_id) REFERENCES channels(id) ON DELETE SET NULL + ); + + CREATE INDEX IF NOT EXISTS idx_compaction_checkpoints_thread_created + ON compaction_checkpoints(workspace_id, thread_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_compaction_checkpoints_turn + ON compaction_checkpoints(source_turn_id, created_at DESC); + `); + + if (!input.hasMigration(WORKSPACE_PROVENANCE_MIGRATION_VERSION)) { + input.recordMigration(WORKSPACE_PROVENANCE_MIGRATION_VERSION, WORKSPACE_PROVENANCE_MIGRATION_NAME); + } + return true; +} diff --git a/packages/core/src/storage/sqlite/workspace-provenance-store.ts b/packages/core/src/storage/sqlite/workspace-provenance-store.ts new file mode 100644 index 000000000..4e6d771c7 --- /dev/null +++ b/packages/core/src/storage/sqlite/workspace-provenance-store.ts @@ -0,0 +1,426 @@ +import { randomUUID } from "node:crypto"; +import type { SQLOutputValue } from "node:sqlite"; +import { + isConversationDomainRef, + workspaceMutationEffectStates, + workspaceMutationOperations, + type CompactionCheckpoint, + type CreateCompactionCheckpointInput, + type CreateWorkspaceMutationRecordInput, + type ListCompactionCheckpointsOptions, + type ListWorkspaceMutationRecordsOptions, + type WorkspaceMutationRecord, +} from "@ling-stack/contracts"; +import { DEFAULT_WORKSPACE_ID } from "../../workspace-types.js"; +import { + parseJson, + readNullableString, + readNumber, + readString, + stringifyJson, +} from "./helpers.js"; +import type { SqliteStoreContext } from "./store-context.js"; + +const canonicalTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u; + +export class SqliteWorkspaceProvenanceStore { + constructor(private readonly context: SqliteStoreContext) {} + + createWorkspaceMutationRecord(input: CreateWorkspaceMutationRecordInput): WorkspaceMutationRecord { + const records = this.createWorkspaceMutationRecords([input]); + const record = records[0]; + if (!record) throw new Error("Workspace mutation record was not created."); + return record; + } + + createWorkspaceMutationRecords(inputs: readonly CreateWorkspaceMutationRecordInput[]): WorkspaceMutationRecord[] { + if (inputs.length === 0) return []; + inputs.forEach(validateMutationInput); + const workspaceIds = new Set(inputs.map((input) => normalizeRequired(input.workspaceId ?? DEFAULT_WORKSPACE_ID, "workspace mutation workspace id"))); + if (workspaceIds.size !== 1) throw new Error("Workspace mutation batch must use one workspace."); + const workspaceId = [...workspaceIds][0]!; + this.context.database.exec("BEGIN IMMEDIATE"); + try { + inputs.forEach((input) => assertMutationReferences(this.context, input)); + const records: WorkspaceMutationRecord[] = []; + const nextSequence = this.context.database.prepare(` + INSERT INTO workspace_provenance_sequences (workspace_id, last_seq) + VALUES (?, 1) + ON CONFLICT(workspace_id) DO UPDATE SET last_seq = last_seq + 1 + RETURNING last_seq + `); + for (const input of inputs) { + const sequenceRow = nextSequence.get(workspaceId) as Record; + const record: WorkspaceMutationRecord = { + id: input.id ?? `workspace_mutation_${randomUUID()}`, + workspaceId, + cwd: input.cwd, + ...(input.threadId ? { threadId: input.threadId } : {}), + ...(input.turnId ? { turnId: input.turnId } : {}), + runId: input.runId, + ...(input.toolCallId ? { toolCallId: input.toolCallId } : {}), + ...(input.approvalId ? { approvalId: input.approvalId } : {}), + ...(input.artifactId ? { artifactId: input.artifactId } : {}), + ...(input.forwardPatchArtifactId ? { forwardPatchArtifactId: input.forwardPatchArtifactId } : {}), + patchPlanId: input.patchPlanId, + operationIndex: input.operationIndex, + path: input.path, + operation: input.operation, + beforeSha256: input.beforeSha256, + afterSha256: input.afterSha256, + inversePatchArtifactId: input.inversePatchArtifactId, + provenanceSeq: readNumber(sequenceRow, "last_seq"), + effectState: input.effectState ?? "applied", + sourceRefs: uniqueDomainRefs(input.sourceRefs ?? []), + createdAt: input.createdAt, + }; + this.context.database.prepare(` + INSERT INTO workspace_mutation_records ( + id, workspace_id, cwd, thread_id, turn_id, run_id, tool_call_id, + approval_id, artifact_id, forward_patch_artifact_id, patch_plan_id, + operation_index, path, operation, before_sha256, after_sha256, + inverse_patch_artifact_id, provenance_seq, effect_state, source_refs_json, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + record.id, + record.workspaceId, + record.cwd, + record.threadId ?? null, + record.turnId ?? null, + record.runId, + record.toolCallId ?? null, + record.approvalId ?? null, + record.artifactId ?? null, + record.forwardPatchArtifactId ?? null, + record.patchPlanId, + record.operationIndex, + record.path, + record.operation, + record.beforeSha256, + record.afterSha256, + record.inversePatchArtifactId, + record.provenanceSeq, + record.effectState, + stringifyJson(record.sourceRefs), + record.createdAt, + ); + records.push(record); + } + this.context.database.exec("COMMIT"); + return records; + } catch (error) { + this.context.database.exec("ROLLBACK"); + throw error; + } + } + + getWorkspaceMutationRecord(id: string): WorkspaceMutationRecord | undefined { + const row = this.context.database.prepare(`${workspaceMutationSelectSql} WHERE id = ?`).get(id); + return row ? mapWorkspaceMutationRecord(row as Record) : undefined; + } + + listWorkspaceMutationRecords(options: ListWorkspaceMutationRecordsOptions = {}): WorkspaceMutationRecord[] { + const clauses: string[] = []; + const params: (string | number)[] = []; + for (const [column, value] of [ + ["workspace_id", options.workspaceId], + ["thread_id", options.threadId], + ["turn_id", options.turnId], + ["run_id", options.runId], + ["patch_plan_id", options.patchPlanId], + ["effect_state", options.effectState], + ] as const) { + if (value) { + clauses.push(`${column} = ?`); + params.push(value); + } + } + params.push(Math.min(Math.max(options.limit ?? 500, 1), 2_000)); + return this.context.database.prepare(` + ${workspaceMutationSelectSql} + ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY provenance_seq ASC, operation_index ASC + LIMIT ? + `).all(...params).map((row) => mapWorkspaceMutationRecord(row as Record)); + } + + getLatestWorkspaceProvenanceWatermark(workspaceId = DEFAULT_WORKSPACE_ID): string { + const row = this.context.database.prepare(` + SELECT COALESCE(last_seq, 0) AS last_seq + FROM workspace_provenance_sequences + WHERE workspace_id = ? + `).get(workspaceId) as Record | undefined; + return `${workspaceId}:${row ? readNumber(row, "last_seq") : 0}`; + } + + createCompactionCheckpoint(input: CreateCompactionCheckpointInput): CompactionCheckpoint { + validateCheckpointInput(input); + const workspaceId = normalizeRequired(input.workspaceId ?? DEFAULT_WORKSPACE_ID, "compaction checkpoint workspace id"); + const coveredTurnIds = uniqueStrings(input.coveredTurnIds); + const summaryIds = uniqueStrings(input.summaryIds ?? []); + const sourceRefs = uniqueDomainRefs(input.sourceRefs); + this.context.database.exec("BEGIN IMMEDIATE"); + try { + assertCheckpointReferences(this.context, input.threadId, input.sourceTurnId, coveredTurnIds, summaryIds, input.coveredItemSeqFrom, input.coveredItemSeqTo); + const currentWatermark = this.getLatestWorkspaceProvenanceWatermark(workspaceId); + assertValidWatermark(input.workspaceProvenanceWatermark, workspaceId, currentWatermark); + const checkpoint: CompactionCheckpoint = { + id: input.id ?? `compaction_checkpoint_${randomUUID()}`, + workspaceId, + threadId: input.threadId, + ...(input.channelId ? { channelId: input.channelId } : {}), + sourceTurnId: input.sourceTurnId, + ...(input.contextViewId ? { contextViewId: input.contextViewId } : {}), + ...(input.contextViewCreatedAt ? { contextViewCreatedAt: input.contextViewCreatedAt } : {}), + coveredItemSeqFrom: input.coveredItemSeqFrom, + coveredItemSeqTo: input.coveredItemSeqTo, + coveredTurnIds, + summaryIds, + ...(input.summaryContentSha256 ? { summaryContentSha256: input.summaryContentSha256 } : {}), + sourceRefs, + workspaceProvenanceWatermark: input.workspaceProvenanceWatermark, + compactionPolicyVersion: input.compactionPolicyVersion, + createdAt: input.createdAt, + }; + this.context.database.prepare(` + INSERT INTO compaction_checkpoints ( + id, workspace_id, thread_id, channel_id, source_turn_id, context_view_id, + context_view_created_at, covered_item_seq_from, covered_item_seq_to, + covered_turn_ids_json, summary_ids_json, summary_content_sha256, + source_refs_json, workspace_provenance_watermark, compaction_policy_version, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + checkpoint.id, + checkpoint.workspaceId, + checkpoint.threadId, + checkpoint.channelId ?? null, + checkpoint.sourceTurnId, + checkpoint.contextViewId ?? null, + checkpoint.contextViewCreatedAt ?? null, + checkpoint.coveredItemSeqFrom, + checkpoint.coveredItemSeqTo, + stringifyJson(checkpoint.coveredTurnIds), + stringifyJson(checkpoint.summaryIds), + checkpoint.summaryContentSha256 ?? null, + stringifyJson(checkpoint.sourceRefs), + checkpoint.workspaceProvenanceWatermark, + checkpoint.compactionPolicyVersion, + checkpoint.createdAt, + ); + this.context.database.exec("COMMIT"); + return checkpoint; + } catch (error) { + this.context.database.exec("ROLLBACK"); + throw error; + } + } + + getCompactionCheckpoint(id: string): CompactionCheckpoint | undefined { + const row = this.context.database.prepare(`${compactionCheckpointSelectSql} WHERE id = ?`).get(id); + return row ? mapCompactionCheckpoint(row as Record) : undefined; + } + + listCompactionCheckpoints(options: ListCompactionCheckpointsOptions = {}): CompactionCheckpoint[] { + const clauses: string[] = []; + const params: (string | number)[] = []; + for (const [column, value] of [ + ["workspace_id", options.workspaceId], + ["thread_id", options.threadId], + ["source_turn_id", options.sourceTurnId], + ] as const) { + if (value) { + clauses.push(`${column} = ?`); + params.push(value); + } + } + params.push(Math.min(Math.max(options.limit ?? 50, 1), 500)); + return this.context.database.prepare(` + ${compactionCheckpointSelectSql} + ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY created_at DESC, id DESC + LIMIT ? + `).all(...params).map((row) => mapCompactionCheckpoint(row as Record)); + } +} + +const workspaceMutationSelectSql = ` + SELECT id, workspace_id, cwd, thread_id, turn_id, run_id, tool_call_id, + approval_id, artifact_id, forward_patch_artifact_id, patch_plan_id, + operation_index, path, operation, before_sha256, after_sha256, + inverse_patch_artifact_id, provenance_seq, effect_state, source_refs_json, + created_at + FROM workspace_mutation_records +`; + +const compactionCheckpointSelectSql = ` + SELECT id, workspace_id, thread_id, channel_id, source_turn_id, context_view_id, + context_view_created_at, covered_item_seq_from, covered_item_seq_to, + covered_turn_ids_json, summary_ids_json, summary_content_sha256, + source_refs_json, workspace_provenance_watermark, compaction_policy_version, + created_at + FROM compaction_checkpoints +`; + +function mapWorkspaceMutationRecord(row: Record): WorkspaceMutationRecord { + return { + id: readString(row, "id"), + workspaceId: readString(row, "workspace_id"), + cwd: readString(row, "cwd"), + ...(readNullableString(row, "thread_id") ? { threadId: readNullableString(row, "thread_id") } : {}), + ...(readNullableString(row, "turn_id") ? { turnId: readNullableString(row, "turn_id") } : {}), + runId: readString(row, "run_id"), + ...(readNullableString(row, "tool_call_id") ? { toolCallId: readNullableString(row, "tool_call_id") } : {}), + ...(readNullableString(row, "approval_id") ? { approvalId: readNullableString(row, "approval_id") } : {}), + ...(readNullableString(row, "artifact_id") ? { artifactId: readNullableString(row, "artifact_id") } : {}), + ...(readNullableString(row, "forward_patch_artifact_id") ? { forwardPatchArtifactId: readNullableString(row, "forward_patch_artifact_id") } : {}), + patchPlanId: readString(row, "patch_plan_id"), + operationIndex: readNumber(row, "operation_index"), + path: readString(row, "path"), + operation: readString(row, "operation") as WorkspaceMutationRecord["operation"], + beforeSha256: readString(row, "before_sha256"), + afterSha256: readString(row, "after_sha256"), + inversePatchArtifactId: readString(row, "inverse_patch_artifact_id"), + provenanceSeq: readNumber(row, "provenance_seq"), + effectState: readString(row, "effect_state") as WorkspaceMutationRecord["effectState"], + sourceRefs: readDomainRefs(row, "source_refs_json"), + createdAt: readString(row, "created_at"), + }; +} + +function mapCompactionCheckpoint(row: Record): CompactionCheckpoint { + return { + id: readString(row, "id"), + workspaceId: readString(row, "workspace_id"), + threadId: readString(row, "thread_id"), + ...(readNullableString(row, "channel_id") ? { channelId: readNullableString(row, "channel_id") } : {}), + sourceTurnId: readString(row, "source_turn_id"), + ...(readNullableString(row, "context_view_id") ? { contextViewId: readNullableString(row, "context_view_id") } : {}), + ...(readNullableString(row, "context_view_created_at") ? { contextViewCreatedAt: readNullableString(row, "context_view_created_at") } : {}), + coveredItemSeqFrom: readNumber(row, "covered_item_seq_from"), + coveredItemSeqTo: readNumber(row, "covered_item_seq_to"), + coveredTurnIds: readStringArray(row, "covered_turn_ids_json"), + summaryIds: readStringArray(row, "summary_ids_json"), + ...(readNullableString(row, "summary_content_sha256") ? { summaryContentSha256: readNullableString(row, "summary_content_sha256") } : {}), + sourceRefs: readDomainRefs(row, "source_refs_json"), + workspaceProvenanceWatermark: readString(row, "workspace_provenance_watermark"), + compactionPolicyVersion: readString(row, "compaction_policy_version"), + createdAt: readString(row, "created_at"), + }; +} + +function validateMutationInput(input: CreateWorkspaceMutationRecordInput): void { + for (const [value, label] of [ + [input.cwd, "cwd"], [input.runId, "run id"], [input.patchPlanId, "patch plan id"], + [input.path, "path"], [input.beforeSha256, "before hash"], [input.afterSha256, "after hash"], + [input.inversePatchArtifactId, "inverse patch artifact id"], + ] as const) normalizeRequired(value, `workspace mutation ${label}`); + if (!Number.isSafeInteger(input.operationIndex) || input.operationIndex < 0) { + throw new RangeError(`Invalid workspace mutation operation index: ${input.operationIndex}`); + } + if (!workspaceMutationOperations.includes(input.operation)) throw new Error(`Invalid workspace mutation operation: ${input.operation}`); + if (input.effectState && !workspaceMutationEffectStates.includes(input.effectState)) throw new Error(`Invalid workspace mutation effect state: ${input.effectState}`); + assertCanonicalTimestamp(input.createdAt); + if ((input.sourceRefs ?? []).some((ref) => !isConversationDomainRef(ref))) throw new Error("Workspace mutation contains an invalid source ref."); +} + +function validateCheckpointInput(input: CreateCompactionCheckpointInput): void { + normalizeRequired(input.threadId, "compaction checkpoint thread id"); + normalizeRequired(input.sourceTurnId, "compaction checkpoint source turn id"); + normalizeRequired(input.workspaceProvenanceWatermark, "compaction checkpoint provenance watermark"); + normalizeRequired(input.compactionPolicyVersion, "compaction checkpoint policy version"); + if (!Number.isSafeInteger(input.coveredItemSeqFrom) || input.coveredItemSeqFrom < 1 + || !Number.isSafeInteger(input.coveredItemSeqTo) || input.coveredItemSeqTo < input.coveredItemSeqFrom) { + throw new RangeError(`Invalid compaction checkpoint Item range: ${input.coveredItemSeqFrom}-${input.coveredItemSeqTo}`); + } + if (input.coveredTurnIds.length === 0) throw new Error("Compaction checkpoint must cover at least one Turn."); + if (input.sourceRefs.some((ref) => !isConversationDomainRef(ref))) throw new Error("Compaction checkpoint contains an invalid source ref."); + if (input.contextViewCreatedAt) assertCanonicalTimestamp(input.contextViewCreatedAt); + assertCanonicalTimestamp(input.createdAt); +} + +function assertMutationReferences(context: SqliteStoreContext, input: CreateWorkspaceMutationRecordInput): void { + const run = context.database.prepare("SELECT thread_id FROM run_sessions WHERE id = ?").get(input.runId) as Record | undefined; + if (!run) throw new Error(`Workspace mutation run not found: ${input.runId}`); + const runThreadId = readNullableString(run, "thread_id"); + if (input.threadId && runThreadId && input.threadId !== runThreadId) throw new Error(`Workspace mutation thread does not match run: ${input.threadId}`); + if (input.turnId) { + const turn = context.database.prepare("SELECT thread_id FROM ling_turns WHERE id = ?").get(input.turnId) as Record | undefined; + if (!turn) throw new Error(`Workspace mutation turn not found: ${input.turnId}`); + if (input.threadId && readString(turn, "thread_id") !== input.threadId) throw new Error(`Workspace mutation turn belongs to another thread: ${input.turnId}`); + } +} + +function assertCheckpointReferences( + context: SqliteStoreContext, + threadId: string, + sourceTurnId: string, + coveredTurnIds: readonly string[], + summaryIds: readonly string[], + from: number, + to: number, +): void { + const sourceTurn = context.database.prepare("SELECT thread_id FROM ling_turns WHERE id = ?").get(sourceTurnId) as Record | undefined; + if (!sourceTurn || readString(sourceTurn, "thread_id") !== threadId) throw new Error(`Compaction checkpoint source Turn does not belong to thread: ${sourceTurnId}`); + for (const turnId of coveredTurnIds) { + const turn = context.database.prepare("SELECT thread_id FROM ling_turns WHERE id = ?").get(turnId) as Record | undefined; + if (!turn || readString(turn, "thread_id") !== threadId) throw new Error(`Compaction checkpoint covered Turn does not belong to thread: ${turnId}`); + } + for (const summaryId of summaryIds) { + const summary = context.database.prepare("SELECT thread_id FROM conversation_context_summaries WHERE id = ?").get(summaryId) as Record | undefined; + if (!summary || readString(summary, "thread_id") !== threadId) throw new Error(`Compaction checkpoint summary does not belong to thread: ${summaryId}`); + } + const range = context.database.prepare(` + SELECT MIN(sequence) AS minimum, MAX(sequence) AS maximum, COUNT(*) AS count + FROM conversation_items + WHERE thread_id = ? AND sequence BETWEEN ? AND ? AND status = 'committed' + `).get(threadId, from, to) as Record; + if (readNumber(range, "count") !== to - from + 1 || readNumber(range, "minimum") !== from || readNumber(range, "maximum") !== to) { + throw new Error(`Compaction checkpoint Item range is not committed in thread: ${threadId}/${from}-${to}`); + } +} + +function assertValidWatermark(value: string, workspaceId: string, current: string): void { + const match = /^(.+):(\d+)$/u.exec(value); + const currentMatch = /^(.+):(\d+)$/u.exec(current); + if (!match || match[1] !== workspaceId || !currentMatch || Number(match[2]) > Number(currentMatch[2])) { + throw new Error(`Invalid workspace provenance watermark: ${value}`); + } +} + +function readStringArray(row: Record, key: string): string[] { + const value = parseJson(readString(row, key)); + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`Invalid string list in ${key}`); + return value; +} + +function readDomainRefs(row: Record, key: string) { + const value = parseJson(readString(row, key)); + if (!Array.isArray(value) || value.some((ref) => !isConversationDomainRef(ref))) throw new Error(`Invalid domain refs in ${key}`); + return value; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values.map((value) => normalizeRequired(value, "list value")))]; +} + +function uniqueDomainRefs(values: readonly import("@ling-stack/contracts").ConversationDomainRef[]) { + const seen = new Set(); + return values.filter((ref) => { + const key = `${ref.type}:${ref.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function normalizeRequired(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} must not be empty.`); + return normalized; +} + +function assertCanonicalTimestamp(value: string): void { + if (!canonicalTimestampPattern.test(value) || Number.isNaN(Date.parse(value))) throw new Error(`Timestamp must be canonical UTC: ${value}`); +} diff --git a/packages/core/test/workspace-provenance.test.mjs b/packages/core/test/workspace-provenance.test.mjs new file mode 100644 index 000000000..5daa45e66 --- /dev/null +++ b/packages/core/test/workspace-provenance.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; +import { createSqliteRunStore, invertPatchPlan, planApplyPatch } from "../dist/index.js"; + +test("workspace mutation provenance and compaction checkpoints survive SQLite reopen", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-workspace-provenance-")); + const now = "2026-08-25T01:00:00.000Z"; + const store = createSqliteRunStore(cwd); + try { + const channel = store.createChannel({ name: "Provenance", boundPath: cwd }, now); + const thread = store.resolveOrCreateConversationThread({ + surfaceKind: "channel_root", + channelId: channel.id, + title: "Provenance", + objective: "Keep workspace mutations reversible after compaction.", + }, now).thread; + const turnResult = store.appendConversationUserTurn({ + turnId: "turn_provenance", + itemId: "item_provenance", + channelId: channel.id, + threadId: thread.id, + title: "Edit file", + userInstruction: "Edit file", + payload: { + body: "Edit file", + format: "plain_text", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + }, + idempotencyKey: "provenance-turn", + }, now); + const run = store.createRun({ + id: "run_provenance", + cwd, + taskId: "task_provenance", + threadId: thread.id, + turnId: turnResult.turnId, + providerName: "test", + providerModel: "test", + taskSnapshot: {}, + providerSnapshot: {}, + requestSnapshot: {}, + createdAt: now, + }); + await writeFile(resolve(cwd, "file.txt"), "old\n", "utf8"); + const plan = planApplyPatch({ + patchText: `*** Begin Patch +*** Update File: file.txt +@@ +-old ++new +*** End Patch`, + readFile: (path) => path === "file.txt" ? "old\n" : undefined, + }); + const forward = store.createArtifact({ runId: run.id, type: "json", path: "forward", sealedPayload: plan, createdAt: now }); + const inverse = store.createArtifact({ runId: run.id, type: "json", path: "inverse", sealedPayload: invertPatchPlan(plan), createdAt: now }); + const records = store.createWorkspaceMutationRecords(plan.operations.map((operation, operationIndex) => ({ + cwd, + threadId: thread.id, + turnId: turnResult.turnId, + runId: run.id, + patchPlanId: "patch-plan-provenance", + operationIndex, + path: operation.path, + operation: operation.kind, + beforeSha256: "before", + afterSha256: "after", + forwardPatchArtifactId: forward.id, + inversePatchArtifactId: inverse.id, + sourceRefs: [{ type: "turn", id: turnResult.turnId }, { type: "run", id: run.id }], + createdAt: now, + }))); + assert.equal(records.length, 1); + assert.equal(records[0].provenanceSeq, 1); + assert.equal(store.getLatestWorkspaceProvenanceWatermark(), "workspace_default:1"); + const checkpoint = store.createCompactionCheckpoint({ + threadId: thread.id, + sourceTurnId: turnResult.turnId, + coveredItemSeqFrom: turnResult.item.sequence, + coveredItemSeqTo: turnResult.item.sequence, + coveredTurnIds: [turnResult.turnId], + summaryIds: [], + sourceRefs: [{ type: "turn", id: turnResult.turnId }], + workspaceProvenanceWatermark: "workspace_default:1", + compactionPolicyVersion: "continuity-v2-summary-v1", + createdAt: now, + }); + assert.equal(store.getCompactionCheckpoint(checkpoint.id)?.sourceTurnId, turnResult.turnId); + assert.throws(() => store.createCompactionCheckpoint({ + threadId: thread.id, + sourceTurnId: turnResult.turnId, + coveredItemSeqFrom: turnResult.item.sequence, + coveredItemSeqTo: turnResult.item.sequence, + coveredTurnIds: [turnResult.turnId], + summaryIds: ["missing_summary"], + sourceRefs: [{ type: "turn", id: turnResult.turnId }], + workspaceProvenanceWatermark: "workspace_default:1", + compactionPolicyVersion: "continuity-v2-summary-v1", + createdAt: now, + }), /summary does not belong to thread/u); + store.close(); + const reopened = createSqliteRunStore(cwd); + try { + assert.equal(reopened.listWorkspaceMutationRecords({ turnId: turnResult.turnId })[0].runId, run.id); + assert.equal(reopened.listCompactionCheckpoints({ threadId: thread.id })[0].id, checkpoint.id); + assert.equal(reopened.getLatestWorkspaceProvenanceWatermark(), "workspace_default:1"); + } finally { + reopened.close(); + } + } finally { + try { store.close(); } catch {} + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/daemon/src/features/channels/context-processing/conversation-summary-coordinator.ts b/packages/daemon/src/features/channels/context-processing/conversation-summary-coordinator.ts index 3f10bac96..4d08b2566 100644 --- a/packages/daemon/src/features/channels/context-processing/conversation-summary-coordinator.ts +++ b/packages/daemon/src/features/channels/context-processing/conversation-summary-coordinator.ts @@ -4,6 +4,7 @@ import type { RunStore, } from "@ling-stack/core"; import type { + CompactionCheckpoint, ConversationContextSummary, ConversationDomainRef, ConversationItemV1, @@ -79,7 +80,7 @@ export type CoordinateConversationSummaryResult = | { readonly status: "not_needed" } | { readonly status: "generator_unavailable"; readonly plan: ConversationSummaryPlan } | { readonly status: "generation_failed"; readonly plan: ConversationSummaryPlan; readonly error: unknown } - | { readonly status: "completed"; readonly plan: ConversationSummaryPlan; readonly commit: CommitConversationContextSummaryResult }; + | { readonly status: "completed"; readonly plan: ConversationSummaryPlan; readonly commit: CommitConversationContextSummaryResult; readonly checkpoint?: CompactionCheckpoint }; export function planConversationContextSummary(input: { readonly store: RunStore; @@ -227,7 +228,24 @@ export async function executeConversationSummaryPlan(input: { expectedActiveSummaryId: plan.activeSummary?.id ?? null, sourceFence: plan.sourceFence, }, input.now ?? new Date().toISOString()); - return { status: "completed", plan, commit }; + if (commit.status !== "committed") return { status: "completed", plan, commit }; + const sourceTurnIds = [...new Set(plan.sourceItems.flatMap((item) => item.turnId ? [item.turnId] : []))]; + const checkpoint = input.store.createCompactionCheckpoint({ + threadId: plan.projection.metadata.threadId, + channelId: plan.sourceItems.find((item) => item.channelId)?.channelId, + sourceTurnId: plan.projection.metadata.turnId, + contextViewId: plan.projection.metadata.contextViewId, + contextViewCreatedAt: plan.projection.metadata.contextViewCreatedAt, + coveredItemSeqFrom: plan.coveredFromSequence, + coveredItemSeqTo: plan.coveredThroughSequence, + coveredTurnIds: sourceTurnIds.length > 0 ? sourceTurnIds : [plan.projection.metadata.turnId], + summaryIds: [commit.summary.id, ...(plan.activeSummary ? [plan.activeSummary.id] : [])], + sourceRefs: plan.sourceRefs, + workspaceProvenanceWatermark: input.store.getLatestWorkspaceProvenanceWatermark(), + compactionPolicyVersion: CONVERSATION_SUMMARY_POLICY_VERSION, + createdAt: input.now ?? new Date().toISOString(), + }); + return { status: "completed", plan, commit, checkpoint }; } function throwIfSummaryAborted(signal: AbortSignal | undefined): void { diff --git a/packages/daemon/src/features/channels/workflow-workspace-change-recovery.ts b/packages/daemon/src/features/channels/workflow-workspace-change-recovery.ts index db79222ca..96ff3ba1d 100644 --- a/packages/daemon/src/features/channels/workflow-workspace-change-recovery.ts +++ b/packages/daemon/src/features/channels/workflow-workspace-change-recovery.ts @@ -29,6 +29,7 @@ import { summarizePatchFiles, summarizePatchPlans, uniquePaths, + withWorkspaceRecoveryLock, type RecoveryAction, type RecoveryIntentFile, type RecoveryPlanStep, @@ -111,45 +112,50 @@ export async function recoverWorkflowWorkspaceChanges( const context = buildWorkflowRecoveryContext(store, workflow); assertWorkflowRecoveryAvailable(context); const cwd = context.cwd as string; - const ownerRun = context.ownerRun as RunSession; - const plans = context.patchRuns.flatMap((item) => item.patchSet.plans); - const observation = observeWorkflowRecoveryState(cwd, workflow.id, plans, workflowRecoveryEvents(store, ownerRun.id, workflow.id)); - if (observation.conflict) { - throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", observation.conflict); - } - if ((action === "undo" && observation.state === "undone") || (action === "reapply" && observation.state !== "undone")) { - throw new RunWorkspaceRecoveryError( - 409, - "workspace_change_invalid_state", - action === "undo" ? "This workflow's patches are already undone." : "This workflow's patches must be undone before they can be reapplied.", - ); - } + return withWorkspaceRecoveryLock(cwd, async () => { + const lockedContext = buildWorkflowRecoveryContext(store, workflow); + assertWorkflowRecoveryAvailable(lockedContext); + const lockedCwd = lockedContext.cwd as string; + const lockedOwnerRun = lockedContext.ownerRun as RunSession; + const lockedPlans = lockedContext.patchRuns.flatMap((item) => item.patchSet.plans); + const observation = observeWorkflowRecoveryState(lockedCwd, workflow.id, lockedPlans, workflowRecoveryEvents(store, lockedOwnerRun.id, workflow.id)); + if (observation.conflict) { + throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", observation.conflict); + } + if ((action === "undo" && observation.state === "undone") || (action === "reapply" && observation.state !== "undone")) { + throw new RunWorkspaceRecoveryError( + 409, + "workspace_change_invalid_state", + action === "undo" ? "This workflow's patches are already undone." : "This workflow's patches must be undone before they can be reapplied.", + ); + } - const recoverySteps = workflowRecoveryPlanSteps(context.patchRuns, action); - const preflight = await preflightRecoveryPlanSequence(cwd, recoverySteps); - const recoveryId = randomUUID(); - store.appendEvent(ownerRun.id, "workspace_change_recovery_started", { - scope: "workflow", - workflowId: workflow.id, - runIds: context.patchRuns.map((item) => item.run.id), - action, - recoveryId, - cwd, - files: recoveryIntentFiles(preflight), - }); - commitRecovery(cwd, preflight); - store.appendEvent(ownerRun.id, action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied", { - scope: "workflow", - workflowId: workflow.id, - runIds: context.patchRuns.map((item) => item.run.id), - recoveryId, - cwd, - approvalIds: context.patchRuns.flatMap((item) => item.patchSet.approvals.map((approval) => approval.id)), - rawPatchSha256: plans.map((plan) => plan.rawPatchSha256), - touchedPaths: preflight.paths, - summary: summarizePatchPlans(plans), + const recoverySteps = workflowRecoveryPlanSteps(lockedContext.patchRuns, action); + const preflight = await preflightRecoveryPlanSequence(lockedCwd, recoverySteps); + const recoveryId = randomUUID(); + store.appendEvent(lockedOwnerRun.id, "workspace_change_recovery_started", { + scope: "workflow", + workflowId: workflow.id, + runIds: lockedContext.patchRuns.map((item) => item.run.id), + action, + recoveryId, + cwd: lockedCwd, + files: recoveryIntentFiles(preflight), + }); + commitRecovery(lockedCwd, preflight); + store.appendEvent(lockedOwnerRun.id, action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied", { + scope: "workflow", + workflowId: workflow.id, + runIds: lockedContext.patchRuns.map((item) => item.run.id), + recoveryId, + cwd: lockedCwd, + approvalIds: lockedContext.patchRuns.flatMap((item) => item.patchSet.approvals.map((approval) => approval.id)), + rawPatchSha256: lockedPlans.map((plan) => plan.rawPatchSha256), + touchedPaths: preflight.paths, + summary: summarizePatchPlans(lockedPlans), + }); + return buildWorkflowWorkspaceChangesResponse(store, workflow); }); - return buildWorkflowWorkspaceChangesResponse(store, workflow); } export async function buildWorkflowWorkspaceChangesResponse( diff --git a/packages/daemon/src/features/runs/approvals.ts b/packages/daemon/src/features/runs/approvals.ts index c9bf2e364..9873b49f9 100644 --- a/packages/daemon/src/features/runs/approvals.ts +++ b/packages/daemon/src/features/runs/approvals.ts @@ -266,6 +266,11 @@ export function createRunApprovalHandler(options: { draft: patchDraft, cwd: request.cwd, allowedWritePaths: options.approvalPolicy.allowedWritePaths, + provenance: { + store: options.store, + run: options.run, + toolCallId: request.toolCallId, + }, }); return { approved: true, @@ -474,6 +479,12 @@ export function createRunApprovalHandler(options: { cwd: request.cwd, allowedWritePaths: options.approvalPolicy.allowedWritePaths, previewArtifactIds: previewArtifacts.map((artifact) => artifact.id), + provenance: { + store: options.store, + run: options.run, + toolCallId: request.toolCallId, + approvalId: resolvedApproval.id, + }, }); handledResult = applied.handledResult; const resultArtifact = createExecutionActionResultArtifact({ diff --git a/packages/daemon/src/features/runs/patch-actions.ts b/packages/daemon/src/features/runs/patch-actions.ts index 8ab0e6ccb..d23550f80 100644 --- a/packages/daemon/src/features/runs/patch-actions.ts +++ b/packages/daemon/src/features/runs/patch-actions.ts @@ -1,12 +1,16 @@ +import { createHash } from "node:crypto"; import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"; import { APPLY_PATCH_FORMAT, ApplyPatchError, applyPatchPlan, + invertPatchPlan, parseApplyPatch, planApplyPatch, renderPatchPreview, + type RunSession, + type RunStore, type ApprovalPayload, type PatchOperation, type PatchPlan, @@ -32,6 +36,13 @@ export interface PatchActionApplyOutput { readonly resultArtifactContent: string; } +export interface PatchProvenanceInput { + readonly store: RunStore; + readonly run: RunSession; + readonly toolCallId?: string; + readonly approvalId?: string; +} + export function createPatchApprovalMetadata(request: PiRunnerApprovalRequest): PatchApprovalMetadata | undefined { const patchText = readPatchText(request.toolName, request.input); if (!patchText) { @@ -90,7 +101,13 @@ export async function applyPatchActionDraft(input: { readonly cwd: string; readonly allowedWritePaths: readonly string[]; readonly previewArtifactIds?: readonly string[]; + readonly provenance?: PatchProvenanceInput; }): Promise { + const before = new Map(input.draft.plan.touchedPaths.map((path) => [path, readWorkspaceTextFile(input.cwd, path)] as const)); + const provenanceArtifacts = input.provenance + ? createPatchProvenanceArtifacts(input.provenance.store, input.provenance.run, input.draft.plan, new Date().toISOString()) + : undefined; + let patchApplied = false; try { assertPatchPathsWithinAllowedWritePaths(input.draft.plan.touchedPaths, input.cwd, input.allowedWritePaths); assertNoSymlinkEscapes(input.draft.plan.touchedPaths, input.cwd, input.allowedWritePaths); @@ -100,6 +117,20 @@ export async function applyPatchActionDraft(input: { writeFile: (path, content) => atomicWriteWorkspaceTextFile(input.cwd, path, content), deleteFile: (path) => deleteWorkspaceFile(input.cwd, path), }); + patchApplied = true; + if (input.provenance && provenanceArtifacts) { + const after = new Map(input.draft.plan.touchedPaths.map((path) => [path, readWorkspaceTextFile(input.cwd, path)] as const)); + recordPatchProvenance({ + ...input.provenance, + cwd: input.cwd, + plan: input.draft.plan, + before, + after, + forwardPatchArtifactId: provenanceArtifacts.forwardPatchArtifactId, + inversePatchArtifactId: provenanceArtifacts.inversePatchArtifactId, + now: new Date().toISOString(), + }); + } const resultArtifactContent = `${JSON.stringify({ status: result.status, format: APPLY_PATCH_FORMAT, @@ -127,6 +158,20 @@ export async function applyPatchActionDraft(input: { resultArtifactContent, }; } catch (error) { + if (patchApplied && input.provenance) { + try { + await applyPatchPlan({ + plan: invertPatchPlan(input.draft.plan), + readFile: (path) => readWorkspaceTextFile(input.cwd, path), + writeFile: (path, content) => atomicWriteWorkspaceTextFile(input.cwd, path, content), + deleteFile: (path) => deleteWorkspaceFile(input.cwd, path), + }); + } catch (rollbackError) { + const original = error instanceof Error ? error.message : String(error); + const rollback = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + error = new ApplyPatchError(`Patch applied without durable provenance and rollback failed: ${original}; ${rollback}`); + } + } const message = error instanceof Error ? error.message : String(error); const resultArtifactContent = `${JSON.stringify({ status: error instanceof ApplyPatchError ? "conflict" : "failed", @@ -154,6 +199,84 @@ export async function applyPatchActionDraft(input: { } } +function createPatchProvenanceArtifacts( + store: RunStore, + run: RunSession, + plan: PatchPlan, + now: string, +): { readonly forwardPatchArtifactId: string; readonly inversePatchArtifactId: string } { + const basePath = `.ling-stack/provenance/${run.id}/${plan.rawPatchSha256}`; + const forward = store.createArtifact({ + runId: run.id, + type: "json", + path: `${basePath}/forward-plan.json`, + title: "Workspace forward patch plan", + contentType: "application/json", + sealedPayload: plan, + createdAt: now, + }); + const inverse = store.createArtifact({ + runId: run.id, + type: "json", + path: `${basePath}/inverse-plan.json`, + title: "Workspace inverse patch plan", + contentType: "application/json", + sealedPayload: invertPatchPlan(plan), + createdAt: now, + }); + return { forwardPatchArtifactId: forward.id, inversePatchArtifactId: inverse.id }; +} + +export function recordPatchProvenance(input: PatchProvenanceInput & { + readonly cwd: string; + readonly plan: PatchPlan; + readonly before: ReadonlyMap; + readonly after: ReadonlyMap; + readonly forwardPatchArtifactId: string; + readonly inversePatchArtifactId: string; + readonly now: string; +}): void { + const turn = input.store.getLingTurnForRun(input.run.id); + // The sealed forward artifact is allocated per successful apply, so it makes + // the plan group unique even when the same patch text is applied twice in one Run. + const patchPlanId = `patch_plan_${input.forwardPatchArtifactId}`; + const sourceRefs = [ + { type: "run" as const, id: input.run.id }, + ...(turn ? [{ type: "turn" as const, id: turn.id }] : []), + ...(input.approvalId ? [{ type: "approval" as const, id: input.approvalId }] : []), + { type: "artifact" as const, id: input.forwardPatchArtifactId }, + { type: "artifact" as const, id: input.inversePatchArtifactId }, + ]; + input.store.createWorkspaceMutationRecords(input.plan.operations.map((operation, operationIndex) => ({ + cwd: input.cwd, + threadId: input.run.threadId, + turnId: turn?.id, + runId: input.run.id, + toolCallId: input.toolCallId, + approvalId: input.approvalId, + forwardPatchArtifactId: input.forwardPatchArtifactId, + patchPlanId, + operationIndex, + path: operation.path, + operation: operation.kind, + beforeSha256: hashWorkspaceContent(input.before.get(operation.path)), + afterSha256: hashWorkspaceContent(input.after.get(operation.path)), + inversePatchArtifactId: input.inversePatchArtifactId, + sourceRefs, + createdAt: input.now, + }))); +} + +export function hashWorkspaceContent(content: string | undefined): string { + const hash = createHash("sha256"); + if (content === undefined) hash.update("missing\\0", "utf8"); + else { + hash.update("text\\0", "utf8"); + hash.update(content, "utf8"); + } + return hash.digest("hex"); +} + function createPatchApprovalPayload(input: { readonly request: PiRunnerApprovalRequest; readonly patchText: string; diff --git a/packages/daemon/src/features/runs/workspace-change-recovery.ts b/packages/daemon/src/features/runs/workspace-change-recovery.ts index 9dc310869..630377116 100644 --- a/packages/daemon/src/features/runs/workspace-change-recovery.ts +++ b/packages/daemon/src/features/runs/workspace-change-recovery.ts @@ -1,5 +1,6 @@ import type { ServerResponse } from "node:http"; import { createHash, randomUUID } from "node:crypto"; +import { resolve as resolvePath } from "node:path"; import { APPLY_PATCH_FORMAT, ApplyPatchError, @@ -16,6 +17,8 @@ import { import type { RunWorkspaceChangeFile, RunWorkspaceChangesResponse, + TurnWorkspaceChangesResponse, + WorkspaceMutationRecord, } from "@ling-stack/contracts"; import type { RunStoreRegistry } from "../../runtime/store-registry.js"; import { writeJson } from "../../shared/response.js"; @@ -31,9 +34,13 @@ import { export type RecoveryAction = "undo" | "reapply"; +const workspaceRecoveryLocks = new Map>(); + export interface RunPatchSet { readonly approvals: readonly ApprovalRequest[]; readonly plans: readonly PatchPlan[]; + readonly mutationRecords: readonly WorkspaceMutationRecord[]; + readonly provenanceComplete: boolean; readonly files: readonly RunWorkspaceChangeFile[]; readonly summary: PatchSummary; } @@ -53,6 +60,7 @@ export interface RecoveryIntentFile { interface RecoveryIntent { readonly action: RecoveryAction; readonly recoveryId: string; + readonly idempotencyKey?: string; readonly files: readonly RecoveryIntentFile[]; } @@ -60,6 +68,8 @@ interface RecoveryObservation { readonly state: "applied" | "undone"; readonly updatedAt?: string; readonly conflict?: string; + /** The latest recovery intent reached its after-state before the terminal event was persisted. */ + readonly completedIntent?: RecoveryIntent; } export interface RecoveryPlanStep { @@ -112,6 +122,14 @@ export async function recoverRunWorkspaceChanges( store: RunStore, run: RunSession, action: RecoveryAction, +): Promise { + return withWorkspaceRecoveryLock(run.cwd, () => recoverRunWorkspaceChangesLocked(store, run, action)); +} + +async function recoverRunWorkspaceChangesLocked( + store: RunStore, + run: RunSession, + action: RecoveryAction, ): Promise { if (!isTerminalRun(run)) { throw new RunWorkspaceRecoveryError(409, "run_not_terminal", "Workspace changes can be recovered only after the run is complete."); @@ -125,6 +143,17 @@ export async function recoverRunWorkspaceChanges( if (observation.conflict) { throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", observation.conflict); } + if (observation.completedIntent && observation.completedIntent.action === action) { + store.appendEvent(run.id, observation.completedIntent.action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied", { + recoveryId: observation.completedIntent.recoveryId, + cwd: run.cwd, + approvalIds: patchSet.approvals.map((approval) => approval.id), + rawPatchSha256: patchSet.plans.map((plan) => plan.rawPatchSha256), + touchedPaths: patchSet.files.map((file) => file.path), + summary: patchSet.summary, + }); + return buildRunWorkspaceChangesResponse(store, run); + } const currentState = observation.state; if ((action === "undo" && currentState === "undone") || (action === "reapply" && currentState !== "undone")) { throw new RunWorkspaceRecoveryError( @@ -156,6 +185,274 @@ export async function recoverRunWorkspaceChanges( return buildRunWorkspaceChangesResponse(store, run); } +/** Resolve every retry/resume Run for one canonical Ling Turn and recover its durable mutations. */ +export async function recoverTurnWorkspaceChanges( + store: RunStore, + turn: import("@ling-stack/core").LingTurn, + action: RecoveryAction, + idempotencyKey: string, +): Promise { + if (!idempotencyKey.trim()) throw new RunWorkspaceRecoveryError(400, "idempotency_key_required", "A recovery idempotency key is required."); + const links = store.listLingTurnRuns(turn.id); + const runs = links.map((link) => store.getRun(link.runId)).filter((run): run is RunSession => Boolean(run)); + const cwd = resolveTurnWorkspaceCwd(runs, store.paths.cwd); + return withWorkspaceRecoveryLock(cwd, async () => { + const context = buildTurnRecoveryContext(store, turn, runs); + if (context.runs.length === 0) throw new RunWorkspaceRecoveryError(404, "turn_runs_not_found", `No Runs are linked to Turn: ${turn.id}`); + if (context.runs.some((run) => !isTerminalRun(run))) throw new RunWorkspaceRecoveryError(409, "turn_not_terminal", "Workspace changes can be recovered only after all Turn Runs are complete."); + if (!context.provenanceComplete) throw new RunWorkspaceRecoveryError(409, "provenance_missing", "Undo is unavailable: durable workspace provenance is incomplete."); + if (context.steps.length === 0) throw new RunWorkspaceRecoveryError(409, "workspace_changes_unavailable", "This Turn has no successfully applied reversible workspace mutations."); + const ownerRun = context.runs.at(-1)!; + const events = turnRecoveryEvents(store, ownerRun.id, turn.id); + const prior = events.find((event) => isRecord(event.data) && event.data.idempotencyKey === idempotencyKey && event.type === (action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied")); + if (prior) return buildTurnWorkspaceChangesResponse(store, turn); + const patchSet = syntheticPatchSet(context.steps.map((step) => step.plan)); + const observation = observeRecoveryState(cwd, patchSet, events); + if (observation.conflict) throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", observation.conflict); + if (observation.completedIntent) { + if (observation.completedIntent.action === action && observation.completedIntent.idempotencyKey === idempotencyKey) { + store.appendEvent(ownerRun.id, action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied", { + scope: "turn", + turnId: turn.id, + runIds: context.runs.map((run) => run.id), + recoveryId: observation.completedIntent.recoveryId, + idempotencyKey, + cwd, + approvalIds: context.steps.flatMap((step) => step.approvals), + rawPatchSha256: context.steps.map((step) => step.plan.rawPatchSha256), + touchedPaths: patchSet.files.map((file) => file.path), + summary: patchSet.summary, + }); + return buildTurnWorkspaceChangesResponse(store, turn); + } + } + if ((action === "undo" && observation.state === "undone") || (action === "reapply" && observation.state !== "undone")) { + throw new RunWorkspaceRecoveryError(409, "workspace_change_invalid_state", action === "undo" ? "This Turn's patch is already undone." : "This Turn's patch must be undone before reapply."); + } + const preflight = await preflightRecoveryPlanSequence(cwd, turnRecoveryPlanSteps(context.steps, action)); + const recoveryId = randomUUID(); + store.appendEvent(ownerRun.id, "workspace_change_recovery_started", { + scope: "turn", + turnId: turn.id, + runIds: context.runs.map((run) => run.id), + action, + recoveryId, + idempotencyKey, + cwd, + files: recoveryIntentFiles(preflight), + }); + commitRecovery(cwd, preflight); + store.appendEvent(ownerRun.id, action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied", { + scope: "turn", + turnId: turn.id, + runIds: context.runs.map((run) => run.id), + recoveryId, + idempotencyKey, + cwd, + approvalIds: context.steps.flatMap((step) => step.approvals), + rawPatchSha256: context.steps.map((step) => step.plan.rawPatchSha256), + touchedPaths: preflight.paths, + summary: summarizePatchPlans(context.steps.map((step) => step.plan)), + }); + return buildTurnWorkspaceChangesResponse(store, turn); + }); +} + +export async function buildTurnWorkspaceChangesResponse( + store: RunStore, + turn: import("@ling-stack/core").LingTurn, +): Promise { + const runs = store.listLingTurnRuns(turn.id).map((link) => store.getRun(link.runId)).filter((run): run is RunSession => Boolean(run)); + const context = buildTurnRecoveryContext(store, turn, runs); + const steps = context.steps; + const plans = steps.map((step) => step.plan); + const ownerRun = runs.at(-1); + let workspaceCwd: string; + try { + workspaceCwd = resolveTurnWorkspaceCwd(runs, store.paths.cwd); + } catch (error) { + const base = { + cwd: ownerRun?.cwd ?? store.paths.cwd, + threadId: turn.threadId, + turnId: turn.id, + runIds: runs.map((run) => run.id), + files: summarizePatchFiles(plans), + summary: summarizePatchPlans(plans), + provenanceComplete: context.provenanceComplete, + updatedAt: turn.updatedAt, + } as const; + return { + ...base, + state: "conflicted", + canUndo: false, + canReapply: false, + unavailableReason: error instanceof Error ? error.message : String(error), + }; + } + const events = ownerRun ? turnRecoveryEvents(store, ownerRun.id, turn.id) : []; + const observation = ownerRun && plans.length > 0 ? observeRecoveryState(workspaceCwd, syntheticPatchSet(plans), events) : { state: "applied" as const }; + const checkpoint = store.listCompactionCheckpoints({ threadId: turn.threadId, limit: 100 }) + .find((candidate) => candidate.sourceTurnId === turn.id || candidate.coveredTurnIds.includes(turn.id)); + const base = { + cwd: workspaceCwd, + threadId: turn.threadId, + turnId: turn.id, + runIds: runs.map((run) => run.id), + files: summarizePatchFiles(plans), + summary: summarizePatchPlans(plans), + ...(checkpoint ? { checkpointId: checkpoint.id } : {}), + provenanceComplete: context.provenanceComplete, + updatedAt: observation.updatedAt ?? turn.updatedAt, + } as const; + if (runs.length === 0) return { ...base, state: "unavailable", canUndo: false, canReapply: false, provenanceComplete: false, unavailableReason: "No Runs are linked to this Turn." }; + if (!context.provenanceComplete) return { ...base, state: "unavailable", canUndo: false, canReapply: false, unavailableReason: "Undo unavailable: durable workspace provenance is incomplete." }; + if (plans.length === 0) return { ...base, state: "unavailable", canUndo: false, canReapply: false, unavailableReason: "No successfully applied reversible workspace mutations were found." }; + if (observation.conflict) return { ...base, state: "conflicted", canUndo: false, canReapply: false, unavailableReason: observation.conflict }; + try { + await preflightRecoveryPlanSequence(workspaceCwd, turnRecoveryPlanSteps(context.steps, observation.state === "undone" ? "reapply" : "undo")); + return { ...base, state: observation.state, canUndo: observation.state === "applied", canReapply: observation.state === "undone" }; + } catch (error) { + return { ...base, state: "conflicted", canUndo: false, canReapply: false, unavailableReason: error instanceof Error ? error.message : String(error) }; + } +} + +export async function handleGetTurnWorkspaceChanges( + response: ServerResponse, + url: URL, + storeRegistry: RunStoreRegistry, + turnId: string, +): Promise { + const store = storeRegistry.findStoreForTurn(turnId, url.searchParams.get("cwd") ?? undefined); + const turn = store?.getLingTurn(turnId); + if (!store || !turn) { + writeJson(response, 404, { error: "turn_not_found", message: "Turn was not found in the active daemon stores." }); + return; + } + writeJson(response, 200, await buildTurnWorkspaceChangesResponse(store, turn)); +} + +export async function handleMutateTurnWorkspaceChanges( + response: ServerResponse, + url: URL, + storeRegistry: RunStoreRegistry, + turnId: string, + action: RecoveryAction, +): Promise { + const store = storeRegistry.findStoreForTurn(turnId, url.searchParams.get("cwd") ?? undefined); + const turn = store?.getLingTurn(turnId); + if (!store || !turn) { + writeJson(response, 404, { error: "turn_not_found", message: "Turn was not found in the active daemon stores." }); + return; + } + try { + const idempotencyKey = url.searchParams.get("idempotencyKey") ?? ""; + writeJson(response, 200, await recoverTurnWorkspaceChanges(store, turn, action, idempotencyKey)); + } catch (error) { + const statusCode = error instanceof RunWorkspaceRecoveryError ? error.statusCode : recoveryErrorStatus(error); + writeJson(response, statusCode, { + error: error instanceof RunWorkspaceRecoveryError ? error.code : statusCode === 403 ? "workspace_change_forbidden" : statusCode === 409 ? "workspace_change_conflict" : "workspace_change_recovery_failed", + message: error instanceof Error ? error.message : String(error), + }); + } +} + +interface TurnRecoveryStep { + readonly run: RunSession; + readonly plan: PatchPlan; + readonly approvals: readonly string[]; + readonly minProvenanceSeq: number; + readonly allowedWritePathSets: readonly (readonly string[])[]; +} + +function buildTurnRecoveryContext(store: RunStore, turn: import("@ling-stack/core").LingTurn, runs: readonly RunSession[]) { + const steps: TurnRecoveryStep[] = []; + let provenanceComplete = runs.length > 0; + for (const run of runs) { + const patchSet = collectRunPatchSet(store, run.id); + if (patchSet.mutationRecords.length === 0) { + if (patchSet.plans.length > 0) provenanceComplete = false; + continue; + } + if (!patchSet.provenanceComplete) { + provenanceComplete = false; + continue; + } + const byPlan = new Map(); + for (const record of patchSet.mutationRecords) { + const group = byPlan.get(record.patchPlanId) ?? []; + group.push(record); + byPlan.set(record.patchPlanId, group); + } + for (const records of byPlan.values()) { + const forwardId = records[0]?.forwardPatchArtifactId; + const artifact = forwardId ? store.getArtifact(forwardId) : undefined; + if (!artifact || !isPatchPlan(artifact.sealedPayload)) { + provenanceComplete = false; + continue; + } + let allowedWritePathSets: readonly (readonly string[])[]; + try { + allowedWritePathSets = readAllowedWritePathSets(store, run); + } catch { + provenanceComplete = false; + continue; + } + steps.push({ + run, + plan: artifact.sealedPayload, + approvals: [...new Set(records.flatMap((record) => record.approvalId ? [record.approvalId] : []))], + minProvenanceSeq: Math.min(...records.map((record) => record.provenanceSeq)), + allowedWritePathSets, + }); + } + } + steps.sort((left, right) => left.minProvenanceSeq - right.minProvenanceSeq); + return { turn, runs, steps, provenanceComplete }; +} + +function resolveTurnWorkspaceCwd(runs: readonly RunSession[], fallbackCwd: string): string { + const canonicalCwd = resolvePath(runs[0]?.cwd ?? fallbackCwd); + if (runs.some((run) => resolvePath(run.cwd) !== canonicalCwd)) { + throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", "Linked Turn Runs resolve to different workspaces; recovery is blocked until the workspace lineage is repaired."); + } + return canonicalCwd; +} + +function turnRecoveryPlanSteps(steps: readonly TurnRecoveryStep[], action: RecoveryAction): RecoveryPlanStep[] { + const ordered = action === "undo" ? [...steps].reverse() : [...steps]; + return ordered.map((step) => ({ + plan: action === "undo" ? invertPatchPlan(step.plan) : step.plan, + allowedWritePathSets: step.allowedWritePathSets, + })); +} + +function syntheticPatchSet(plans: readonly PatchPlan[]): RunPatchSet { + return { approvals: [], plans, mutationRecords: [], provenanceComplete: true, files: summarizePatchFiles(plans), summary: summarizePatchPlans(plans) }; +} + +function turnRecoveryEvents(store: RunStore, ownerRunId: string, turnId: string): readonly RunEventEnvelope[] { + return listAllRunEvents(store, ownerRunId).filter((event) => { + if (!isRecord(event.data)) return false; + return event.data.scope === "turn" && event.data.turnId === turnId; + }); +} + +export async function withWorkspaceRecoveryLock(cwd: string, fn: () => Promise): Promise { + const key = resolvePath(cwd); + const previous = workspaceRecoveryLocks.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { release = resolve; }); + const queued = previous.then(() => current); + workspaceRecoveryLocks.set(key, queued); + await previous; + try { + return await fn(); + } finally { + release(); + if (workspaceRecoveryLocks.get(key) === queued) workspaceRecoveryLocks.delete(key); + } +} + export class RunWorkspaceRecoveryError extends Error { constructor( readonly statusCode: number, @@ -226,6 +523,21 @@ function resolveRunStore( } export function collectRunPatchSet(store: RunStore, runId: string): RunPatchSet { + const mutationRecords = store.listWorkspaceMutationRecords({ runId, limit: 2_000 }); + if (mutationRecords.length > 0) { + const durable = durablePatchPlans(store, mutationRecords); + const approvals = uniqueApprovals(mutationRecords + .flatMap((record) => record.approvalId ? [store.getApprovalRequest(record.approvalId)] : []) + .filter((approval): approval is ApprovalRequest => Boolean(approval))); + return { + approvals, + plans: durable.plans, + mutationRecords, + provenanceComplete: durable.complete, + files: summarizePatchFiles(durable.plans), + summary: summarizePatchPlans(durable.plans), + }; + } const events = listAllRunEvents(store, runId); const successfulToolEnds = new Map(); for (const event of events) { @@ -246,11 +558,49 @@ export function collectRunPatchSet(store: RunStore, runId: string): RunPatchSet return { approvals, plans, + mutationRecords: [], + provenanceComplete: false, files: summarizePatchFiles(plans), summary: summarizePatchPlans(plans), }; } +function durablePatchPlans( + store: RunStore, + records: readonly WorkspaceMutationRecord[], +): { readonly plans: readonly PatchPlan[]; readonly complete: boolean } { + const byPlan = new Map(); + for (const record of records) { + const group = byPlan.get(record.patchPlanId) ?? []; + group.push(record); + byPlan.set(record.patchPlanId, group); + } + const ordered = [...byPlan.values()].sort((left, right) => + Math.min(...left.map((record) => record.provenanceSeq)) - Math.min(...right.map((record) => record.provenanceSeq))); + const plans: PatchPlan[] = []; + for (const group of ordered) { + const artifactIds = new Set(group.map((record) => record.forwardPatchArtifactId).filter((id): id is string => Boolean(id))); + if (artifactIds.size !== 1 || group.some((record) => record.effectState !== "applied")) return { plans: [], complete: false }; + const artifact = store.getArtifact([...artifactIds][0]!); + if (!artifact || !isPatchPlan(artifact.sealedPayload)) return { plans: [], complete: false }; + const plan = artifact.sealedPayload; + const orderedRecords = [...group].sort((left, right) => left.operationIndex - right.operationIndex); + if (orderedRecords.length !== plan.operations.length + || orderedRecords.some((record, index) => { + const operation = plan.operations[index]; + return !operation || record.operationIndex !== index || record.path !== operation.path || record.operation !== operation.kind; + })) { + return { plans: [], complete: false }; + } + plans.push(plan); + } + return { plans, complete: plans.length > 0 }; +} + +function uniqueApprovals(approvals: readonly ApprovalRequest[]): ApprovalRequest[] { + return [...new Map(approvals.map((approval) => [approval.id, approval])).values()]; +} + function patchPlanFromApproval(approval: ApprovalRequest): PatchPlan | undefined { if (approval.payload.type !== "file_edit" && approval.payload.type !== "file_write") { return undefined; @@ -259,7 +609,7 @@ function patchPlanFromApproval(approval: ApprovalRequest): PatchPlan | undefined return isPatchPlan(plan) ? plan : undefined; } -function isPatchPlan(value: unknown): value is PatchPlan { +export function isPatchPlan(value: unknown): value is PatchPlan { return isRecord(value) && value.format === APPLY_PATCH_FORMAT && Array.isArray(value.operations) && Array.isArray(value.touchedPaths) && isRecord(value.summary) && typeof value.rawPatchSha256 === "string"; } @@ -332,6 +682,11 @@ export function commitRecovery(cwd: string, preflight: RecoveryPreflight): void } throw error; } + for (const path of preflight.paths) { + if (readWorkspaceTextFile(cwd, path) !== preflight.after.get(path)) { + throw new ApplyPatchError(`Workspace recovery verification failed: ${path}`); + } + } } function writeWorkspaceState(cwd: string, path: string, content: string | undefined): void { @@ -390,6 +745,7 @@ function observeRecoveryState( return { state: intent.action === "undo" ? "undone" : "applied", updatedAt: pendingEvent.ts, + completedIntent: intent, }; } if (matchesBefore) { @@ -437,7 +793,12 @@ function readRecoveryIntent(data: unknown, patchSet: RunPatchSet): RecoveryInten if (files.length === 0 || files.length !== expectedPaths.size) { return undefined; } - return { action: data.action, recoveryId: data.recoveryId, files }; + return { + action: data.action, + recoveryId: data.recoveryId, + ...(typeof data.idempotencyKey === "string" ? { idempotencyKey: data.idempotencyKey } : {}), + files, + }; } export function hashRecoveryContent(content: string | undefined): string { @@ -524,6 +885,6 @@ function runScopedRecoveryEvents(events: readonly RunEventEnvelope[]): readonly if (event.type !== "workspace_change_recovery_started" && event.type !== "workspace_changes_undone" && event.type !== "workspace_changes_reapplied") { return true; } - return !isRecord(event.data) || event.data.scope !== "workflow"; + return !isRecord(event.data) || event.data.scope === undefined; }); } diff --git a/packages/daemon/src/runtime/store-registry.ts b/packages/daemon/src/runtime/store-registry.ts index ca686bde2..a3c14fb86 100644 --- a/packages/daemon/src/runtime/store-registry.ts +++ b/packages/daemon/src/runtime/store-registry.ts @@ -112,6 +112,21 @@ export class RunStoreRegistry { return undefined; } + findStoreForTurn(turnId: string, cwd?: string): RunStore | undefined { + for (const store of this.storesByCwd.values()) { + if (store.getLingTurn(turnId)) return store; + } + for (const root of this.trustedWorkspaceRoots) { + const store = this.getStore(root); + if (store.getLingTurn(turnId)) return store; + } + if (cwd) { + const store = this.getStore(cwd); + if (store.getLingTurn(turnId)) return store; + } + return undefined; + } + findStoreForArtifact(artifactId: string, cwd?: string): { readonly store: RunStore; readonly artifact: RunArtifact } | undefined { for (const store of this.storesByCwd.values()) { const artifact = store.getArtifact(artifactId); diff --git a/packages/daemon/src/server/router.ts b/packages/daemon/src/server/router.ts index c72c4a605..c024b9b13 100644 --- a/packages/daemon/src/server/router.ts +++ b/packages/daemon/src/server/router.ts @@ -251,7 +251,9 @@ import { import { handleGetWorkspaceFilePreview } from "../features/previews/workspace-file-preview.js"; import { handleGetRunWorkspaceChanges, + handleGetTurnWorkspaceChanges, handleMutateRunWorkspaceChanges, + handleMutateTurnWorkspaceChanges, } from "../features/runs/workspace-change-recovery.js"; import { handleEffectiveConfig, handleWorkspaceSettings } from "../features/settings/routes.js"; import { handleSkillRoute } from "../features/skills/routes.js"; @@ -331,6 +333,7 @@ import { parseProviderRoute, parseRunInterventionRoute, parseRunRoute, + parseTurnRoute, parseSkillRoute, parseTaskRoute, parseTeammateDirectInterventionRoute, @@ -1384,6 +1387,29 @@ export async function routeRequest( return; } + const turnRoute = parseTurnRoute(url.pathname); + if (request.method === "GET" && turnRoute?.kind === "workspace-changes") { + if (!allowLocalWorkspaceRequest({ request, response, storeRegistry, cwd: url.searchParams.get("cwd") ?? undefined })) { + return; + } + await handleGetTurnWorkspaceChanges(response, url, storeRegistry, turnRoute.turnId); + return; + } + if (request.method === "POST" && turnRoute?.kind === "workspace-changes-undo") { + if (!allowLocalWorkspaceRequest({ request, response, storeRegistry, cwd: url.searchParams.get("cwd") ?? undefined })) { + return; + } + await handleMutateTurnWorkspaceChanges(response, url, storeRegistry, turnRoute.turnId, "undo"); + return; + } + if (request.method === "POST" && turnRoute?.kind === "workspace-changes-reapply") { + if (!allowLocalWorkspaceRequest({ request, response, storeRegistry, cwd: url.searchParams.get("cwd") ?? undefined })) { + return; + } + await handleMutateTurnWorkspaceChanges(response, url, storeRegistry, turnRoute.turnId, "reapply"); + return; + } + const runInterventionRoute = parseRunInterventionRoute(url.pathname); if (runInterventionRoute) { if (runInterventionRoute.kind === "promote") { diff --git a/packages/daemon/src/server/routes.ts b/packages/daemon/src/server/routes.ts index aa09da9ce..a3fa1ae98 100644 --- a/packages/daemon/src/server/routes.ts +++ b/packages/daemon/src/server/routes.ts @@ -78,6 +78,35 @@ export function parseRunRoute( return undefined; } +export function parseTurnRoute( + pathname: string, +): + | { readonly kind: "workspace-changes"; readonly turnId: string } + | { readonly kind: "workspace-changes-undo"; readonly turnId: string } + | { readonly kind: "workspace-changes-reapply"; readonly turnId: string } + | undefined { + const segments = decodeRouteSegments(pathname); + if (!segments) return undefined; + if (segments.length === 3 && segments[0] === "turns" && segments[2] === "workspace-changes") { + return { kind: "workspace-changes", turnId: segments[1] }; + } + if (segments.length === 4 && segments[0] === "turns" && segments[2] === "workspace-changes" && segments[3] === "undo") { + return { kind: "workspace-changes-undo", turnId: segments[1] }; + } + if (segments.length === 4 && segments[0] === "turns" && segments[2] === "workspace-changes" && segments[3] === "reapply") { + return { kind: "workspace-changes-reapply", turnId: segments[1] }; + } + return undefined; +} + +function decodeRouteSegments(pathname: string): string[] | undefined { + try { + return pathname.split("/").filter(Boolean).map(decodeURIComponent); + } catch { + return undefined; + } +} + export function parseRunInterventionRoute( pathname: string, ): diff --git a/packages/daemon/test/conversation-summary-coordinator.test.mjs b/packages/daemon/test/conversation-summary-coordinator.test.mjs index 7e167eda9..451c33caf 100644 --- a/packages/daemon/test/conversation-summary-coordinator.test.mjs +++ b/packages/daemon/test/conversation-summary-coordinator.test.mjs @@ -49,6 +49,11 @@ test("summary coordinator compacts stable turns, preserves a raw tail, and advan assert.equal(first.status, "completed"); assert.equal(first.commit.status, "committed"); assert.equal(first.commit.summary.coveredThroughSequence, firstBatch[9].item.sequence); + assert.ok(first.checkpoint); + assert.equal(first.checkpoint.sourceTurnId, firstPlan.projection.metadata.turnId); + assert.deepEqual(first.checkpoint.summaryIds, [first.commit.summary.id]); + assert.equal(first.checkpoint.coveredItemSeqFrom, firstBatch[0].item.sequence); + assert.equal(first.checkpoint.coveredItemSeqTo, firstBatch[9].item.sequence); const secondBatch = []; for (let index = 0; index < 10; index += 1) { @@ -70,6 +75,8 @@ test("summary coordinator compacts stable turns, preserves a raw tail, and advan assert.equal(second.commit.summary.sourceRefs.some((ref) => ref.type === "context_summary" && ref.id === first.commit.summary.id), true); assert.equal(fixture.store.getConversationContextSummary(first.commit.summary.id)?.status, "superseded"); + assert.ok(second.checkpoint); + assert.deepEqual(second.checkpoint.summaryIds, [second.commit.summary.id, first.commit.summary.id]); }); test("summary coordinator selects the hard trigger and discards a late assignment result", async (t) => { @@ -119,6 +126,8 @@ test("summary coordinator selects the hard trigger and discards a late assignmen }); assert.equal(result.status, "completed"); assert.deepEqual(result.commit, { status: "discarded_stale", reason: "assignments_changed" }); + assert.equal(result.checkpoint, undefined); + assert.deepEqual(fixture.store.listCompactionCheckpoints({ threadId: fixture.thread.id }), []); }); test("summary coordinator counts the active summary in the hard budget and bounds its replacement", async (t) => { diff --git a/packages/daemon/test/post-compaction-undo-routes.test.mjs b/packages/daemon/test/post-compaction-undo-routes.test.mjs new file mode 100644 index 000000000..a5fd9da02 --- /dev/null +++ b/packages/daemon/test/post-compaction-undo-routes.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseTurnRoute } from "../dist/server/routes.js"; + +test("post-compaction turn workspace-change routes parse only their supported shapes", () => { + assert.deepEqual(parseTurnRoute("/turns/turn_123/workspace-changes"), { + kind: "workspace-changes", + turnId: "turn_123", + }); + assert.deepEqual(parseTurnRoute("/turns/turn_123/workspace-changes/undo"), { + kind: "workspace-changes-undo", + turnId: "turn_123", + }); + assert.deepEqual(parseTurnRoute("/turns/turn_123/workspace-changes/reapply"), { + kind: "workspace-changes-reapply", + turnId: "turn_123", + }); +}); + +test("post-compaction turn workspace-change routes reject malformed paths", () => { + for (const pathname of [ + "/turns//workspace-changes", + "/turns/turn_123/workspace-changes/unknown", + "/turns/turn_123/workspace-changes/undo/extra", + "/turns/%E0%A4%A/workspace-changes", + "/runs/run_123/workspace-changes", + ]) { + assert.equal(parseTurnRoute(pathname), undefined, pathname); + } +}); + +test("post-compaction turn route IDs are decoded once", () => { + assert.deepEqual(parseTurnRoute("/turns/turn%2F123/workspace-changes"), { + kind: "workspace-changes", + turnId: "turn/123", + }); +}); diff --git a/packages/daemon/test/post-compaction-undo.test.mjs b/packages/daemon/test/post-compaction-undo.test.mjs new file mode 100644 index 000000000..a0d4ce3fe --- /dev/null +++ b/packages/daemon/test/post-compaction-undo.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; +import { createSqliteRunStore, invertPatchPlan, planApplyPatch } from "../../core/dist/index.js"; +import { + buildTurnWorkspaceChangesResponse, + hashRecoveryContent, + recoverTurnWorkspaceChanges, +} from "../dist/features/runs/workspace-change-recovery.js"; + +test("post-compaction Turn undo resolves durable mutation provenance across reopen", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-post-compaction-undo-")); + const now = "2026-08-25T02:00:00.000Z"; + const store = createSqliteRunStore(cwd); + try { + const channel = store.createChannel({ name: "Undo", boundPath: cwd }, now); + const thread = store.resolveOrCreateConversationThread({ + surfaceKind: "channel_root", channelId: channel.id, title: "Undo", objective: "Undo after compaction", + }, now).thread; + const turnResult = store.appendConversationUserTurn({ + turnId: "turn_post_compaction", + itemId: "item_post_compaction", + channelId: channel.id, + threadId: thread.id, + title: "Change file", + userInstruction: "Change file", + payload: { body: "Change file", format: "plain_text", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [] }, + idempotencyKey: "post-compaction-turn", + }, now); + const run = store.createRun({ + id: "run_post_compaction", + cwd, + taskId: "task_post_compaction", + threadId: thread.id, + turnId: turnResult.turnId, + providerName: "test", + providerModel: "test", + taskSnapshot: {}, providerSnapshot: {}, requestSnapshot: { + cwd, + effectivePolicySnapshot: { + mode: "workspace_write", toolDecisions: {}, allowedWritePaths: [cwd], + workspacePolicyUpdatedAt: now, riskNotes: [], + }, + }, + createdAt: now, + }); + const path = resolve(cwd, "file.txt"); + await writeFile(path, "old\n", "utf8"); + const plan = planApplyPatch({ + patchText: `*** Begin Patch +*** Update File: file.txt +@@ +-old ++new +*** End Patch`, + readFile: (relativePath) => relativePath === "file.txt" ? "old\n" : undefined, + }); + const forward = store.createArtifact({ runId: run.id, type: "json", path: "forward", sealedPayload: plan, createdAt: now }); + const inverse = store.createArtifact({ runId: run.id, type: "json", path: "inverse", sealedPayload: invertPatchPlan(plan), createdAt: now }); + await writeFile(path, "new\n", "utf8"); + store.createWorkspaceMutationRecord({ + cwd, threadId: thread.id, turnId: turnResult.turnId, runId: run.id, + patchPlanId: `patch_plan_${plan.rawPatchSha256}`, operationIndex: 0, + path: "file.txt", operation: "update", beforeSha256: "before", afterSha256: "after", + forwardPatchArtifactId: forward.id, inversePatchArtifactId: inverse.id, + sourceRefs: [{ type: "turn", id: turnResult.turnId }, { type: "run", id: run.id }], createdAt: now, + }); + store.markRunSucceeded(run.id, "2026-08-25T02:00:01.000Z"); + store.createCompactionCheckpoint({ + threadId: thread.id, sourceTurnId: turnResult.turnId, + coveredItemSeqFrom: turnResult.item.sequence, coveredItemSeqTo: turnResult.item.sequence, + coveredTurnIds: [turnResult.turnId], sourceRefs: [{ type: "turn", id: turnResult.turnId }], + workspaceProvenanceWatermark: "workspace_default:1", compactionPolicyVersion: "test", createdAt: now, + }); + + const before = await buildTurnWorkspaceChangesResponse(store, store.getLingTurn(turnResult.turnId)); + assert.equal(before.provenanceComplete, true); + assert.equal(before.canUndo, true); + // Simulate a daemon crash after the inverse write but before the terminal event. + store.appendEvent(run.id, "workspace_change_recovery_started", { + scope: "turn", + turnId: turnResult.turnId, + runIds: [run.id], + action: "undo", + recoveryId: "recovery-crash-before-terminal", + idempotencyKey: "undo-post-compaction-crash", + cwd, + files: [{ + path: "file.txt", + beforeSha256: hashRecoveryContent("new\n"), + afterSha256: hashRecoveryContent("old\n"), + }], + }); + await writeFile(path, "old\n", "utf8"); + const recoveredTerminal = await recoverTurnWorkspaceChanges(store, store.getLingTurn(turnResult.turnId), "undo", "undo-post-compaction-crash"); + assert.equal(recoveredTerminal.state, "undone"); + const replay = await recoverTurnWorkspaceChanges(store, store.getLingTurn(turnResult.turnId), "undo", "undo-post-compaction-crash"); + assert.equal(replay.state, "undone"); + const reapplied = await recoverTurnWorkspaceChanges(store, store.getLingTurn(turnResult.turnId), "reapply", "reapply-post-compaction-1"); + assert.equal(reapplied.state, "applied"); + assert.equal(await readFile(path, "utf8"), "new\n"); + await writeFile(path, "user edit\n", "utf8"); + await assert.rejects( + () => recoverTurnWorkspaceChanges(store, store.getLingTurn(turnResult.turnId), "undo", "undo-post-compaction-conflict"), + /changed after preview/, + ); + } finally { + try { store.close(); } catch {} + await rm(cwd, { recursive: true, force: true }); + } +}); From 1d71acb1782629e98f4095455264d989cb28f618 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Wed, 26 Aug 2026 01:26:42 +0800 Subject: [PATCH 6/8] feat: persist post-compaction undo requests --- docs/design/post-compaction-undo.md | 3 + packages/contracts/src/workspace-changes.ts | 66 +++++ packages/core/src/run-types.ts | 8 + packages/core/src/storage/sqlite-run-store.ts | 23 ++ .../core/src/storage/sqlite/migrations.ts | 7 + .../sqlite/post-compaction-undo-migration.ts | 51 ++++ .../sqlite/post-compaction-undo-store.ts | 240 ++++++++++++++++++ .../core/test/workspace-provenance.test.mjs | 47 ++++ .../runs/workspace-change-recovery.ts | 198 +++++++++++++-- .../daemon/test/post-compaction-undo.test.mjs | 2 + 10 files changed, 624 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/storage/sqlite/post-compaction-undo-migration.ts create mode 100644 packages/core/src/storage/sqlite/post-compaction-undo-store.ts diff --git a/docs/design/post-compaction-undo.md b/docs/design/post-compaction-undo.md index c703c9340..584688a26 100644 --- a/docs/design/post-compaction-undo.md +++ b/docs/design/post-compaction-undo.md @@ -479,6 +479,9 @@ The following slices are implemented in the current daemon/core code: enforced. Cross-workspace Run lineages fail closed. - Existing run-level recovery remains compatible; legacy Runs without provenance retain the old run-level fallback but are not eligible for Turn-level undo. +- A dedicated `post_compaction_undo_records` table now stores the idempotency key, target + lineage, expected workspace fingerprint, affected paths, and CAS state transitions. Run + events remain the detailed recovery evidence; the table is the durable request/state index. ### S3: projection and API surface (P2) — implemented (minimal) diff --git a/packages/contracts/src/workspace-changes.ts b/packages/contracts/src/workspace-changes.ts index fbc11e2de..21456bbed 100644 --- a/packages/contracts/src/workspace-changes.ts +++ b/packages/contracts/src/workspace-changes.ts @@ -189,6 +189,72 @@ export interface TurnWorkspaceChangesResponse { readonly updatedAt: string; } +export const postCompactionUndoStates = [ + "available", + "validating", + "applied", + "undone", + "conflict", + "provenance_missing", + "snapshot_unverified", + "expired", + "failed", +] as const; +export type PostCompactionUndoState = (typeof postCompactionUndoStates)[number]; +export type PostCompactionUndoAction = "undo" | "reapply"; + +/** Durable idempotency/CAS record for one Turn-level post-compaction recovery request. */ +export interface PostCompactionUndoRecord { + readonly id: string; + readonly idempotencyKey: string; + readonly workspaceId: string; + readonly threadId: string; + readonly targetTurnId: string; + readonly targetRunIds: readonly string[]; + readonly checkpointId?: string; + /** Fingerprint of the target paths observed immediately before this action. */ + readonly expectedWorkspaceFingerprint: string; + readonly action: PostCompactionUndoAction; + readonly state: PostCompactionUndoState; + readonly affectedPaths: readonly string[]; + readonly lastEventSeq: number; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface CreatePostCompactionUndoRecordInput { + readonly id?: string; + readonly idempotencyKey: string; + readonly workspaceId?: string; + readonly threadId: string; + readonly targetTurnId: string; + readonly targetRunIds: readonly string[]; + readonly checkpointId?: string; + readonly expectedWorkspaceFingerprint: string; + readonly action: PostCompactionUndoAction; + readonly state?: PostCompactionUndoState; + readonly affectedPaths: readonly string[]; + readonly lastEventSeq?: number; + readonly createdAt: string; + readonly updatedAt?: string; +} + +export interface UpdatePostCompactionUndoRecordInput { + readonly id: string; + readonly expectedState: PostCompactionUndoState; + readonly state: PostCompactionUndoState; + readonly lastEventSeq: number; + readonly updatedAt: string; +} + +export interface ListPostCompactionUndoRecordsOptions { + readonly workspaceId?: string; + readonly threadId?: string; + readonly targetTurnId?: string; + readonly idempotencyKey?: string; + readonly limit?: number; +} + export interface WorkflowWorkspaceChangesResponse { readonly cwd: string; readonly workflowId: string; diff --git a/packages/core/src/run-types.ts b/packages/core/src/run-types.ts index c21238a5e..95a29ced0 100644 --- a/packages/core/src/run-types.ts +++ b/packages/core/src/run-types.ts @@ -3,14 +3,18 @@ import type { ConversationItemV1, CompactionCheckpoint, CreateCompactionCheckpointInput, + CreatePostCompactionUndoRecordInput, CreateWorkspaceMutationRecordInput, LingThreadRelationKindV2, LingThreadSurfaceKindV2, ListCompactionCheckpointsOptions, + ListPostCompactionUndoRecordsOptions, ListWorkspaceMutationRecordsOptions, RunEventDataByType as ContractRunEventDataByType, RunEventEnvelope as ContractRunEventEnvelope, RunEventType as ContractRunEventType, + PostCompactionUndoRecord, + UpdatePostCompactionUndoRecordInput, WorkspaceMutationRecord, } from "@ling-stack/contracts"; import type { @@ -1529,6 +1533,10 @@ export interface RunStore { createCompactionCheckpoint(input: CreateCompactionCheckpointInput): CompactionCheckpoint; getCompactionCheckpoint(id: string): CompactionCheckpoint | undefined; listCompactionCheckpoints(options?: ListCompactionCheckpointsOptions): CompactionCheckpoint[]; + createPostCompactionUndoRecord(input: CreatePostCompactionUndoRecordInput): PostCompactionUndoRecord; + getPostCompactionUndoRecord(id: string): PostCompactionUndoRecord | undefined; + listPostCompactionUndoRecords(options?: ListPostCompactionUndoRecordsOptions): PostCompactionUndoRecord[]; + updatePostCompactionUndoRecord(input: UpdatePostCompactionUndoRecordInput): PostCompactionUndoRecord | undefined; createRunIntervention(input: CreateRunInterventionInput, now: string): RunIntervention; getRunIntervention(interventionId: string): RunIntervention | undefined; listRunInterventions(options?: ListRunInterventionsOptions): RunIntervention[]; diff --git a/packages/core/src/storage/sqlite-run-store.ts b/packages/core/src/storage/sqlite-run-store.ts index 14f36719f..61bde9670 100644 --- a/packages/core/src/storage/sqlite-run-store.ts +++ b/packages/core/src/storage/sqlite-run-store.ts @@ -320,6 +320,10 @@ import type { ListCompactionCheckpointsOptions, ListWorkspaceMutationRecordsOptions, WorkspaceMutationRecord, + PostCompactionUndoRecord, + UpdatePostCompactionUndoRecordInput, + CreatePostCompactionUndoRecordInput, + ListPostCompactionUndoRecordsOptions, } from "@ling-stack/contracts"; import { type AcquireRuntimeSemaphoreSlotInput, @@ -620,6 +624,7 @@ import { SqliteExternalWorkSchedulerStore } from "./sqlite/external-work-schedul import { SqliteExternalWorkWorkflowCoordinator } from "./sqlite/external-work-workflow-coordinator.js"; import { SqliteWorkItemStore } from "./sqlite/work-item-store.js"; import { SqliteWorkspaceProvenanceStore } from "./sqlite/workspace-provenance-store.js"; +import { SqlitePostCompactionUndoStore } from "./sqlite/post-compaction-undo-store.js"; export interface SqliteRunStoreOptions { readonly cwd: string; @@ -711,6 +716,7 @@ export class SqliteRunStore implements RunStore { private readonly externalWorkWorkflows: SqliteExternalWorkWorkflowCoordinator; private readonly workItems: SqliteWorkItemStore; private readonly workspaceProvenance: SqliteWorkspaceProvenanceStore; + private readonly postCompactionUndo: SqlitePostCompactionUndoStore; constructor(options: SqliteRunStoreOptions) { this.paths = options.paths ?? resolveRuntimePaths(options.cwd); @@ -720,6 +726,7 @@ export class SqliteRunStore implements RunStore { this.database = new DatabaseSync(this.paths.databasePath); this.conversationItems = new SqliteConversationItemStore({ database: this.database }); this.workspaceProvenance = new SqliteWorkspaceProvenanceStore({ database: this.database }); + this.postCompactionUndo = new SqlitePostCompactionUndoStore({ database: this.database }); this.conversationTurnIngest = new SqliteConversationTurnIngestStore( { database: this.database }, this.conversationItems, @@ -1721,6 +1728,22 @@ export class SqliteRunStore implements RunStore { return this.workspaceProvenance.listCompactionCheckpoints(options); } + createPostCompactionUndoRecord(input: CreatePostCompactionUndoRecordInput): PostCompactionUndoRecord { + return this.postCompactionUndo.create(input); + } + + getPostCompactionUndoRecord(id: string): PostCompactionUndoRecord | undefined { + return this.postCompactionUndo.get(id); + } + + listPostCompactionUndoRecords(options: ListPostCompactionUndoRecordsOptions = {}): PostCompactionUndoRecord[] { + return this.postCompactionUndo.list(options); + } + + updatePostCompactionUndoRecord(input: UpdatePostCompactionUndoRecordInput): PostCompactionUndoRecord | undefined { + return this.postCompactionUndo.update(input); + } + createRunIntervention(input: CreateRunInterventionInput, now: string): RunIntervention { return this.runInterventions.createRunIntervention(input, now); } diff --git a/packages/core/src/storage/sqlite/migrations.ts b/packages/core/src/storage/sqlite/migrations.ts index a0120fafa..3c9de20f0 100644 --- a/packages/core/src/storage/sqlite/migrations.ts +++ b/packages/core/src/storage/sqlite/migrations.ts @@ -7,6 +7,7 @@ import { applyRunContextViewLineageMigration, } from "./run-context-view-lineage-migration.js"; import { applyWorkspaceProvenanceMigration } from "./workspace-provenance-migration.js"; +import { applyPostCompactionUndoMigration } from "./post-compaction-undo-migration.js"; export interface SqliteRunStoreMigrationContext { readonly database: DatabaseSync; @@ -5658,4 +5659,10 @@ export function applySqliteRunStoreMigrations(context: SqliteRunStoreMigrationCo hasTable: context.hasTable.bind(context), recordMigration: context.recordMigration.bind(context), }); + applyPostCompactionUndoMigration({ + database, + hasMigration: context.hasMigration.bind(context), + hasTable: context.hasTable.bind(context), + recordMigration: context.recordMigration.bind(context), + }); } diff --git a/packages/core/src/storage/sqlite/post-compaction-undo-migration.ts b/packages/core/src/storage/sqlite/post-compaction-undo-migration.ts new file mode 100644 index 000000000..b8da51e73 --- /dev/null +++ b/packages/core/src/storage/sqlite/post-compaction-undo-migration.ts @@ -0,0 +1,51 @@ +import type { DatabaseSync } from "node:sqlite"; + +export const POST_COMPACTION_UNDO_MIGRATION_VERSION = 124; +export const POST_COMPACTION_UNDO_MIGRATION_NAME = "create_post_compaction_undo_records"; + +export interface PostCompactionUndoMigrationInput { + readonly database: DatabaseSync; + readonly hasMigration: (version: number) => boolean; + readonly hasTable: (tableName: string) => boolean; + readonly recordMigration: (version: number, name: string) => void; +} + +export function applyPostCompactionUndoMigration(input: PostCompactionUndoMigrationInput): boolean { + if (input.hasTable("post_compaction_undo_records") && input.hasMigration(POST_COMPACTION_UNDO_MIGRATION_VERSION)) { + return false; + } + input.database.exec(` + CREATE TABLE IF NOT EXISTS post_compaction_undo_records ( + id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL, + workspace_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + target_turn_id TEXT NOT NULL, + target_run_ids_json TEXT NOT NULL, + checkpoint_id TEXT, + expected_workspace_fingerprint TEXT NOT NULL, + action TEXT NOT NULL CHECK(action IN ('undo', 'reapply')), + state TEXT NOT NULL CHECK(state IN ( + 'available', 'validating', 'applied', 'undone', 'conflict', + 'provenance_missing', 'snapshot_unverified', 'expired', 'failed' + )), + affected_paths_json TEXT NOT NULL, + last_event_seq INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(thread_id) REFERENCES ling_threads(id) ON DELETE CASCADE, + FOREIGN KEY(target_turn_id) REFERENCES ling_turns(id) ON DELETE CASCADE, + FOREIGN KEY(checkpoint_id) REFERENCES compaction_checkpoints(id) ON DELETE SET NULL, + UNIQUE(workspace_id, idempotency_key) + ); + + CREATE INDEX IF NOT EXISTS idx_post_compaction_undo_turn_created + ON post_compaction_undo_records(workspace_id, target_turn_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_post_compaction_undo_thread_created + ON post_compaction_undo_records(workspace_id, thread_id, created_at DESC); + `); + if (!input.hasMigration(POST_COMPACTION_UNDO_MIGRATION_VERSION)) { + input.recordMigration(POST_COMPACTION_UNDO_MIGRATION_VERSION, POST_COMPACTION_UNDO_MIGRATION_NAME); + } + return true; +} diff --git a/packages/core/src/storage/sqlite/post-compaction-undo-store.ts b/packages/core/src/storage/sqlite/post-compaction-undo-store.ts new file mode 100644 index 000000000..840085e66 --- /dev/null +++ b/packages/core/src/storage/sqlite/post-compaction-undo-store.ts @@ -0,0 +1,240 @@ +import { randomUUID } from "node:crypto"; +import type { SQLOutputValue } from "node:sqlite"; +import { + postCompactionUndoStates, + type CreatePostCompactionUndoRecordInput, + type ListPostCompactionUndoRecordsOptions, + type PostCompactionUndoRecord, + type UpdatePostCompactionUndoRecordInput, +} from "@ling-stack/contracts"; +import { DEFAULT_WORKSPACE_ID } from "../../workspace-types.js"; +import { + parseJson, + readNumber, + readString, + stringifyJson, +} from "./helpers.js"; +import type { SqliteStoreContext } from "./store-context.js"; + +const canonicalTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u; + +export class SqlitePostCompactionUndoStore { + constructor(private readonly context: SqliteStoreContext) {} + + create(input: CreatePostCompactionUndoRecordInput): PostCompactionUndoRecord { + validateCreateInput(input); + const workspaceId = normalizeRequired(input.workspaceId ?? DEFAULT_WORKSPACE_ID, "post-compaction undo workspace id"); + const targetRunIds = uniqueRequiredStrings(input.targetRunIds, "post-compaction undo run ids"); + const affectedPaths = uniqueRequiredStrings(input.affectedPaths, "post-compaction undo paths"); + const existing = this.getByIdempotency(workspaceId, input.idempotencyKey); + if (existing) { + if (existing.threadId !== input.threadId || existing.targetTurnId !== input.targetTurnId || existing.action !== input.action) { + throw new Error(`Post-compaction undo idempotency key is bound to another target: ${input.idempotencyKey}`); + } + return existing; + } + const record: PostCompactionUndoRecord = { + id: input.id ?? `post_compaction_undo_${randomUUID()}`, + idempotencyKey: input.idempotencyKey, + workspaceId, + threadId: input.threadId, + targetTurnId: input.targetTurnId, + targetRunIds, + ...(input.checkpointId ? { checkpointId: input.checkpointId } : {}), + expectedWorkspaceFingerprint: input.expectedWorkspaceFingerprint, + action: input.action, + state: input.state ?? "validating", + affectedPaths, + lastEventSeq: input.lastEventSeq ?? 0, + createdAt: input.createdAt, + updatedAt: input.updatedAt ?? input.createdAt, + }; + this.context.database.exec("BEGIN IMMEDIATE"); + try { + assertReferences(this.context, record); + this.context.database.prepare(` + INSERT INTO post_compaction_undo_records ( + id, idempotency_key, workspace_id, thread_id, target_turn_id, + target_run_ids_json, checkpoint_id, expected_workspace_fingerprint, + action, state, affected_paths_json, last_event_seq, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + record.id, + record.idempotencyKey, + record.workspaceId, + record.threadId, + record.targetTurnId, + stringifyJson(record.targetRunIds), + record.checkpointId ?? null, + record.expectedWorkspaceFingerprint, + record.action, + record.state, + stringifyJson(record.affectedPaths), + record.lastEventSeq, + record.createdAt, + record.updatedAt, + ); + this.context.database.exec("COMMIT"); + return record; + } catch (error) { + this.context.database.exec("ROLLBACK"); + const replay = this.getByIdempotency(workspaceId, input.idempotencyKey); + if (replay) return replay; + throw error; + } + } + + get(id: string): PostCompactionUndoRecord | undefined { + const row = this.context.database.prepare(`${selectSql} WHERE id = ?`).get(id); + return row ? mapRecord(row as Record) : undefined; + } + + getByIdempotency(workspaceId: string, idempotencyKey: string): PostCompactionUndoRecord | undefined { + const row = this.context.database.prepare(`${selectSql} WHERE workspace_id = ? AND idempotency_key = ?`).get(workspaceId, idempotencyKey); + return row ? mapRecord(row as Record) : undefined; + } + + list(options: ListPostCompactionUndoRecordsOptions = {}): PostCompactionUndoRecord[] { + const clauses: string[] = []; + const params: (string | number)[] = []; + for (const [column, value] of [ + ["workspace_id", options.workspaceId], + ["thread_id", options.threadId], + ["target_turn_id", options.targetTurnId], + ["idempotency_key", options.idempotencyKey], + ] as const) { + if (value) { + clauses.push(`${column} = ?`); + params.push(value); + } + } + params.push(Math.min(Math.max(options.limit ?? 100, 1), 1_000)); + return this.context.database.prepare(` + ${selectSql} + ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY created_at DESC, id DESC + LIMIT ? + `).all(...params).map((row) => mapRecord(row as Record)); + } + + update(input: UpdatePostCompactionUndoRecordInput): PostCompactionUndoRecord | undefined { + validateUpdateInput(input); + const result = this.context.database.prepare(` + UPDATE post_compaction_undo_records + SET state = ?, last_event_seq = ?, updated_at = ? + WHERE id = ? AND state = ? + `).run(input.state, input.lastEventSeq, input.updatedAt, input.id, input.expectedState); + if (Number(result.changes) !== 1) return undefined; + return this.get(input.id); + } +} + +const selectSql = ` + SELECT id, idempotency_key, workspace_id, thread_id, target_turn_id, + target_run_ids_json, checkpoint_id, expected_workspace_fingerprint, + action, state, affected_paths_json, last_event_seq, created_at, updated_at + FROM post_compaction_undo_records +`; + +function mapRecord(row: Record): PostCompactionUndoRecord { + const targetRunIds = parseStringArray(readString(row, "target_run_ids_json"), "target_run_ids_json"); + const affectedPaths = parseStringArray(readString(row, "affected_paths_json"), "affected_paths_json"); + const checkpointId = row.checkpoint_id; + return { + id: readString(row, "id"), + idempotencyKey: readString(row, "idempotency_key"), + workspaceId: readString(row, "workspace_id"), + threadId: readString(row, "thread_id"), + targetTurnId: readString(row, "target_turn_id"), + targetRunIds, + ...(typeof checkpointId === "string" && checkpointId ? { checkpointId } : {}), + expectedWorkspaceFingerprint: readString(row, "expected_workspace_fingerprint"), + action: readString(row, "action") as PostCompactionUndoRecord["action"], + state: readString(row, "state") as PostCompactionUndoRecord["state"], + affectedPaths, + lastEventSeq: readNumber(row, "last_event_seq"), + createdAt: readString(row, "created_at"), + updatedAt: readString(row, "updated_at"), + }; +} + +function assertReferences(context: SqliteStoreContext, record: PostCompactionUndoRecord): void { + const turn = context.database.prepare("SELECT workspace_id, thread_id FROM ling_turns WHERE id = ?").get(record.targetTurnId) as Record | undefined; + if (!turn || readString(turn, "thread_id") !== record.threadId) throw new Error(`Post-compaction undo target Turn does not belong to thread: ${record.targetTurnId}`); + if (readString(turn, "workspace_id") !== record.workspaceId) throw new Error(`Post-compaction undo target Turn does not belong to workspace: ${record.targetTurnId}`); + const linkedRuns = context.database.prepare(` + SELECT COUNT(*) AS count + FROM ling_turn_runs + WHERE turn_id = ? AND run_id IN (${record.targetRunIds.map(() => "?").join(",")}) + `).get(record.targetTurnId, ...record.targetRunIds) as Record; + if (readNumber(linkedRuns, "count") !== record.targetRunIds.length) throw new Error("Post-compaction undo target Runs are not all linked to the target Turn."); + if (record.checkpointId) { + const checkpoint = context.database.prepare("SELECT workspace_id, thread_id, source_turn_id, covered_turn_ids_json FROM compaction_checkpoints WHERE id = ?").get(record.checkpointId) as Record | undefined; + const coversTargetTurn = checkpoint + ? readString(checkpoint, "source_turn_id") === record.targetTurnId + || parseStringArray(readString(checkpoint, "covered_turn_ids_json"), "covered_turn_ids_json").includes(record.targetTurnId) + : false; + if (!checkpoint || readString(checkpoint, "workspace_id") !== record.workspaceId || readString(checkpoint, "thread_id") !== record.threadId || !coversTargetTurn) { + throw new Error(`Post-compaction undo checkpoint does not match target Turn: ${record.checkpointId}`); + } + } +} + +function validateCreateInput(input: CreatePostCompactionUndoRecordInput): void { + normalizeRequired(input.idempotencyKey, "post-compaction undo idempotency key"); + normalizeRequired(input.threadId, "post-compaction undo thread id"); + normalizeRequired(input.targetTurnId, "post-compaction undo target Turn id"); + normalizeRequired(input.expectedWorkspaceFingerprint, "post-compaction undo workspace fingerprint"); + if (input.action !== "undo" && input.action !== "reapply") throw new Error(`Invalid post-compaction undo action: ${input.action}`); + if (input.state && !postCompactionUndoStates.includes(input.state)) throw new Error(`Invalid post-compaction undo state: ${input.state}`); + uniqueRequiredStrings(input.targetRunIds, "post-compaction undo run ids"); + uniqueRequiredStrings(input.affectedPaths, "post-compaction undo paths"); + if (input.lastEventSeq !== undefined && (!Number.isSafeInteger(input.lastEventSeq) || input.lastEventSeq < 0)) throw new RangeError("Invalid post-compaction undo event sequence."); + assertTimestamp(input.createdAt); + if (input.updatedAt) assertTimestamp(input.updatedAt); +} + +function validateUpdateInput(input: UpdatePostCompactionUndoRecordInput): void { + normalizeRequired(input.id, "post-compaction undo record id"); + if (!postCompactionUndoStates.includes(input.expectedState) || !postCompactionUndoStates.includes(input.state)) throw new Error("Invalid post-compaction undo state transition."); + if (!allowedPostCompactionUndoTransitions[input.expectedState].includes(input.state)) { + throw new Error(`Illegal post-compaction undo transition: ${input.expectedState} -> ${input.state}`); + } + if (!Number.isSafeInteger(input.lastEventSeq) || input.lastEventSeq < 0) throw new RangeError("Invalid post-compaction undo event sequence."); + assertTimestamp(input.updatedAt); +} + +const allowedPostCompactionUndoTransitions: Readonly> = { + available: ["validating", "conflict", "provenance_missing", "snapshot_unverified", "expired", "failed"], + validating: ["validating", "applied", "undone", "conflict", "provenance_missing", "snapshot_unverified", "expired", "failed"], + applied: [], + undone: [], + conflict: [], + provenance_missing: [], + snapshot_unverified: [], + expired: [], + failed: [], +}; + +function parseStringArray(value: string, key: string): string[] { + const parsed = parseJson(value); + if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string" || item.trim().length === 0)) throw new Error(`Invalid string list in ${key}`); + return parsed; +} + +function uniqueRequiredStrings(values: readonly string[], label: string): string[] { + if (values.length === 0) throw new Error(`${label} must not be empty.`); + const result = [...new Set(values.map((value) => normalizeRequired(value, label)))]; + if (result.length !== values.length) throw new Error(`${label} must not contain duplicates.`); + return result; +} + +function normalizeRequired(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} must not be empty.`); + return normalized; +} + +function assertTimestamp(value: string): void { + if (!canonicalTimestampPattern.test(value) || Number.isNaN(Date.parse(value))) throw new Error(`Timestamp must be canonical UTC: ${value}`); +} diff --git a/packages/core/test/workspace-provenance.test.mjs b/packages/core/test/workspace-provenance.test.mjs index 5daa45e66..103cdea11 100644 --- a/packages/core/test/workspace-provenance.test.mjs +++ b/packages/core/test/workspace-provenance.test.mjs @@ -103,12 +103,59 @@ test("workspace mutation provenance and compaction checkpoints survive SQLite re compactionPolicyVersion: "continuity-v2-summary-v1", createdAt: now, }), /summary does not belong to thread/u); + const recovery = store.createPostCompactionUndoRecord({ + idempotencyKey: "undo-provenance-record", + workspaceId: "workspace_default", + threadId: thread.id, + targetTurnId: turnResult.turnId, + targetRunIds: [run.id], + checkpointId: checkpoint.id, + expectedWorkspaceFingerprint: "fingerprint-before-undo", + action: "undo", + affectedPaths: ["file.txt"], + createdAt: now, + }); + assert.equal(recovery.state, "validating"); + assert.equal(store.createPostCompactionUndoRecord({ + idempotencyKey: "undo-provenance-record", + threadId: thread.id, + targetTurnId: turnResult.turnId, + targetRunIds: [run.id], + checkpointId: checkpoint.id, + expectedWorkspaceFingerprint: "ignored-on-replay", + action: "undo", + affectedPaths: ["file.txt"], + createdAt: now, + }).id, recovery.id); + assert.equal(store.updatePostCompactionUndoRecord({ + id: recovery.id, + expectedState: "validating", + state: "undone", + lastEventSeq: 9, + updatedAt: "2026-08-25T01:00:01.000Z", + })?.state, "undone"); + assert.equal(store.updatePostCompactionUndoRecord({ + id: recovery.id, + expectedState: "validating", + state: "applied", + lastEventSeq: 10, + updatedAt: "2026-08-25T01:00:02.000Z", + }), undefined); + assert.throws(() => store.updatePostCompactionUndoRecord({ + id: recovery.id, + expectedState: "undone", + state: "validating", + lastEventSeq: 11, + updatedAt: "2026-08-25T01:00:03.000Z", + }), /Illegal post-compaction undo transition/u); store.close(); const reopened = createSqliteRunStore(cwd); try { assert.equal(reopened.listWorkspaceMutationRecords({ turnId: turnResult.turnId })[0].runId, run.id); assert.equal(reopened.listCompactionCheckpoints({ threadId: thread.id })[0].id, checkpoint.id); assert.equal(reopened.getLatestWorkspaceProvenanceWatermark(), "workspace_default:1"); + assert.equal(reopened.getPostCompactionUndoRecord(recovery.id)?.state, "undone"); + assert.equal(reopened.listPostCompactionUndoRecords({ idempotencyKey: "undo-provenance-record" }).length, 1); } finally { reopened.close(); } diff --git a/packages/daemon/src/features/runs/workspace-change-recovery.ts b/packages/daemon/src/features/runs/workspace-change-recovery.ts index 630377116..125ca4e69 100644 --- a/packages/daemon/src/features/runs/workspace-change-recovery.ts +++ b/packages/daemon/src/features/runs/workspace-change-recovery.ts @@ -203,35 +203,138 @@ export async function recoverTurnWorkspaceChanges( if (!context.provenanceComplete) throw new RunWorkspaceRecoveryError(409, "provenance_missing", "Undo is unavailable: durable workspace provenance is incomplete."); if (context.steps.length === 0) throw new RunWorkspaceRecoveryError(409, "workspace_changes_unavailable", "This Turn has no successfully applied reversible workspace mutations."); const ownerRun = context.runs.at(-1)!; + const durableRecord = store.listPostCompactionUndoRecords({ + workspaceId: turn.workspaceId, + targetTurnId: turn.id, + idempotencyKey, + limit: 1, + })[0]; + if (durableRecord && durableRecord.action !== action) { + throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", "This idempotency key is already bound to the opposite recovery action."); + } + if (durableRecord && (durableRecord.state === "conflict" || durableRecord.state === "failed" || durableRecord.state === "expired" || durableRecord.state === "provenance_missing")) { + throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", `The durable recovery record is ${durableRecord.state}; use a new idempotency key after review.`); + } + if (durableRecord && ((action === "undo" && durableRecord.state === "undone") || (action === "reapply" && durableRecord.state === "applied"))) { + return buildTurnWorkspaceChangesResponse(store, turn); + } const events = turnRecoveryEvents(store, ownerRun.id, turn.id); const prior = events.find((event) => isRecord(event.data) && event.data.idempotencyKey === idempotencyKey && event.type === (action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied")); - if (prior) return buildTurnWorkspaceChangesResponse(store, turn); + if (prior) { + if (durableRecord?.state === "validating") { + store.updatePostCompactionUndoRecord({ + id: durableRecord.id, + expectedState: "validating", + state: action === "undo" ? "undone" : "applied", + lastEventSeq: prior.seq, + updatedAt: prior.ts, + }); + } + return buildTurnWorkspaceChangesResponse(store, turn); + } const patchSet = syntheticPatchSet(context.steps.map((step) => step.plan)); const observation = observeRecoveryState(cwd, patchSet, events); - if (observation.conflict) throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", observation.conflict); + if (observation.conflict) { + if (durableRecord?.state === "validating") { + store.updatePostCompactionUndoRecord({ + id: durableRecord.id, + expectedState: "validating", + state: "conflict", + lastEventSeq: events.at(-1)?.seq ?? durableRecord.lastEventSeq, + updatedAt: new Date().toISOString(), + }); + } + throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", observation.conflict); + } if (observation.completedIntent) { if (observation.completedIntent.action === action && observation.completedIntent.idempotencyKey === idempotencyKey) { - store.appendEvent(ownerRun.id, action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied", { - scope: "turn", - turnId: turn.id, - runIds: context.runs.map((run) => run.id), - recoveryId: observation.completedIntent.recoveryId, + // A crash can occur after the recovery intent is persisted and the + // workspace reaches its target state, but before the durable request + // record is created (or before the terminal event is written). Rebuild + // the record from the intent so the idempotency key remains durable + // across daemon restarts instead of treating this as an unindexed + // side-effect. + const recoveredRecord = durableRecord ?? store.createPostCompactionUndoRecord({ idempotencyKey, - cwd, - approvalIds: context.steps.flatMap((step) => step.approvals), - rawPatchSha256: context.steps.map((step) => step.plan.rawPatchSha256), - touchedPaths: patchSet.files.map((file) => file.path), - summary: patchSet.summary, + workspaceId: turn.workspaceId, + threadId: turn.threadId, + targetTurnId: turn.id, + targetRunIds: context.runs.map((run) => run.id), + checkpointId: findTurnCheckpoint(store, turn)?.id, + expectedWorkspaceFingerprint: recoveryIntentFingerprint(observation.completedIntent, "before"), + action, + state: "validating", + affectedPaths: observation.completedIntent.files.map((file) => file.path), + createdAt: observation.updatedAt ?? new Date().toISOString(), }); + if (recoveredRecord.state === "validating") { + const terminalEvent = store.appendEvent(ownerRun.id, action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied", { + scope: "turn", + turnId: turn.id, + runIds: context.runs.map((run) => run.id), + recoveryId: observation.completedIntent.recoveryId, + idempotencyKey, + cwd, + approvalIds: context.steps.flatMap((step) => step.approvals), + rawPatchSha256: context.steps.map((step) => step.plan.rawPatchSha256), + touchedPaths: patchSet.files.map((file) => file.path), + summary: patchSet.summary, + }); + store.updatePostCompactionUndoRecord({ + id: recoveredRecord.id, + expectedState: "validating", + state: action === "undo" ? "undone" : "applied", + lastEventSeq: terminalEvent.seq, + updatedAt: terminalEvent.ts, + }); + } return buildTurnWorkspaceChangesResponse(store, turn); } } if ((action === "undo" && observation.state === "undone") || (action === "reapply" && observation.state !== "undone")) { throw new RunWorkspaceRecoveryError(409, "workspace_change_invalid_state", action === "undo" ? "This Turn's patch is already undone." : "This Turn's patch must be undone before reapply."); } - const preflight = await preflightRecoveryPlanSequence(cwd, turnRecoveryPlanSteps(context.steps, action)); + let preflight: RecoveryPreflight; + try { + preflight = await preflightRecoveryPlanSequence(cwd, turnRecoveryPlanSteps(context.steps, action)); + } catch (error) { + if (durableRecord?.state === "validating") { + store.updatePostCompactionUndoRecord({ + id: durableRecord.id, + expectedState: "validating", + state: "conflict", + lastEventSeq: durableRecord.lastEventSeq, + updatedAt: new Date().toISOString(), + }); + } + throw error; + } + const expectedWorkspaceFingerprint = workspaceFingerprint(preflight.before); + if (durableRecord && durableRecord.state === "validating" && durableRecord.expectedWorkspaceFingerprint !== expectedWorkspaceFingerprint) { + store.updatePostCompactionUndoRecord({ + id: durableRecord.id, + expectedState: "validating", + state: "conflict", + lastEventSeq: durableRecord.lastEventSeq, + updatedAt: new Date().toISOString(), + }); + throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", "Workspace changed after the recovery request was validated; retry with a new idempotency key."); + } + const recoveryRecord = durableRecord ?? store.createPostCompactionUndoRecord({ + idempotencyKey, + workspaceId: turn.workspaceId, + threadId: turn.threadId, + targetTurnId: turn.id, + targetRunIds: context.runs.map((run) => run.id), + checkpointId: findTurnCheckpoint(store, turn)?.id, + expectedWorkspaceFingerprint, + action, + state: "validating", + affectedPaths: preflight.paths, + createdAt: new Date().toISOString(), + }); const recoveryId = randomUUID(); - store.appendEvent(ownerRun.id, "workspace_change_recovery_started", { + const startedEvent = store.appendEvent(ownerRun.id, "workspace_change_recovery_started", { scope: "turn", turnId: turn.id, runIds: context.runs.map((run) => run.id), @@ -241,8 +344,15 @@ export async function recoverTurnWorkspaceChanges( cwd, files: recoveryIntentFiles(preflight), }); + store.updatePostCompactionUndoRecord({ + id: recoveryRecord.id, + expectedState: "validating", + state: "validating", + lastEventSeq: startedEvent.seq, + updatedAt: startedEvent.ts, + }); commitRecovery(cwd, preflight); - store.appendEvent(ownerRun.id, action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied", { + const terminalEvent = store.appendEvent(ownerRun.id, action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied", { scope: "turn", turnId: turn.id, runIds: context.runs.map((run) => run.id), @@ -254,6 +364,13 @@ export async function recoverTurnWorkspaceChanges( touchedPaths: preflight.paths, summary: summarizePatchPlans(context.steps.map((step) => step.plan)), }); + store.updatePostCompactionUndoRecord({ + id: recoveryRecord.id, + expectedState: "validating", + state: action === "undo" ? "undone" : "applied", + lastEventSeq: terminalEvent.seq, + updatedAt: terminalEvent.ts, + }); return buildTurnWorkspaceChangesResponse(store, turn); }); } @@ -291,8 +408,11 @@ export async function buildTurnWorkspaceChangesResponse( } const events = ownerRun ? turnRecoveryEvents(store, ownerRun.id, turn.id) : []; const observation = ownerRun && plans.length > 0 ? observeRecoveryState(workspaceCwd, syntheticPatchSet(plans), events) : { state: "applied" as const }; - const checkpoint = store.listCompactionCheckpoints({ threadId: turn.threadId, limit: 100 }) - .find((candidate) => candidate.sourceTurnId === turn.id || candidate.coveredTurnIds.includes(turn.id)); + const durableRecovery = store.listPostCompactionUndoRecords({ workspaceId: turn.workspaceId, targetTurnId: turn.id, limit: 100 })[0]; + const checkpoint = findTurnCheckpoint(store, turn); + const durableState = durableRecovery?.state === "undone" ? "undone" + : durableRecovery?.state === "applied" ? "applied" + : observation.state; const base = { cwd: workspaceCwd, threadId: turn.threadId, @@ -302,15 +422,23 @@ export async function buildTurnWorkspaceChangesResponse( summary: summarizePatchPlans(plans), ...(checkpoint ? { checkpointId: checkpoint.id } : {}), provenanceComplete: context.provenanceComplete, - updatedAt: observation.updatedAt ?? turn.updatedAt, + updatedAt: observation.updatedAt ?? durableRecovery?.updatedAt ?? turn.updatedAt, } as const; if (runs.length === 0) return { ...base, state: "unavailable", canUndo: false, canReapply: false, provenanceComplete: false, unavailableReason: "No Runs are linked to this Turn." }; if (!context.provenanceComplete) return { ...base, state: "unavailable", canUndo: false, canReapply: false, unavailableReason: "Undo unavailable: durable workspace provenance is incomplete." }; if (plans.length === 0) return { ...base, state: "unavailable", canUndo: false, canReapply: false, unavailableReason: "No successfully applied reversible workspace mutations were found." }; - if (observation.conflict) return { ...base, state: "conflicted", canUndo: false, canReapply: false, unavailableReason: observation.conflict }; + if (observation.conflict || durableRecovery?.state === "conflict" || durableRecovery?.state === "failed") { + return { + ...base, + state: "conflicted", + canUndo: false, + canReapply: false, + unavailableReason: observation.conflict ?? `Recovery is ${durableRecovery?.state}; review the workspace before retrying.`, + }; + } try { - await preflightRecoveryPlanSequence(workspaceCwd, turnRecoveryPlanSteps(context.steps, observation.state === "undone" ? "reapply" : "undo")); - return { ...base, state: observation.state, canUndo: observation.state === "applied", canReapply: observation.state === "undone" }; + await preflightRecoveryPlanSequence(workspaceCwd, turnRecoveryPlanSteps(context.steps, durableState === "undone" ? "reapply" : "undo")); + return { ...base, state: durableState, canUndo: durableState === "applied", canReapply: durableState === "undone" }; } catch (error) { return { ...base, state: "conflicted", canUndo: false, canReapply: false, unavailableReason: error instanceof Error ? error.message : String(error) }; } @@ -418,6 +546,11 @@ function resolveTurnWorkspaceCwd(runs: readonly RunSession[], fallbackCwd: strin return canonicalCwd; } +function findTurnCheckpoint(store: RunStore, turn: import("@ling-stack/core").LingTurn) { + return store.listCompactionCheckpoints({ threadId: turn.threadId, sourceTurnId: turn.id, limit: 1 })[0] + ?? store.listCompactionCheckpoints({ threadId: turn.threadId, limit: 100 }).find((candidate) => candidate.coveredTurnIds.includes(turn.id)); +} + function turnRecoveryPlanSteps(steps: readonly TurnRecoveryStep[], action: RecoveryAction): RecoveryPlanStep[] { const ordered = action === "undo" ? [...steps].reverse() : [...steps]; return ordered.map((step) => ({ @@ -812,6 +945,29 @@ export function hashRecoveryContent(content: string | undefined): string { return hash.digest("hex"); } +/** Stable fingerprint for the exact path set used by a recovery CAS decision. */ +export function workspaceFingerprint(contents: ReadonlyMap): string { + const hash = createHash("sha256"); + for (const path of [...contents.keys()].sort()) { + hash.update(path, "utf8"); + hash.update("\0", "utf8"); + hash.update(hashRecoveryContent(contents.get(path)), "utf8"); + hash.update("\0", "utf8"); + } + return hash.digest("hex"); +} + +function recoveryIntentFingerprint(intent: RecoveryIntent, side: "before" | "after"): string { + const hash = createHash("sha256"); + for (const file of [...intent.files].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)) { + hash.update(file.path, "utf8"); + hash.update("\0", "utf8"); + hash.update(side === "before" ? file.beforeSha256 : file.afterSha256, "utf8"); + hash.update("\0", "utf8"); + } + return hash.digest("hex"); +} + export function summarizePatchFiles(plans: readonly PatchPlan[]): readonly RunWorkspaceChangeFile[] { const files = new Map(); for (const operation of plans.flatMap((plan) => plan.operations)) { diff --git a/packages/daemon/test/post-compaction-undo.test.mjs b/packages/daemon/test/post-compaction-undo.test.mjs index a0d4ce3fe..3e69f746a 100644 --- a/packages/daemon/test/post-compaction-undo.test.mjs +++ b/packages/daemon/test/post-compaction-undo.test.mjs @@ -96,10 +96,12 @@ test("post-compaction Turn undo resolves durable mutation provenance across reop await writeFile(path, "old\n", "utf8"); const recoveredTerminal = await recoverTurnWorkspaceChanges(store, store.getLingTurn(turnResult.turnId), "undo", "undo-post-compaction-crash"); assert.equal(recoveredTerminal.state, "undone"); + assert.equal(store.listPostCompactionUndoRecords({ targetTurnId: turnResult.turnId, idempotencyKey: "undo-post-compaction-crash" })[0]?.state, "undone"); const replay = await recoverTurnWorkspaceChanges(store, store.getLingTurn(turnResult.turnId), "undo", "undo-post-compaction-crash"); assert.equal(replay.state, "undone"); const reapplied = await recoverTurnWorkspaceChanges(store, store.getLingTurn(turnResult.turnId), "reapply", "reapply-post-compaction-1"); assert.equal(reapplied.state, "applied"); + assert.equal(store.listPostCompactionUndoRecords({ targetTurnId: turnResult.turnId, idempotencyKey: "reapply-post-compaction-1" })[0]?.state, "applied"); assert.equal(await readFile(path, "utf8"), "new\n"); await writeFile(path, "user edit\n", "utf8"); await assert.rejects( From e0092f7e03aaac52182e9d4320e73183bb0f4e66 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Wed, 26 Aug 2026 03:15:46 +0800 Subject: [PATCH 7/8] feat: expose post-compaction turn recovery --- apps/web/src/api/workspace-changes.ts | 13 + apps/web/src/lib/conversation-work-cell.ts | 4 +- .../ConversationArtifactRecoveryDialog.tsx | 34 ++- .../blocks/ConversationArtifactStrip.tsx | 7 +- docs/design/post-compaction-undo.md | 5 +- .../src/work-cell-view-model.ts | 3 +- .../sqlite/post-compaction-undo-store.ts | 2 +- .../sqlite/workspace-provenance-store.ts | 47 +++- .../core/test/workspace-provenance.test.mjs | 16 +- .../conversation-summary-coordinator.ts | 52 ++-- .../runs/workspace-change-recovery.ts | 121 +++++++-- .../conversation-summary-coordinator.test.mjs | 30 +++ .../daemon/test/post-compaction-undo.test.mjs | 248 ++++++++++++++++++ 13 files changed, 526 insertions(+), 56 deletions(-) diff --git a/apps/web/src/api/workspace-changes.ts b/apps/web/src/api/workspace-changes.ts index fc974e70d..3a0b4e746 100644 --- a/apps/web/src/api/workspace-changes.ts +++ b/apps/web/src/api/workspace-changes.ts @@ -1,5 +1,6 @@ import type { RunWorkspaceChangesResponse, + TurnWorkspaceChangesResponse, WorkflowWorkspaceChangesResponse, WorkspaceChangesResponse, WorkspaceFileOpenOptionsResponse, @@ -26,6 +27,18 @@ export function reapplyRunWorkspaceChanges(cwd: string, runId: string): Promise< return daemonClient.reapplyRunWorkspaceChanges(runId, cwd); } +export function getTurnWorkspaceChanges(cwd: string, turnId: string): Promise { + return daemonClient.getTurnWorkspaceChanges(turnId, cwd); +} + +export function undoTurnWorkspaceChanges(cwd: string, turnId: string, idempotencyKey: string): Promise { + return daemonClient.undoTurnWorkspaceChanges(turnId, idempotencyKey, cwd); +} + +export function reapplyTurnWorkspaceChanges(cwd: string, turnId: string, idempotencyKey: string): Promise { + return daemonClient.reapplyTurnWorkspaceChanges(turnId, idempotencyKey, cwd); +} + export function getWorkflowWorkspaceChanges(cwd: string, workflowId: string): Promise { const demo = channelWorkCellDemoWorkflowRecovery({ cwd, workflowId }); if (demo) { diff --git a/apps/web/src/lib/conversation-work-cell.ts b/apps/web/src/lib/conversation-work-cell.ts index db7bd4e44..c1f8465e4 100644 --- a/apps/web/src/lib/conversation-work-cell.ts +++ b/apps/web/src/lib/conversation-work-cell.ts @@ -497,7 +497,9 @@ export function buildConversationWorkCellViewModel(input: BuildConversationWorkC const artifactStripItems = mergeConversationArtifactStripItems( baseArtifactStripItems, (terminalReport?.artifactStripItems ?? []).filter((item) => item.kind !== "changed" || !hasBaseChangedItem), - ); + ).map((item) => turnContext && item.kind === "changed" + ? { ...item, recoveryTarget: { kind: "turn" as const, id: turnContext.turn.id } } + : item); const representedArtifactIds = new Set(artifactStripItems.flatMap((item) => [ item.artifactId, ...(item.changeArtifacts ?? []).map((artifact) => artifact.artifactId), diff --git a/apps/web/src/shared/conversation/blocks/ConversationArtifactRecoveryDialog.tsx b/apps/web/src/shared/conversation/blocks/ConversationArtifactRecoveryDialog.tsx index d20a0b6fd..2f9ad9efb 100644 --- a/apps/web/src/shared/conversation/blocks/ConversationArtifactRecoveryDialog.tsx +++ b/apps/web/src/shared/conversation/blocks/ConversationArtifactRecoveryDialog.tsx @@ -1,14 +1,16 @@ import { Redo2, RefreshCw, Undo2 } from "lucide-react"; -import { useRef, useState, type RefObject } from "react"; +import { useEffect, useRef, useState, type RefObject } from "react"; import { + reapplyTurnWorkspaceChanges, reapplyRunWorkspaceChanges, reapplyWorkflowWorkspaceChanges, + undoTurnWorkspaceChanges, undoRunWorkspaceChanges, undoWorkflowWorkspaceChanges, } from "../../../api.js"; import { useI18n } from "../../../i18n.js"; import type { ConversationWorkspaceRecoveryTarget } from "../../../lib/conversation-work-cell.js"; -import type { RunWorkspaceChangesResponse, WorkflowWorkspaceChangesResponse } from "../../../types.js"; +import type { RunWorkspaceChangesResponse, TurnWorkspaceChangesResponse, WorkflowWorkspaceChangesResponse } from "../../../types.js"; import { Button, Dialog, @@ -20,7 +22,7 @@ import { } from "../../ui/index.js"; export type ConversationArtifactRecoveryAction = "undo" | "reapply"; -export type ConversationArtifactRecoveryResponse = RunWorkspaceChangesResponse | WorkflowWorkspaceChangesResponse; +export type ConversationArtifactRecoveryResponse = RunWorkspaceChangesResponse | TurnWorkspaceChangesResponse | WorkflowWorkspaceChangesResponse; export function ConversationArtifactRecoveryDialog({ action, @@ -45,7 +47,13 @@ export function ConversationArtifactRecoveryDialog({ const cancelRef = useRef(null); const inFlightRef = useRef(false); const lastActionRef = useRef("undo"); + const idempotencyKeyRef = useRef(undefined); if (action) lastActionRef.current = action; + useEffect(() => { + idempotencyKeyRef.current = action && target?.kind === "turn" + ? createRecoveryIdempotencyKey() + : undefined; + }, [action, target?.id, target?.kind]); const visibleAction = action ?? lastActionRef.current; const title = visibleAction === "undo" ? t("Undo changes to {count} files?", { count: fileCount }) @@ -54,6 +62,7 @@ export function ConversationArtifactRecoveryDialog({ function close(): void { if (inFlightRef.current) return; setError(""); + idempotencyKeyRef.current = undefined; onClose(); } @@ -63,7 +72,11 @@ export function ConversationArtifactRecoveryDialog({ inFlightRef.current = true; setBusy(true); try { - const response = await mutateWorkspaceRecovery(cwd, target, requestedAction); + const idempotencyKey = target.kind === "turn" + ? idempotencyKeyRef.current ?? createRecoveryIdempotencyKey() + : undefined; + idempotencyKeyRef.current = idempotencyKey; + const response = await mutateWorkspaceRecovery(cwd, target, requestedAction, idempotencyKey); setError(""); onRecovered(response); onClose(); @@ -137,16 +150,29 @@ export function ConversationArtifactRecoveryDialog({ ); } +function createRecoveryIdempotencyKey(): string { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; +} + function mutateWorkspaceRecovery( cwd: string, target: ConversationWorkspaceRecoveryTarget, action: ConversationArtifactRecoveryAction, + idempotencyKey?: string, ): Promise { if (target.kind === "workflow") { return action === "undo" ? undoWorkflowWorkspaceChanges(cwd, target.id) : reapplyWorkflowWorkspaceChanges(cwd, target.id); } + if (target.kind === "turn") { + if (!idempotencyKey) { + return Promise.reject(new Error("A recovery idempotency key is required.")); + } + return action === "undo" + ? undoTurnWorkspaceChanges(cwd, target.id, idempotencyKey) + : reapplyTurnWorkspaceChanges(cwd, target.id, idempotencyKey); + } return action === "undo" ? undoRunWorkspaceChanges(cwd, target.id) : reapplyRunWorkspaceChanges(cwd, target.id); diff --git a/apps/web/src/shared/conversation/blocks/ConversationArtifactStrip.tsx b/apps/web/src/shared/conversation/blocks/ConversationArtifactStrip.tsx index 8b49f112e..6430ac50d 100644 --- a/apps/web/src/shared/conversation/blocks/ConversationArtifactStrip.tsx +++ b/apps/web/src/shared/conversation/blocks/ConversationArtifactStrip.tsx @@ -2,13 +2,14 @@ import { useEffect, useRef, useState, type CSSProperties, type ReactNode, type R import { createPortal } from "react-dom"; import { ChevronDown, Code2, FilePenLine, FileText, Image as ImageIcon, Loader2, Redo2, Undo2 } from "lucide-react"; import { + getTurnWorkspaceChanges, getRunWorkspaceChanges, getWorkflowWorkspaceChanges, } from "../../../api.js"; import { useI18n, type Translate } from "../../../i18n.js"; import { parseUnifiedDiff, summarizeFileChanges, type ConversationFileChange } from "../../../lib/diff-summary.js"; import type { ConversationArtifactStripChangeArtifact, ConversationArtifactStripItem, ConversationWorkspaceRecoveryTarget } from "../../../lib/conversation-work-cell.js"; -import type { PreviewContentResponse, RunWorkspaceChangesResponse, WorkflowWorkspaceChangesResponse } from "../../../types.js"; +import type { PreviewContentResponse, RunWorkspaceChangesResponse, TurnWorkspaceChangesResponse, WorkflowWorkspaceChangesResponse } from "../../../types.js"; import type { ArtifactPreviewLoader } from "../preview/ArtifactPreviewDrawer.js"; import { ConversationArtifactRecoveryDialog, @@ -20,7 +21,7 @@ import { WorkspaceFileOpenMenu } from "./WorkspaceFileOpenMenu.js"; const artifactHoverDelayMs = 2_000; const artifactHoverHideDelayMs = 300; const collapsedChangeFileCount = 3; -type WorkspaceRecoveryResponse = RunWorkspaceChangesResponse | WorkflowWorkspaceChangesResponse; +type WorkspaceRecoveryResponse = RunWorkspaceChangesResponse | TurnWorkspaceChangesResponse | WorkflowWorkspaceChangesResponse; export interface ConversationWorkspaceReviewTarget { readonly cwd: string; @@ -336,6 +337,8 @@ function getWorkspaceRecovery( ): Promise { return target.kind === "workflow" ? getWorkflowWorkspaceChanges(cwd, target.id) + : target.kind === "turn" + ? getTurnWorkspaceChanges(cwd, target.id) : getRunWorkspaceChanges(cwd, target.id); } diff --git a/docs/design/post-compaction-undo.md b/docs/design/post-compaction-undo.md index 584688a26..9174bcbba 100644 --- a/docs/design/post-compaction-undo.md +++ b/docs/design/post-compaction-undo.md @@ -211,6 +211,7 @@ type PostCompactionUndoState = | "available" | "validating" | "applied" + | "undone" | "conflict" | "provenance_missing" | "snapshot_unverified" @@ -357,14 +358,14 @@ timeline. ```text available | - +--> validating --+--> applied (undo committed) + +--> validating --+--> undone (undo committed) | +--> conflict (CAS/policy/lock mismatch) | +--> provenance_missing | +--> snapshot_unverified | +--> expired | +--> failed (apply or verification failure) | - +--> validating --+--> undone (reapply committed) + +--> validating --+--> applied (reapply committed) ``` Each transition carries: diff --git a/packages/conversation-ui-core/src/work-cell-view-model.ts b/packages/conversation-ui-core/src/work-cell-view-model.ts index 894ed6172..79927e147 100644 --- a/packages/conversation-ui-core/src/work-cell-view-model.ts +++ b/packages/conversation-ui-core/src/work-cell-view-model.ts @@ -157,7 +157,8 @@ export interface ConversationArtifactStripChangeArtifact { export type ConversationWorkspaceRecoveryTarget = | { readonly kind: "run"; readonly id: string } - | { readonly kind: "workflow"; readonly id: string }; + | { readonly kind: "workflow"; readonly id: string } + | { readonly kind: "turn"; readonly id: string }; export interface ConversationArtifactStripItem { readonly id: string; diff --git a/packages/core/src/storage/sqlite/post-compaction-undo-store.ts b/packages/core/src/storage/sqlite/post-compaction-undo-store.ts index 840085e66..091c9112f 100644 --- a/packages/core/src/storage/sqlite/post-compaction-undo-store.ts +++ b/packages/core/src/storage/sqlite/post-compaction-undo-store.ts @@ -43,7 +43,7 @@ export class SqlitePostCompactionUndoStore { ...(input.checkpointId ? { checkpointId: input.checkpointId } : {}), expectedWorkspaceFingerprint: input.expectedWorkspaceFingerprint, action: input.action, - state: input.state ?? "validating", + state: input.state ?? "available", affectedPaths, lastEventSeq: input.lastEventSeq ?? 0, createdAt: input.createdAt, diff --git a/packages/core/src/storage/sqlite/workspace-provenance-store.ts b/packages/core/src/storage/sqlite/workspace-provenance-store.ts index 4e6d771c7..17033c5f3 100644 --- a/packages/core/src/storage/sqlite/workspace-provenance-store.ts +++ b/packages/core/src/storage/sqlite/workspace-provenance-store.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { resolve as resolvePath } from "node:path"; import type { SQLOutputValue } from "node:sqlite"; import { isConversationDomainRef, @@ -36,12 +37,12 @@ export class SqliteWorkspaceProvenanceStore { createWorkspaceMutationRecords(inputs: readonly CreateWorkspaceMutationRecordInput[]): WorkspaceMutationRecord[] { if (inputs.length === 0) return []; inputs.forEach(validateMutationInput); - const workspaceIds = new Set(inputs.map((input) => normalizeRequired(input.workspaceId ?? DEFAULT_WORKSPACE_ID, "workspace mutation workspace id"))); + const workspaceIds = new Set(inputs.map((input) => inferWorkspaceId(this.context, input))); if (workspaceIds.size !== 1) throw new Error("Workspace mutation batch must use one workspace."); const workspaceId = [...workspaceIds][0]!; this.context.database.exec("BEGIN IMMEDIATE"); try { - inputs.forEach((input) => assertMutationReferences(this.context, input)); + inputs.forEach((input) => assertMutationReferences(this.context, input, workspaceId)); const records: WorkspaceMutationRecord[] = []; const nextSequence = this.context.database.prepare(` INSERT INTO workspace_provenance_sequences (workspace_id, last_seq) @@ -336,19 +337,55 @@ function validateCheckpointInput(input: CreateCompactionCheckpointInput): void { } if (input.coveredTurnIds.length === 0) throw new Error("Compaction checkpoint must cover at least one Turn."); if (input.sourceRefs.some((ref) => !isConversationDomainRef(ref))) throw new Error("Compaction checkpoint contains an invalid source ref."); + if (input.summaryContentSha256 && !/^[0-9a-f]{64}$/u.test(input.summaryContentSha256)) { + throw new Error("Compaction checkpoint summary content hash must be a lowercase SHA-256 digest."); + } if (input.contextViewCreatedAt) assertCanonicalTimestamp(input.contextViewCreatedAt); assertCanonicalTimestamp(input.createdAt); } -function assertMutationReferences(context: SqliteStoreContext, input: CreateWorkspaceMutationRecordInput): void { - const run = context.database.prepare("SELECT thread_id FROM run_sessions WHERE id = ?").get(input.runId) as Record | undefined; +function inferWorkspaceId(context: SqliteStoreContext, input: CreateWorkspaceMutationRecordInput): string { + if (input.workspaceId !== undefined) return normalizeRequired(input.workspaceId, "workspace mutation workspace id"); + if (input.turnId) { + const turn = context.database.prepare("SELECT workspace_id FROM ling_turns WHERE id = ?").get(input.turnId) as Record | undefined; + if (turn && typeof turn.workspace_id === "string") return readString(turn, "workspace_id"); + } + if (input.threadId) { + const thread = context.database.prepare("SELECT workspace_id FROM ling_threads WHERE id = ?").get(input.threadId) as Record | undefined; + if (thread && typeof thread.workspace_id === "string") return readString(thread, "workspace_id"); + } + return DEFAULT_WORKSPACE_ID; +} + +function assertMutationReferences(context: SqliteStoreContext, input: CreateWorkspaceMutationRecordInput, workspaceId: string): void { + const run = context.database.prepare("SELECT thread_id, cwd FROM run_sessions WHERE id = ?").get(input.runId) as Record | undefined; if (!run) throw new Error(`Workspace mutation run not found: ${input.runId}`); const runThreadId = readNullableString(run, "thread_id"); if (input.threadId && runThreadId && input.threadId !== runThreadId) throw new Error(`Workspace mutation thread does not match run: ${input.threadId}`); + if (typeof run.cwd === "string" && resolvePath(run.cwd) !== resolvePath(input.cwd)) { + throw new Error(`Workspace mutation cwd does not match run: ${input.cwd}`); + } if (input.turnId) { - const turn = context.database.prepare("SELECT thread_id FROM ling_turns WHERE id = ?").get(input.turnId) as Record | undefined; + const turn = context.database.prepare("SELECT thread_id, workspace_id FROM ling_turns WHERE id = ?").get(input.turnId) as Record | undefined; if (!turn) throw new Error(`Workspace mutation turn not found: ${input.turnId}`); if (input.threadId && readString(turn, "thread_id") !== input.threadId) throw new Error(`Workspace mutation turn belongs to another thread: ${input.turnId}`); + if (readString(turn, "workspace_id") !== workspaceId) { + throw new Error(`Workspace mutation turn belongs to another workspace: ${input.turnId}`); + } + const link = context.database.prepare("SELECT 1 AS linked FROM ling_turn_runs WHERE turn_id = ? AND run_id = ?").get(input.turnId, input.runId); + if (!link) throw new Error(`Workspace mutation Turn is not linked to Run: ${input.turnId}/${input.runId}`); + } + for (const [artifactId, label] of [ + [input.forwardPatchArtifactId, "forward patch"], + [input.inversePatchArtifactId, "inverse patch"], + [input.artifactId, "workspace mutation"], + ] as const) { + if (!artifactId) continue; + const artifact = context.database.prepare("SELECT run_id FROM artifacts WHERE id = ?").get(artifactId) as Record | undefined; + if (!artifact) throw new Error(`Workspace mutation ${label} artifact not found: ${artifactId}`); + if (readString(artifact, "run_id") !== input.runId) { + throw new Error(`Workspace mutation ${label} artifact belongs to another Run: ${artifactId}`); + } } } diff --git a/packages/core/test/workspace-provenance.test.mjs b/packages/core/test/workspace-provenance.test.mjs index 103cdea11..61449e34f 100644 --- a/packages/core/test/workspace-provenance.test.mjs +++ b/packages/core/test/workspace-provenance.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { DatabaseSync } from "node:sqlite"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; @@ -115,7 +116,7 @@ test("workspace mutation provenance and compaction checkpoints survive SQLite re affectedPaths: ["file.txt"], createdAt: now, }); - assert.equal(recovery.state, "validating"); + assert.equal(recovery.state, "available"); assert.equal(store.createPostCompactionUndoRecord({ idempotencyKey: "undo-provenance-record", threadId: thread.id, @@ -127,6 +128,13 @@ test("workspace mutation provenance and compaction checkpoints survive SQLite re affectedPaths: ["file.txt"], createdAt: now, }).id, recovery.id); + assert.equal(store.updatePostCompactionUndoRecord({ + id: recovery.id, + expectedState: "available", + state: "validating", + lastEventSeq: 8, + updatedAt: "2026-08-25T01:00:00.500Z", + })?.state, "validating"); assert.equal(store.updatePostCompactionUndoRecord({ id: recovery.id, expectedState: "validating", @@ -156,6 +164,12 @@ test("workspace mutation provenance and compaction checkpoints survive SQLite re assert.equal(reopened.getLatestWorkspaceProvenanceWatermark(), "workspace_default:1"); assert.equal(reopened.getPostCompactionUndoRecord(recovery.id)?.state, "undone"); assert.equal(reopened.listPostCompactionUndoRecords({ idempotencyKey: "undo-provenance-record" }).length, 1); + const integrity = new DatabaseSync(reopened.paths.databasePath); + try { + assert.deepEqual(integrity.prepare("PRAGMA foreign_key_check").all(), []); + } finally { + integrity.close(); + } } finally { reopened.close(); } diff --git a/packages/daemon/src/features/channels/context-processing/conversation-summary-coordinator.ts b/packages/daemon/src/features/channels/context-processing/conversation-summary-coordinator.ts index 4d08b2566..5f4e1198d 100644 --- a/packages/daemon/src/features/channels/context-processing/conversation-summary-coordinator.ts +++ b/packages/daemon/src/features/channels/context-processing/conversation-summary-coordinator.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { CommitConversationContextSummaryResult, ConversationContextSummarySourceFence, @@ -80,6 +81,7 @@ export type CoordinateConversationSummaryResult = | { readonly status: "not_needed" } | { readonly status: "generator_unavailable"; readonly plan: ConversationSummaryPlan } | { readonly status: "generation_failed"; readonly plan: ConversationSummaryPlan; readonly error: unknown } + | { readonly status: "checkpoint_failed"; readonly plan: ConversationSummaryPlan; readonly commit: CommitConversationContextSummaryResult; readonly error: unknown } | { readonly status: "completed"; readonly plan: ConversationSummaryPlan; readonly commit: CommitConversationContextSummaryResult; readonly checkpoint?: CompactionCheckpoint }; export function planConversationContextSummary(input: { @@ -230,21 +232,30 @@ export async function executeConversationSummaryPlan(input: { }, input.now ?? new Date().toISOString()); if (commit.status !== "committed") return { status: "completed", plan, commit }; const sourceTurnIds = [...new Set(plan.sourceItems.flatMap((item) => item.turnId ? [item.turnId] : []))]; - const checkpoint = input.store.createCompactionCheckpoint({ - threadId: plan.projection.metadata.threadId, - channelId: plan.sourceItems.find((item) => item.channelId)?.channelId, - sourceTurnId: plan.projection.metadata.turnId, - contextViewId: plan.projection.metadata.contextViewId, - contextViewCreatedAt: plan.projection.metadata.contextViewCreatedAt, - coveredItemSeqFrom: plan.coveredFromSequence, - coveredItemSeqTo: plan.coveredThroughSequence, - coveredTurnIds: sourceTurnIds.length > 0 ? sourceTurnIds : [plan.projection.metadata.turnId], - summaryIds: [commit.summary.id, ...(plan.activeSummary ? [plan.activeSummary.id] : [])], - sourceRefs: plan.sourceRefs, - workspaceProvenanceWatermark: input.store.getLatestWorkspaceProvenanceWatermark(), - compactionPolicyVersion: CONVERSATION_SUMMARY_POLICY_VERSION, - createdAt: input.now ?? new Date().toISOString(), - }); + let checkpoint: CompactionCheckpoint; + try { + checkpoint = input.store.createCompactionCheckpoint({ + threadId: plan.projection.metadata.threadId, + channelId: plan.sourceItems.find((item) => item.channelId)?.channelId, + sourceTurnId: plan.projection.metadata.turnId, + contextViewId: plan.projection.metadata.contextViewId, + contextViewCreatedAt: plan.projection.metadata.contextViewCreatedAt, + coveredItemSeqFrom: plan.coveredFromSequence, + coveredItemSeqTo: plan.coveredThroughSequence, + coveredTurnIds: sourceTurnIds.length > 0 ? sourceTurnIds : [plan.projection.metadata.turnId], + summaryIds: [commit.summary.id, ...(plan.activeSummary ? [plan.activeSummary.id] : [])], + summaryContentSha256: hashSummaryContent(commit.summary), + sourceRefs: plan.sourceRefs, + workspaceProvenanceWatermark: input.store.getLatestWorkspaceProvenanceWatermark(), + compactionPolicyVersion: CONVERSATION_SUMMARY_POLICY_VERSION, + createdAt: input.now ?? new Date().toISOString(), + }); + } catch (error) { + // The summary commit is durable evidence, but it is not a recovery + // authority until the checkpoint install succeeds. Keep the installed + // checkpoint absent and make the failed boundary explicit to callers. + return { status: "checkpoint_failed", plan, commit, error }; + } return { status: "completed", plan, commit, checkpoint }; } @@ -252,6 +263,17 @@ function throwIfSummaryAborted(signal: AbortSignal | undefined): void { if (signal?.aborted) throw signal.reason ?? new Error("Conversation summary generation aborted."); } +function hashSummaryContent(summary: ConversationContextSummary): string { + return createHash("sha256").update(JSON.stringify({ + objective: summary.objective ?? null, + decisions: summary.decisions, + constraints: summary.constraints, + openQuestions: summary.openQuestions, + activeWorkRefs: summary.activeWorkRefs, + sourceRefs: summary.sourceRefs, + }), "utf8").digest("hex"); +} + function stableCompletedPrefix(store: RunStore, items: readonly ConversationItemV1[]): ConversationItemV1[] { const stable: ConversationItemV1[] = []; const terminalByTurnId = new Map(); diff --git a/packages/daemon/src/features/runs/workspace-change-recovery.ts b/packages/daemon/src/features/runs/workspace-change-recovery.ts index 125ca4e69..8779d4b5e 100644 --- a/packages/daemon/src/features/runs/workspace-change-recovery.ts +++ b/packages/daemon/src/features/runs/workspace-change-recovery.ts @@ -212,7 +212,7 @@ export async function recoverTurnWorkspaceChanges( if (durableRecord && durableRecord.action !== action) { throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", "This idempotency key is already bound to the opposite recovery action."); } - if (durableRecord && (durableRecord.state === "conflict" || durableRecord.state === "failed" || durableRecord.state === "expired" || durableRecord.state === "provenance_missing")) { + if (durableRecord && (durableRecord.state === "conflict" || durableRecord.state === "failed" || durableRecord.state === "expired" || durableRecord.state === "provenance_missing" || durableRecord.state === "snapshot_unverified")) { throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", `The durable recovery record is ${durableRecord.state}; use a new idempotency key after review.`); } if (durableRecord && ((action === "undo" && durableRecord.state === "undone") || (action === "reapply" && durableRecord.state === "applied"))) { @@ -221,24 +221,41 @@ export async function recoverTurnWorkspaceChanges( const events = turnRecoveryEvents(store, ownerRun.id, turn.id); const prior = events.find((event) => isRecord(event.data) && event.data.idempotencyKey === idempotencyKey && event.type === (action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied")); if (prior) { - if (durableRecord?.state === "validating") { - store.updatePostCompactionUndoRecord({ - id: durableRecord.id, - expectedState: "validating", - state: action === "undo" ? "undone" : "applied", - lastEventSeq: prior.seq, - updatedAt: prior.ts, - }); + if (durableRecord) { + if (durableRecord.state === "available") { + const claimed = store.updatePostCompactionUndoRecord({ + id: durableRecord.id, + expectedState: "available", + state: "validating", + lastEventSeq: prior.seq, + updatedAt: prior.ts, + }); + if (!claimed) { + throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", "Another recovery request owns this durable recovery record."); + } + } + if (durableRecord.state === "available" || durableRecord.state === "validating") { + const settled = store.updatePostCompactionUndoRecord({ + id: durableRecord.id, + expectedState: "validating", + state: action === "undo" ? "undone" : "applied", + lastEventSeq: prior.seq, + updatedAt: prior.ts, + }); + if (!settled) { + throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", "Another recovery request owns this durable recovery record."); + } + } } return buildTurnWorkspaceChangesResponse(store, turn); } const patchSet = syntheticPatchSet(context.steps.map((step) => step.plan)); const observation = observeRecoveryState(cwd, patchSet, events); if (observation.conflict) { - if (durableRecord?.state === "validating") { + if (durableRecord?.state === "available" || durableRecord?.state === "validating") { store.updatePostCompactionUndoRecord({ id: durableRecord.id, - expectedState: "validating", + expectedState: durableRecord.state, state: "conflict", lastEventSeq: events.at(-1)?.seq ?? durableRecord.lastEventSeq, updatedAt: new Date().toISOString(), @@ -263,11 +280,22 @@ export async function recoverTurnWorkspaceChanges( checkpointId: findTurnCheckpoint(store, turn)?.id, expectedWorkspaceFingerprint: recoveryIntentFingerprint(observation.completedIntent, "before"), action, - state: "validating", affectedPaths: observation.completedIntent.files.map((file) => file.path), createdAt: observation.updatedAt ?? new Date().toISOString(), }); - if (recoveredRecord.state === "validating") { + if (recoveredRecord.state === "available") { + const claimed = store.updatePostCompactionUndoRecord({ + id: recoveredRecord.id, + expectedState: "available", + state: "validating", + lastEventSeq: events.at(-1)?.seq ?? recoveredRecord.lastEventSeq, + updatedAt: observation.updatedAt ?? new Date().toISOString(), + }); + if (!claimed) { + throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", "Another recovery request owns this durable recovery record."); + } + } + if (recoveredRecord.state === "available" || recoveredRecord.state === "validating") { const terminalEvent = store.appendEvent(ownerRun.id, action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied", { scope: "turn", turnId: turn.id, @@ -298,10 +326,10 @@ export async function recoverTurnWorkspaceChanges( try { preflight = await preflightRecoveryPlanSequence(cwd, turnRecoveryPlanSteps(context.steps, action)); } catch (error) { - if (durableRecord?.state === "validating") { + if (durableRecord?.state === "available" || durableRecord?.state === "validating") { store.updatePostCompactionUndoRecord({ id: durableRecord.id, - expectedState: "validating", + expectedState: durableRecord.state, state: "conflict", lastEventSeq: durableRecord.lastEventSeq, updatedAt: new Date().toISOString(), @@ -310,10 +338,10 @@ export async function recoverTurnWorkspaceChanges( throw error; } const expectedWorkspaceFingerprint = workspaceFingerprint(preflight.before); - if (durableRecord && durableRecord.state === "validating" && durableRecord.expectedWorkspaceFingerprint !== expectedWorkspaceFingerprint) { + if (durableRecord && (durableRecord.state === "available" || durableRecord.state === "validating") && durableRecord.expectedWorkspaceFingerprint !== expectedWorkspaceFingerprint) { store.updatePostCompactionUndoRecord({ id: durableRecord.id, - expectedState: "validating", + expectedState: durableRecord.state, state: "conflict", lastEventSeq: durableRecord.lastEventSeq, updatedAt: new Date().toISOString(), @@ -329,7 +357,6 @@ export async function recoverTurnWorkspaceChanges( checkpointId: findTurnCheckpoint(store, turn)?.id, expectedWorkspaceFingerprint, action, - state: "validating", affectedPaths: preflight.paths, createdAt: new Date().toISOString(), }); @@ -344,14 +371,28 @@ export async function recoverTurnWorkspaceChanges( cwd, files: recoveryIntentFiles(preflight), }); - store.updatePostCompactionUndoRecord({ + const validating = store.updatePostCompactionUndoRecord({ id: recoveryRecord.id, - expectedState: "validating", + expectedState: recoveryRecord.state === "available" ? "available" : "validating", state: "validating", lastEventSeq: startedEvent.seq, updatedAt: startedEvent.ts, }); - commitRecovery(cwd, preflight); + if (!validating) { + throw new RunWorkspaceRecoveryError(409, "workspace_change_conflict", "Another recovery request owns this durable recovery record."); + } + try { + commitRecovery(cwd, preflight); + } catch (error) { + store.updatePostCompactionUndoRecord({ + id: recoveryRecord.id, + expectedState: "validating", + state: "failed", + lastEventSeq: startedEvent.seq, + updatedAt: new Date().toISOString(), + }); + throw error; + } const terminalEvent = store.appendEvent(ownerRun.id, action === "undo" ? "workspace_changes_undone" : "workspace_changes_reapplied", { scope: "turn", turnId: turn.id, @@ -505,6 +546,25 @@ function buildTurnRecoveryContext(store: RunStore, turn: import("@ling-stack/cor provenanceComplete = false; continue; } + // A Run can only contribute mutations when every durable record points + // back to this exact Turn lineage and workspace. Do not allow a malformed + // or legacy record to broaden a Turn-level undo target to another Turn. + let recordsMatchTurn = true; + try { + recordsMatchTurn = patchSet.mutationRecords.every((record) => + record.runId === run.id + && record.turnId === turn.id + && record.threadId === turn.threadId + && record.workspaceId === turn.workspaceId + && resolvePath(record.cwd) === resolvePath(run.cwd), + ); + } catch { + recordsMatchTurn = false; + } + if (!recordsMatchTurn) { + provenanceComplete = false; + continue; + } const byPlan = new Map(); for (const record of patchSet.mutationRecords) { const group = byPlan.get(record.patchPlanId) ?? []; @@ -713,10 +773,23 @@ function durablePatchPlans( const plans: PatchPlan[] = []; for (const group of ordered) { const artifactIds = new Set(group.map((record) => record.forwardPatchArtifactId).filter((id): id is string => Boolean(id))); - if (artifactIds.size !== 1 || group.some((record) => record.effectState !== "applied")) return { plans: [], complete: false }; - const artifact = store.getArtifact([...artifactIds][0]!); - if (!artifact || !isPatchPlan(artifact.sealedPayload)) return { plans: [], complete: false }; + const inverseArtifactIds = new Set(group.map((record) => record.inversePatchArtifactId)); + if (artifactIds.size !== 1 || inverseArtifactIds.size !== 1 || group.some((record) => record.effectState !== "applied")) { + return { plans: [], complete: false }; + } + const forwardArtifactId = [...artifactIds][0]!; + const inverseArtifactId = [...inverseArtifactIds][0]!; + const artifact = store.getArtifact(forwardArtifactId); + const inverseArtifact = store.getArtifact(inverseArtifactId); + if (!artifact || artifact.runId !== group[0]?.runId || !isPatchPlan(artifact.sealedPayload)) { + return { plans: [], complete: false }; + } const plan = artifact.sealedPayload; + if (!inverseArtifact || inverseArtifact.runId !== group[0]?.runId || !isPatchPlan(inverseArtifact.sealedPayload) + || JSON.stringify(inverseArtifact.sealedPayload) !== JSON.stringify(invertPatchPlan(plan)) + || group.some((record) => record.forwardPatchArtifactId !== forwardArtifactId || record.inversePatchArtifactId !== inverseArtifactId)) { + return { plans: [], complete: false }; + } const orderedRecords = [...group].sort((left, right) => left.operationIndex - right.operationIndex); if (orderedRecords.length !== plan.operations.length || orderedRecords.some((record, index) => { diff --git a/packages/daemon/test/conversation-summary-coordinator.test.mjs b/packages/daemon/test/conversation-summary-coordinator.test.mjs index 451c33caf..5b939cc13 100644 --- a/packages/daemon/test/conversation-summary-coordinator.test.mjs +++ b/packages/daemon/test/conversation-summary-coordinator.test.mjs @@ -52,6 +52,7 @@ test("summary coordinator compacts stable turns, preserves a raw tail, and advan assert.ok(first.checkpoint); assert.equal(first.checkpoint.sourceTurnId, firstPlan.projection.metadata.turnId); assert.deepEqual(first.checkpoint.summaryIds, [first.commit.summary.id]); + assert.match(first.checkpoint.summaryContentSha256, /^[0-9a-f]{64}$/u); assert.equal(first.checkpoint.coveredItemSeqFrom, firstBatch[0].item.sequence); assert.equal(first.checkpoint.coveredItemSeqTo, firstBatch[9].item.sequence); @@ -79,6 +80,35 @@ test("summary coordinator compacts stable turns, preserves a raw tail, and advan assert.deepEqual(second.checkpoint.summaryIds, [second.commit.summary.id, first.commit.summary.id]); }); +test("summary commit remains evidence when checkpoint installation fails", async (t) => { + const fixture = await createFixture(t, "checkpoint-failure"); + const turns = []; + for (let index = 0; index < 10; index += 1) { + turns.push(addCompletedTurn(fixture, `checkpoint-failure-${index}`, `Decision ${index}`)); + } + const failingStore = new Proxy(fixture.store, { + get(target, property) { + if (property === "createCompactionCheckpoint") { + return () => { throw new Error("simulated checkpoint install failure"); }; + } + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const result = await coordinateConversationContextSummary({ + store: failingStore, + sourceItemId: turns.at(-1).item.id, + inputBudgetTokens: 16_000, + policy: summaryPolicy, + generate: mergeDraft, + now: "2026-08-10T05:30:00.000Z", + }); + assert.equal(result.status, "checkpoint_failed"); + assert.equal(result.commit.status, "committed"); + assert.deepEqual(fixture.store.listCompactionCheckpoints({ threadId: fixture.thread.id }), []); + assert.ok(fixture.store.getConversationContextSummary(result.commit.summary.id)); +}); + test("summary coordinator selects the hard trigger and discards a late assignment result", async (t) => { const fixture = await createFixture(t, "race"); const turns = []; diff --git a/packages/daemon/test/post-compaction-undo.test.mjs b/packages/daemon/test/post-compaction-undo.test.mjs index 3e69f746a..4ad189c44 100644 --- a/packages/daemon/test/post-compaction-undo.test.mjs +++ b/packages/daemon/test/post-compaction-undo.test.mjs @@ -9,6 +9,7 @@ import { hashRecoveryContent, recoverTurnWorkspaceChanges, } from "../dist/features/runs/workspace-change-recovery.js"; +import { recordPatchProvenance } from "../dist/features/runs/patch-actions.js"; test("post-compaction Turn undo resolves durable mutation provenance across reopen", async () => { const cwd = await mkdtemp(resolve(tmpdir(), "ling-post-compaction-undo-")); @@ -113,3 +114,250 @@ test("post-compaction Turn undo resolves durable mutation provenance across reop await rm(cwd, { recursive: true, force: true }); } }); + +test("Turn recovery requires a matching inverse artifact and never trusts a forward-only record", async () => { + const fixture = await createTurnFixture("inverse-mismatch"); + try { + const path = resolve(fixture.cwd, "mismatch.txt"); + await writeFile(path, "old\n", "utf8"); + const plan = planApplyPatch({ + patchText: `*** Begin Patch +*** Update File: mismatch.txt +@@ +-old ++new +*** End Patch`, + readFile: (relativePath) => relativePath === "mismatch.txt" ? "old\n" : undefined, + }); + const forward = fixture.store.createArtifact({ runId: fixture.run.id, type: "json", path: "forward", sealedPayload: plan, createdAt: fixture.now }); + const wrongInverse = fixture.store.createArtifact({ runId: fixture.run.id, type: "json", path: "wrong-inverse", sealedPayload: plan, createdAt: fixture.now }); + await writeFile(path, "new\n", "utf8"); + fixture.store.createWorkspaceMutationRecord({ + cwd: fixture.cwd, + workspaceId: fixture.turn.workspaceId, + threadId: fixture.thread.id, + turnId: fixture.turn.id, + runId: fixture.run.id, + patchPlanId: "patch_plan_inverse_mismatch", + operationIndex: 0, + path: "mismatch.txt", + operation: "update", + beforeSha256: "before", + afterSha256: "after", + forwardPatchArtifactId: forward.id, + inversePatchArtifactId: wrongInverse.id, + sourceRefs: [{ type: "turn", id: fixture.turn.id }, { type: "run", id: fixture.run.id }], + createdAt: fixture.now, + }); + fixture.store.markRunSucceeded(fixture.run.id, "2026-08-25T03:00:01.000Z"); + const response = await buildTurnWorkspaceChangesResponse(fixture.store, fixture.turn); + assert.equal(response.provenanceComplete, false); + assert.equal(response.state, "unavailable"); + await assert.rejects( + () => recoverTurnWorkspaceChanges(fixture.store, fixture.turn, "undo", "inverse-mismatch-undo"), + /durable workspace provenance is incomplete/u, + ); + assert.equal(await readFile(path, "utf8"), "new\n"); + } finally { + fixture.store.close(); + await rm(fixture.cwd, { recursive: true, force: true }); + } +}); + +test("Turn recovery undoes multiple retry Runs in reverse provenance order", async () => { + const fixture = await createTurnFixture("multi-run"); + try { + const path = resolve(fixture.cwd, "retry.txt"); + await writeFile(path, "old\n", "utf8"); + const first = await addTurnPatch(fixture, fixture.run, path, "old", "first", "retry-first"); + const secondRun = fixture.store.createRun({ + id: "run_multi_run_retry", + cwd: fixture.cwd, + taskId: "task_multi_run_retry", + threadId: fixture.thread.id, + turnId: fixture.turn.id, + providerName: "test", + providerModel: "test", + taskSnapshot: {}, + providerSnapshot: {}, + requestSnapshot: fixture.requestSnapshot, + createdAt: "2026-08-25T03:00:02.000Z", + }); + await addTurnPatch(fixture, secondRun, path, "first", "second", "retry-second"); + assert.equal(await readFile(path, "utf8"), "second\n"); + const response = await recoverTurnWorkspaceChanges(fixture.store, fixture.turn, "undo", "multi-run-undo"); + assert.equal(response.state, "undone"); + assert.equal(await readFile(path, "utf8"), "old\n"); + assert.deepEqual(response.runIds, [fixture.run.id, secondRun.id]); + assert.ok(first); + } finally { + fixture.store.close(); + await rm(fixture.cwd, { recursive: true, force: true }); + } +}); + +test("Turn recovery selectively undoes its own paths and blocks when a later Turn touched them", async () => { + const fixture = await createTurnFixture("selective"); + try { + const targetPath = resolve(fixture.cwd, "target.txt"); + const unrelatedPath = resolve(fixture.cwd, "unrelated.txt"); + await writeFile(targetPath, "old\n", "utf8"); + await addTurnPatch(fixture, fixture.run, targetPath, "old", "target", "target"); + const laterTurnResult = fixture.store.appendConversationUserTurn({ + channelId: fixture.channel.id, + threadId: fixture.thread.id, + title: "Later edit", + userInstruction: "Later edit", + payload: { body: "Later edit", format: "plain_text", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [] }, + idempotencyKey: "later-turn", + }, "2026-08-25T03:00:04.000Z"); + const laterRun = fixture.store.createRun({ + id: "run_later_turn", + cwd: fixture.cwd, + taskId: "task_later_turn", + threadId: fixture.thread.id, + turnId: laterTurnResult.turnId, + providerName: "test", + providerModel: "test", + taskSnapshot: {}, + providerSnapshot: {}, + requestSnapshot: fixture.requestSnapshot, + createdAt: "2026-08-25T03:00:05.000Z", + }); + await writeFile(unrelatedPath, "unrelated-old\n", "utf8"); + await addTurnPatch({ ...fixture, turn: fixture.store.getLingTurn(laterTurnResult.turnId), run: laterRun }, laterRun, unrelatedPath, "unrelated-old", "unrelated-later", "later-unrelated"); + assert.equal(await readFile(unrelatedPath, "utf8"), "unrelated-later\n"); + const undone = await recoverTurnWorkspaceChanges(fixture.store, fixture.turn, "undo", "selective-undo"); + assert.equal(undone.state, "undone"); + assert.equal(await readFile(targetPath, "utf8"), "old\n"); + assert.equal(await readFile(unrelatedPath, "utf8"), "unrelated-later\n"); + + const reapplied = await recoverTurnWorkspaceChanges(fixture.store, fixture.turn, "reapply", "selective-reapply"); + assert.equal(reapplied.state, "applied"); + assert.equal(await readFile(targetPath, "utf8"), "target\n"); + const laterTargetTurn = fixture.store.appendConversationUserTurn({ + channelId: fixture.channel.id, + threadId: fixture.thread.id, + title: "Later target edit", + userInstruction: "Later target edit", + payload: { body: "Later target edit", format: "plain_text", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [] }, + idempotencyKey: "later-target-turn", + }, "2026-08-25T03:00:06.000Z"); + const laterTargetRun = fixture.store.createRun({ + id: "run_later_target_turn", + cwd: fixture.cwd, + taskId: "task_later_target_turn", + threadId: fixture.thread.id, + turnId: laterTargetTurn.turnId, + providerName: "test", + providerModel: "test", + taskSnapshot: {}, + providerSnapshot: {}, + requestSnapshot: fixture.requestSnapshot, + createdAt: "2026-08-25T03:00:07.000Z", + }); + await addTurnPatch({ ...fixture, turn: fixture.store.getLingTurn(laterTargetTurn.turnId), run: laterTargetRun }, laterTargetRun, targetPath, "target", "later-target", "later-target"); + await assert.rejects( + () => recoverTurnWorkspaceChanges(fixture.store, fixture.turn, "undo", "selective-conflict"), + /changed after preview/u, + ); + assert.equal(await readFile(targetPath, "utf8"), "later-target\n"); + } finally { + fixture.store.close(); + await rm(fixture.cwd, { recursive: true, force: true }); + } +}); + +test("no-op provenance capture does not create a mutation record", async () => { + const fixture = await createTurnFixture("no-op"); + try { + recordPatchProvenance({ + store: fixture.store, + run: fixture.run, + cwd: fixture.cwd, + plan: { format: "ling-apply-patch-v1", operations: [], touchedPaths: [], summary: { fileCount: 0, addCount: 0, updateCount: 0, deleteCount: 0, moveCount: 0, addedLines: 0, removedLines: 0 }, rawPatchSha256: "noop" }, + before: new Map(), + after: new Map(), + forwardPatchArtifactId: "unused-forward", + inversePatchArtifactId: "unused-inverse", + now: fixture.now, + }); + assert.deepEqual(fixture.store.listWorkspaceMutationRecords({ runId: fixture.run.id }), []); + } finally { + fixture.store.close(); + await rm(fixture.cwd, { recursive: true, force: true }); + } +}); + +async function createTurnFixture(suffix) { + const cwd = await mkdtemp(resolve(tmpdir(), `ling-post-compaction-${suffix}-`)); + const now = "2026-08-25T03:00:00.000Z"; + const store = createSqliteRunStore(cwd); + const channel = store.createChannel({ name: `Undo ${suffix}`, boundPath: cwd }, now); + const thread = store.resolveOrCreateConversationThread({ + surfaceKind: "channel_root", channelId: channel.id, title: `Undo ${suffix}`, objective: "Undo after compaction", + }, now).thread; + const turnResult = store.appendConversationUserTurn({ + channelId: channel.id, + threadId: thread.id, + title: "Change file", + userInstruction: "Change file", + payload: { body: "Change file", format: "plain_text", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [] }, + idempotencyKey: `post-compaction-${suffix}`, + }, now); + const requestSnapshot = { + cwd, + effectivePolicySnapshot: { + mode: "workspace_write", toolDecisions: {}, allowedWritePaths: [cwd], workspacePolicyUpdatedAt: now, riskNotes: [], + }, + }; + const run = store.createRun({ + id: `run_post_compaction_${suffix}`, + cwd, + taskId: `task_post_compaction_${suffix}`, + threadId: thread.id, + turnId: turnResult.turnId, + providerName: "test", + providerModel: "test", + taskSnapshot: {}, + providerSnapshot: {}, + requestSnapshot, + createdAt: now, + }); + return { cwd, now, store, channel, thread, turn: store.getLingTurn(turnResult.turnId), run, requestSnapshot }; +} + +async function addTurnPatch(fixture, run, absolutePath, before, after, suffix) { + const relativePath = absolutePath.slice(fixture.cwd.length + 1); + const plan = planApplyPatch({ + patchText: `*** Begin Patch +*** Update File: ${relativePath} +@@ +-${before} ++${after} +*** End Patch`, + readFile: (path) => path === relativePath ? `${before}\n` : undefined, + }); + const forward = fixture.store.createArtifact({ runId: run.id, type: "json", path: `${suffix}-forward`, sealedPayload: plan, createdAt: fixture.now }); + const inverse = fixture.store.createArtifact({ runId: run.id, type: "json", path: `${suffix}-inverse`, sealedPayload: invertPatchPlan(plan), createdAt: fixture.now }); + await writeFile(absolutePath, `${after}\n`, "utf8"); + fixture.store.createWorkspaceMutationRecord({ + cwd: fixture.cwd, + workspaceId: fixture.turn.workspaceId, + threadId: fixture.turn.threadId, + turnId: fixture.turn.id, + runId: run.id, + patchPlanId: `patch_plan_${suffix}`, + operationIndex: 0, + path: relativePath, + operation: "update", + beforeSha256: "before", + afterSha256: "after", + forwardPatchArtifactId: forward.id, + inversePatchArtifactId: inverse.id, + sourceRefs: [{ type: "turn", id: fixture.turn.id }, { type: "run", id: run.id }], + createdAt: fixture.now, + }); + fixture.store.markRunSucceeded(run.id, "2026-08-25T03:00:03.000Z"); + return plan; +} From 1e3b75e3841ae1f45d9707eed366e8806dbe671f Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Wed, 26 Aug 2026 17:32:28 +0800 Subject: [PATCH 8/8] feat: implement post-compaction snapshot restore --- apps/web/src/api/workspace-changes.ts | 36 + .../right-dock/WorkspaceChangeReviewPage.tsx | 3 + .../WorkspaceSnapshotRestorePanel.tsx | 182 ++++ .../channel-right-dock/workspace-changes.css | 43 + .../post-compaction-snapshot-restore.md | 709 +++++++++++++++ .../api-client/src/endpoints/operations.ts | 39 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/workspace-snapshots.ts | 308 +++++++ packages/core/src/run-types.ts | 47 + packages/core/src/runtime-paths.ts | 10 + packages/core/src/storage/sqlite-run-store.ts | 140 +++ .../storage/sqlite/approval-request-store.ts | 5 + .../core/src/storage/sqlite/migrations.ts | 7 + .../src/storage/sqlite/run-session-store.ts | 6 + .../sqlite/workspace-snapshot-migration.ts | 461 ++++++++++ .../sqlite/workspace-snapshot-store.ts | 562 ++++++++++++ .../workspace-snapshot-migration.test.mjs | 108 +++ packages/daemon/src/config.ts | 32 + .../runs/workspace-snapshot-restore.ts | 818 ++++++++++++++++++ .../runs/workspace-snapshot-routes.ts | 140 +++ packages/daemon/src/server/router.ts | 10 + .../test/workspace-snapshot-restore.test.mjs | 260 ++++++ .../test/workspace-snapshot-routes.test.mjs | 190 ++++ 23 files changed, 4116 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/features/workspace/right-dock/WorkspaceSnapshotRestorePanel.tsx create mode 100644 docs/design/post-compaction-snapshot-restore.md create mode 100644 packages/contracts/src/workspace-snapshots.ts create mode 100644 packages/core/src/storage/sqlite/workspace-snapshot-migration.ts create mode 100644 packages/core/src/storage/sqlite/workspace-snapshot-store.ts create mode 100644 packages/core/test/workspace-snapshot-migration.test.mjs create mode 100644 packages/daemon/src/features/runs/workspace-snapshot-restore.ts create mode 100644 packages/daemon/src/features/runs/workspace-snapshot-routes.ts create mode 100644 packages/daemon/test/workspace-snapshot-restore.test.mjs create mode 100644 packages/daemon/test/workspace-snapshot-routes.test.mjs diff --git a/apps/web/src/api/workspace-changes.ts b/apps/web/src/api/workspace-changes.ts index 3a0b4e746..cebb3f67c 100644 --- a/apps/web/src/api/workspace-changes.ts +++ b/apps/web/src/api/workspace-changes.ts @@ -7,6 +7,10 @@ import type { WorkspaceFileOpenRequest, WorkspaceFileOpenResponse, WorkspaceFileOpenTarget, + WorkspaceSnapshotManifest, + WorkspaceSnapshotPreview, + SnapshotRestoreConfirmation, + SnapshotRestoreRequest, } from "../types.js"; import { daemonClient } from "./client.js"; import { channelWorkCellDemoWorkflowRecovery } from "../lib/work-cell-demo-fixture.js"; @@ -15,6 +19,38 @@ export function getWorkspaceChanges(cwd: string): Promise { + return daemonClient.listWorkspaceSnapshots(cwd); +} + +export function createWorkspaceSnapshot(cwd: string): Promise<{ readonly manifest: WorkspaceSnapshotManifest }> { + return daemonClient.createWorkspaceSnapshot(cwd); +} + +export function getWorkspaceSnapshot(cwd: string, snapshotId: string): Promise { + return daemonClient.getWorkspaceSnapshot(cwd, snapshotId); +} + +export function createWorkspaceSnapshotAnchor(cwd: string, snapshotId: string): Promise { + return daemonClient.createWorkspaceSnapshotAnchor(cwd, snapshotId); +} + +export function createWorkspaceSnapshotConfirmation(cwd: string, snapshotId: string, anchorId: string): Promise { + return daemonClient.createWorkspaceSnapshotConfirmation(cwd, snapshotId, { anchorId }); +} + +export function confirmWorkspaceSnapshotRestore(cwd: string, confirmationId: string): Promise { + return daemonClient.confirmWorkspaceSnapshotRestore(cwd, confirmationId); +} + +export function restoreWorkspaceSnapshot(cwd: string, snapshotId: string, input: { readonly idempotencyKey: string; readonly anchorId: string; readonly confirmationId: string; readonly expectedWorkspaceFingerprint?: string; readonly expectedProvenanceWatermark?: string }): Promise { + return daemonClient.restoreWorkspaceSnapshot(cwd, snapshotId, input); +} + +export function getWorkspaceSnapshotRestore(cwd: string, requestId: string): Promise { + return daemonClient.getWorkspaceSnapshotRestore(cwd, requestId); +} + export function getRunWorkspaceChanges(cwd: string, runId: string): Promise { return daemonClient.getRunWorkspaceChanges(runId, cwd); } diff --git a/apps/web/src/features/workspace/right-dock/WorkspaceChangeReviewPage.tsx b/apps/web/src/features/workspace/right-dock/WorkspaceChangeReviewPage.tsx index 876021317..bda73197f 100644 --- a/apps/web/src/features/workspace/right-dock/WorkspaceChangeReviewPage.tsx +++ b/apps/web/src/features/workspace/right-dock/WorkspaceChangeReviewPage.tsx @@ -17,6 +17,7 @@ import { terminalReportFileName, } from "../../../shared/conversation/blocks/TerminalReportBlock.js"; import { WorkspaceFileOpenMenu } from "../../../shared/conversation/blocks/WorkspaceFileOpenMenu.js"; +import { WorkspaceSnapshotRestorePanel } from "./WorkspaceSnapshotRestorePanel.js"; import { Button, EmptyState, @@ -61,6 +62,7 @@ export function WorkspaceChangeReviewPage({ return () => globalThis.cancelAnimationFrame(frameId); }, [files.length, requestedPath]); + function scrollToFile(path: string): void { setSelectedPath(path); fileRefs.current.get(path)?.scrollIntoView({ behavior: "smooth", block: "start" }); @@ -177,6 +179,7 @@ export function WorkspaceChangeReviewPage({ ) : null} + {cwd ? : null} ); } diff --git a/apps/web/src/features/workspace/right-dock/WorkspaceSnapshotRestorePanel.tsx b/apps/web/src/features/workspace/right-dock/WorkspaceSnapshotRestorePanel.tsx new file mode 100644 index 000000000..94851da7f --- /dev/null +++ b/apps/web/src/features/workspace/right-dock/WorkspaceSnapshotRestorePanel.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Camera, ChevronDown, RotateCcw } from "lucide-react"; +import type { + SnapshotRestoreRequest, + WorkspaceSnapshotManifest, + WorkspaceSnapshotPreview, + WorkspaceSnapshotPreviewEntry, +} from "@ling-stack/contracts"; +import { + confirmWorkspaceSnapshotRestore, + createWorkspaceSnapshot, + createWorkspaceSnapshotAnchor, + createWorkspaceSnapshotConfirmation, + getWorkspaceSnapshot, + getWorkspaceSnapshotRestore, + listWorkspaceSnapshots, + restoreWorkspaceSnapshot, +} from "../../../api/workspace-changes.js"; +import { useI18n } from "../../../i18n.js"; +import { + Button, + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + IconButton, + InlineFeedback, +} from "../../../shared/ui/index.js"; + +export function WorkspaceSnapshotRestorePanel({ cwd }: { readonly cwd: string }): React.JSX.Element { + const { t } = useI18n(); + const [snapshots, setSnapshots] = useState([]); + const [enabled, setEnabled] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(); + const [selected, setSelected] = useState(); + const [dialogOpen, setDialogOpen] = useState(false); + const [restore, setRestore] = useState(); + const [expanded, setExpanded] = useState(false); + const returnFocusRef = useRef(null); + + async function refresh(): Promise { + const result = await listWorkspaceSnapshots(cwd); + setSnapshots(result.snapshots); + setEnabled(result.restoreUiEnabled === true); + } + + useEffect(() => { + let cancelled = false; + void listWorkspaceSnapshots(cwd).then((result) => { + if (!cancelled) { + setSnapshots(result.snapshots); + setEnabled(result.restoreUiEnabled === true); + } + }).catch(() => undefined); + return () => { cancelled = true; }; + }, [cwd]); + + async function createSnapshot(): Promise { + if (busy) return; + setBusy(true); setMessage(undefined); + try { + const result = await createWorkspaceSnapshot(cwd); + await refresh(); + setMessage(result.manifest.state === "committed" ? t("Restore point created") : t("Restore point unavailable")); + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)); + } finally { setBusy(false); } + } + + async function openPreview(snapshot: WorkspaceSnapshotManifest, trigger: HTMLButtonElement): Promise { + returnFocusRef.current = trigger; + setBusy(true); setMessage(undefined); + try { + setSelected(await getWorkspaceSnapshot(cwd, snapshot.id)); + setDialogOpen(true); + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)); + } finally { setBusy(false); } + } + + async function confirmRestore(): Promise { + if (!selected || busy) return; + setBusy(true); setMessage(undefined); + try { + // The anchor and confirmation are durable records. The restore RPC is + // only called after both records have been created and explicitly confirmed. + const anchor = await createWorkspaceSnapshotAnchor(cwd, selected.manifest.id); + const pending = await createWorkspaceSnapshotConfirmation(cwd, selected.manifest.id, anchor.id); + const confirmation = await confirmWorkspaceSnapshotRestore(cwd, pending.id); + if (confirmation.state !== "confirmed") throw new Error(t("Restore confirmation was not accepted")); + const idempotencyKey = `web-snapshot-restore-${globalThis.crypto?.randomUUID?.() ?? Date.now().toString(36)}`; + const request = await restoreWorkspaceSnapshot(cwd, selected.manifest.id, { + idempotencyKey, + anchorId: anchor.id, + confirmationId: confirmation.id, + expectedWorkspaceFingerprint: anchor.expectedWorkspaceFingerprint, + expectedProvenanceWatermark: anchor.expectedProvenanceWatermark, + }); + setRestore(request); + if (request.state === "committed") { + setMessage(t("Workspace restored; conversation history is unchanged")); + setDialogOpen(false); + await refresh(); + } else { + await pollRestore(request.id); + } + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)); + } finally { setBusy(false); } + } + + async function pollRestore(requestId: string): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + const current = await getWorkspaceSnapshotRestore(cwd, requestId); + setRestore(current); + if (["committed", "failed", "conflict", "expired", "recovery_required"].includes(current.state)) { + setMessage(current.state === "committed" ? t("Workspace restored; conversation history is unchanged") : current.lastError ?? t("Restore requires review")); + if (current.state === "committed") { setDialogOpen(false); await refresh(); } + return; + } + await new Promise((resolve) => globalThis.setTimeout(resolve, 250)); + } + setMessage(t("Restore is still running; reopen this panel to inspect durable progress")); + } + + const previewOperations = useMemo(() => groupOperations(selected?.operations ?? []), [selected]); + + if (!enabled) return <>; + return ( +
+
+ {t("Restore points")}{t("Workspace files only; conversation history is unchanged")} + void createSnapshot()} size="sm" /> +
+ {message ? {message} : null} + {restore && restore.state !== "committed" ?
{t("Restore progress")}{restore.state}{restore.journalEntryCount} {t("journal entries")}
: null} + {snapshots.length > 0 ? ( +
    + {snapshots.slice(0, 8).map((snapshot) => ( +
  • + {new Date(snapshot.createdAt).toLocaleString()}{snapshot.managedEntryCount ?? snapshot.entryCount} {t("managed entries")} · {snapshot.excludedEntryCount ?? 0} {t("excluded")}{snapshot.expiresAt ? ` · ${t("expires")} ${new Date(snapshot.expiresAt).toLocaleDateString()}` : ""} + +
  • + ))} +
+ ) : {t("No restore points")}} + + {selected ? ( + { if (!busy) setDialogOpen(open); }}> + + + +
{t("Captured")} {new Date(selected.manifest.createdAt).toLocaleString()}{selected.manifest.captureKind}{selected.manifest.managedEntryCount ?? selected.manifest.entryCount} {t("managed paths")}{selected.manifest.excludedEntryCount ?? 0} {t("excluded")}
+
{t("Current fingerprint")}{selected.fingerprintStatus === "matching" ? t("matches snapshot") : t("has changed; restore will compare again before writing")}
+ {(["create", "update", "delete", "unchanged"] as const).map((operation) => previewOperations[operation]?.length ? setExpanded((value) => !value)} /> : null)} + {selected.entries.some((entry) => !entry.managed) ?
{t("Excluded paths are never changed")}{selected.entries.filter((entry) => !entry.managed).map((entry) => `${entry.relativePath} (${entry.exclusionReason ?? "unsupported"})`).join(" · ")}
: null} +
+ + + + +
+
+ ) : null} +
+ ); +} + +function groupOperations(entries: readonly WorkspaceSnapshotPreviewEntry[]): Record { + return entries.reduce>((groups, entry) => { + (groups[entry.operation] ??= []).push(entry); + return groups; + }, {}); +} + +function PreviewGroup({ operation, entries, expanded, onToggle }: { readonly operation: "create" | "update" | "delete" | "unchanged"; readonly entries: readonly WorkspaceSnapshotPreviewEntry[]; readonly expanded: boolean; readonly onToggle: () => void }): React.JSX.Element { + const { t } = useI18n(); + const labels: Record = { create: t("Create"), update: t("Update"), delete: t("Delete"), unchanged: t("Unchanged") }; + return
{expanded ?
    {entries.slice(0, 80).map((entry) =>
  • {entry.relativePath}
  • )}
: null}
; +} diff --git a/apps/web/src/styles/channel-right-dock/workspace-changes.css b/apps/web/src/styles/channel-right-dock/workspace-changes.css index 6bd92871a..7a6e9b231 100644 --- a/apps/web/src/styles/channel-right-dock/workspace-changes.css +++ b/apps/web/src/styles/channel-right-dock/workspace-changes.css @@ -250,6 +250,49 @@ white-space: nowrap; } +.workspace-snapshot-restore-panel { + display: grid; + gap: var(--ling-space-3); + padding: var(--ling-space-4); + border: 1px solid var(--ling-border-default); + border-radius: var(--ling-radius-md); + background: var(--ling-bg-panel); +} +.workspace-snapshot-restore-head, +.workspace-snapshot-restore-list li, +.workspace-snapshot-restore-meta, +.workspace-snapshot-restore-progress, +.workspace-snapshot-fingerprint { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ling-space-3); +} +.workspace-snapshot-restore-head span, +.workspace-snapshot-restore-copy { display: grid; gap: var(--ling-space-1); min-width: 0; } +.workspace-snapshot-restore-head strong, +.workspace-snapshot-restore-copy strong { color: var(--ling-text-strong); font-size: var(--ling-font-xs); } +.workspace-snapshot-restore-head small, +.workspace-snapshot-restore-copy small, +.workspace-snapshot-restore-empty, +.workspace-snapshot-excluded small { color: var(--ling-text-muted); font-size: 10px; } +.workspace-snapshot-restore-list { display: grid; gap: var(--ling-space-2); margin: 0; padding: 0; list-style: none; } +.workspace-snapshot-restore-list li { padding-block: var(--ling-space-2); border-top: 1px solid var(--ling-border-default); } +.workspace-snapshot-restore-progress { padding: var(--ling-space-2); border-radius: var(--ling-radius-sm); background: var(--ling-bg-subtle); font-size: 11px; } +.workspace-snapshot-restore-progress span { color: var(--ling-status-info); font-family: ui-monospace, monospace; } +.workspace-snapshot-restore-dialog .dialog-body { display: grid; gap: var(--ling-space-4); } +.workspace-snapshot-restore-meta { flex-wrap: wrap; justify-content: flex-start; color: var(--ling-text-muted); font-size: 11px; } +.workspace-snapshot-fingerprint { justify-content: flex-start; padding: var(--ling-space-3); border-radius: var(--ling-radius-sm); font-size: 11px; } +.workspace-snapshot-fingerprint.matching { color: var(--ling-status-success); background: color-mix(in srgb, var(--ling-status-success) 8%, transparent); } +.workspace-snapshot-fingerprint.changed { color: var(--ling-status-warning); background: color-mix(in srgb, var(--ling-status-warning) 8%, transparent); } +.workspace-snapshot-preview-group { border: 1px solid var(--ling-border-default); border-radius: var(--ling-radius-sm); } +.workspace-snapshot-preview-group > button { display: flex; width: 100%; align-items: center; justify-content: space-between; padding: var(--ling-space-3); border: 0; color: var(--ling-text-strong); background: transparent; cursor: pointer; text-align: left; } +.workspace-snapshot-preview-group > button svg { transition: transform 120ms ease; } +.workspace-snapshot-preview-group > button svg.expanded { transform: rotate(180deg); } +.workspace-snapshot-preview-group ul { max-height: 180px; margin: 0; padding: 0 var(--ling-space-4) var(--ling-space-3); overflow: auto; list-style: none; color: var(--ling-text-muted); font: 10px ui-monospace, monospace; } +.workspace-snapshot-preview-group li { padding-block: 2px; } +.workspace-snapshot-excluded { display: grid; gap: var(--ling-space-1); padding: var(--ling-space-3); border: 1px solid color-mix(in srgb, var(--ling-status-warning) 30%, var(--ling-border-default)); border-radius: var(--ling-radius-sm); } + @media (max-width: 760px) { .workspace-change-file-badges > small { display: none; diff --git a/docs/design/post-compaction-snapshot-restore.md b/docs/design/post-compaction-snapshot-restore.md new file mode 100644 index 000000000..b784d4815 --- /dev/null +++ b/docs/design/post-compaction-snapshot-restore.md @@ -0,0 +1,709 @@ +# Post-Compaction Snapshot Restore P2 + +**Status:** Implemented (P2), feature-flagged and validated +**Date:** 2026-08-26 +**Scope:** P2 design and implementation contract. Runtime, contract, migration, API, and UI paths are implemented behind the workspace snapshot feature flags; automatic compaction-boundary capture remains explicitly armed rather than implicit. + +## 1. Decision summary + +Snapshot restore is an explicit, broader workspace mutation. It is not a hidden fallback for selective inverse patch recovery and it is not a conversation-history rollback. + +The implementation must follow these rules: + +1. The restore target is an immutable content-addressed snapshot plus a durable restore anchor. A condenser summary, harness transcript, Git diff, or model inference can never select or authorize a snapshot. +2. A snapshot restore is offered only when the snapshot was captured as the baseline for the target boundary and the current workspace still matches the recorded post-boundary fingerprint and provenance watermark. +3. The user chooses Restore snapshot explicitly after seeing the managed path set, additions, deletions, and exclusions. Selective inverse patch remains the default Turn undo action. +4. The daemon acquires a workspace recovery lease and a durable fencing epoch. New Runs, queued Runs, approval dispatch, and other workspace mutations must observe the fence and wait or fail with a typed conflict. An in-process lock alone is insufficient. +5. The restore engine uses a staged, journaled per-entry plan. It never runs git reset --hard, replaces the workspace directory, recursively deletes an unbounded path, or overwrites .ling-stack control state. +6. All snapshot bytes and restore intents are durable before any workspace byte changes. After a crash, all-before and all-after states are idempotently recoverable; mixed or externally changed state is a visible conflict and is never silently repaired. +7. Snapshots are additive and content-addressed. Manifests are immutable, blobs are deduplicated by SHA-256, and garbage collection never removes a blob referenced by a live snapshot, restore journal, audit record, or rollback preimage. + +This gives Ling a safe opt-in path for cases that selective inverse patches cannot cover, without turning a compaction boundary into permission to erase unrelated user work. + +## 2. Relationship to the P1 design + +This document extends docs/design/post-compaction-undo.md from commit e0092f7e. The P1 design remains normative for: + +- LingThread -> LingTurn -> LingTurnRun -> RunSession lineage; +- canonical timeline and context projection ownership; +- durable WorkspaceMutationRecord and forward/inverse PatchPlan provenance; +- selective inverse undo/reapply, CAS, policy checks, symlink checks, and idempotency; +- compaction checkpoint installation and checkpoint_failed semantics; +- the rule that harness transcript and summary text are evidence only. + +P2 adds a snapshot store and a snapshot-specific restore coordinator. It does not add a second conversation timeline, Item authority, or harness rollback protocol. + +### What already exists + +| Existing flow | P2 reuse | P2 change | +| --- | --- | --- | +| RunSession, LingTurnRun, terminal Run lookup | Resolve target lineage and active writers | Add snapshot anchor references, not a second owner | +| WorkspaceMutationRecord and patch artifacts | Build mutation watermark and target fence | Record snapshot eligibility and post-boundary fingerprint | +| resource_leases workspace/bound-path leases | Serialize recovery with active workspace work | Add a system-owned restore lease and fencing check at Run admission | +| durable Run events and SQLite migrations | Audit and crash evidence | Add snapshot/restore tables and typed restore events | +| existing workspace path/symlink policy helpers | Validate every managed path | Extend validation to directories, binaries, links, and exclusions | +| existing approval/request surface | Explicit local-user confirmation | Add workspace_restore approval payload or equivalent durable confirmation | +| existing workspace change UI and API client | Present affected paths and conflict state | Add snapshot list, preview, confirmation, and restore status | + +P2 must not rebuild patch provenance or infer changes from the current Git working tree. + +## 3. Scope and non-goals + +### In scope + +- Full managed-workspace snapshots made of an immutable manifest and content-addressed blobs. +- Snapshot capture at an explicitly armed restore boundary. +- A durable restore anchor that binds a baseline snapshot to a target Turn/Run boundary, current post-boundary fingerprint, and provenance watermark. +- Explicit restore request, durable confirmation, workspace fencing, staged commit, post-restore verification, and restart reconciliation. +- Regular files, text files, binary files, directories, and safe internal symlinks. +- Snapshot quotas, retention, mark-and-sweep blob garbage collection, and observability. +- HTTP/API/UI contracts needed to inspect and explicitly invoke restore. +- Additive migration and feature-flagged rollout. + +### Not in scope + +- git reset, git checkout, branch switching, index restoration, or commit rollback. +- Restoring .git, .ling-stack, daemon databases, runtime logs, sockets, devices, FIFOs, mounts, extended ACLs, resource forks, or ownership metadata. +- Reconstructing a missing snapshot from a condenser summary, harness transcript, current Git diff, or an inferred file list. +- Automatic snapshot restore after selective inverse CAS failure. +- A force-restore or ignore-conflict switch. +- Cross-machine or remote object storage in the first implementation. +- Workspace knowledge provider implementation or post-compaction undo policy changes. + +If a requested workspace contains unsupported entries, the snapshot records its exact coverage and exclusions. Restore never mutates excluded paths; the UI must not label such a restore as an unqualified whole-workspace restore. + +## 4. Product semantics + +### 4.1 Snapshot baseline versus Turn undo + +The most important semantic distinction is the direction of time: + + baseline snapshot S (before target boundary) + | + +--> target Turn / Runs apply mutations + | + post-boundary workspace fingerprint Fpost + | + +--> explicit Restore snapshot(S) + restores S's managed paths only + +A snapshot captured after a Turn is not a baseline for undoing that Turn. It can be used as an audit or future resume anchor, but it must not be offered as a Turn restore target. + +The first P2 implementation therefore exposes two separate actions: + +- Selective undo Turn: inverse PatchPlans, narrow touched-path scope, default action. +- Restore snapshot: snapshot ID plus restore anchor, potentially broader scope, explicit user action and confirmation. + +When no pre-boundary snapshot exists, snapshot restore is unavailable. It does not get silently captured at request time, because a snapshot taken after the user asks to undo is too late to represent the required baseline. + +### 4.2 Capture trigger + +Full capture is expensive and must be armed. A capture may be requested by: + +1. an explicit user Create restore point action before a risky Turn; +2. a workspace policy that arms capture before compaction for selected workspaces; or +3. a recovery-aware scheduler that has already obtained the user's durable consent for the boundary. + +A provider-only compaction response never arms capture. Capture is complete only after the manifest and every referenced blob have been committed and verified. If capture fails, the Turn may continue according to its normal policy, but no restore anchor is exposed. + +### 4.3 Restore choice rules + + Turn undo requested + | + +-- selective inverse eligible? -- yes --> offer selective undo (default) + | + +-- matching pre-boundary snapshot anchor + exact CAS? -- yes --> offer explicit restore + | + +-- otherwise --> snapshot restore unavailable; explain why + +If selective undo is blocked by a touched-path conflict, the UI may show that a snapshot restore exists, but the user must make a new explicit choice. The daemon must revalidate the snapshot anchor; it must not promote the broader operation automatically. + +## 5. Authority and invariants + +The authority graph remains: + + Ling timeline + LingThread -> LingTurn -> LingTurnRun -> RunSession + | + +--> RunEvent / Approval / Artifact + +--> WorkspaceMutationRecord + +--> RestoreAnchor -> SnapshotManifest -> SnapshotBlob + + Condenser summary --------------------> reconstruction evidence only + Harness transcript -------------------> execution evidence only + Git status/diff ----------------------> current-state diagnostic only + +Required invariants: + +1. A snapshot belongs to one canonical workspaceId and one canonical root. cwd may be a descendant execution directory, but the manifest root is stable and realpath-checked. +2. A restore anchor references a snapshot captured before its target watermark and stores the exact expected post-boundary workspace fingerprint. The anchor is invalid if either ordering or fingerprint evidence is missing. +3. Manifest, entry, and blob hashes are immutable. A changed blob is a storage integrity failure, not a best-effort decode. +4. Restore touches only paths in the manifest managed set plus explicit deletions of post-boundary managed entries. Excluded and control paths are never touched. +5. Every restore request has an idempotency key, durable state, fencing epoch, and complete before/after intent before the first workspace write. +6. A successful restore is true only after every managed entry matches the manifest and the resulting managed-workspace fingerprint equals the snapshot fingerprint. +7. A conflict or mixed crash state produces no success response and no hidden retry. +8. Approval is Ling-owned and durable. The harness is not blocked on a synchronous restore RPC and cannot authorize a restore by itself. + +## 6. Durable data model + +The following types are the implementation contract. All readonly collections are stored in canonical order and validated on read. + +### 6.1 Snapshot manifest + + type WorkspaceSnapshotCaptureKind = "user_restore_point" | "compaction_boundary"; + type WorkspaceSnapshotEntryKind = "file" | "directory" | "symlink"; + + interface WorkspaceSnapshotManifest { + readonly id: string; + readonly schemaVersion: 1; + readonly workspaceId: string; + readonly canonicalRoot: string; + readonly captureCwd: string; + readonly captureKind: WorkspaceSnapshotCaptureKind; + readonly sourceTurnId?: string; + readonly sourceRunId?: string; + readonly sourceCheckpointId?: string; + readonly provenanceWatermark: number; + readonly exclusionPolicySha256: string; + readonly managedEntryCount: number; + readonly excludedEntryCount: number; + readonly managedBytes: number; + readonly workspaceFingerprintSha256: string; + readonly manifestSha256: string; + readonly createdAt: string; + readonly expiresAt?: string; + readonly state: "committed" | "expired" | "corrupt"; + } + +manifestSha256 hashes canonical JSON metadata plus the sorted entry digest list. It is not a hash of pretty-printed or insertion-order-dependent JSON. + +### 6.2 Snapshot entries + + interface WorkspaceSnapshotEntry { + readonly snapshotId: string; + readonly relativePath: string; + readonly kind: WorkspaceSnapshotEntryKind; + readonly modeBits: number; + readonly sizeBytes: number; + readonly contentSha256?: string; + readonly blobId?: string; + readonly symlinkTarget?: string; + readonly managed: boolean; + readonly exclusionReason?: + | "control_path" + | "vcs_metadata" + | "secret_like_path" + | "too_large" + | "unsupported_type" + | "external_symlink" + | "case_collision"; + } + +Rules: + +- file entries reference one raw byte blob, including binary and invalid UTF-8 files. +- directory entries have no blob and preserve empty directories and mode bits. +- symlink entries store link text, never dereferenced target bytes. Only relative links whose resolved target stays within the canonical root are managed. +- managed false entries are recorded for coverage/audit but cannot be mutated by restore. +- modeBits preserves executable/read-only bits only. Ownership, ACLs, xattrs, and mtimes are excluded from the fingerprint and restore contract. + +### 6.3 Content-addressed blob + + interface WorkspaceSnapshotBlob { + readonly id: string; + readonly sha256: string; + readonly sizeBytes: number; + readonly contentType: "raw_bytes"; + readonly storageKey: string; + readonly createdAt: string; + readonly lastReferencedAt: string; + } + +The blob path is derived from a validated digest and never from a user path. A blob is written to a temporary file, fsynced, atomically renamed into the daemon runtime object store, then referenced by a committed manifest. The SQLite row is not visible as committed until the object exists and hashes correctly. The manifest payload and entry rows are immutable; expiry/corruption is lifecycle metadata on the snapshot reference and does not rewrite the content-addressed payload. + +### 6.4 Restore anchor + + interface WorkspaceSnapshotRestoreAnchor { + readonly id: string; + readonly workspaceId: string; + readonly canonicalRoot: string; + readonly snapshotId: string; + readonly targetThreadId?: string; + readonly targetTurnId?: string; + readonly targetRunIds: readonly string[]; + readonly baselineWatermark: number; + readonly postBoundaryWatermark: number; + readonly baselineFingerprintSha256: string; + readonly expectedPostBoundaryFingerprintSha256: string; + readonly managedPathCount: number; + readonly createdAt: string; + readonly state: "eligible" | "consumed" | "expired" | "invalid"; + } + +An anchor tied to a Turn is created only after the target boundary is terminal and its current managed workspace fingerprint has been captured. `threadId`, `targetTurnId`, and `checkpointId` are required together for that path. A user-selected restore point uses the same durable anchor table with those lineage fields omitted; it is valid for explicit workspace restore but is never shown as Turn undo. `postBoundaryWatermark` must be greater than or equal to `baselineWatermark` whenever a Turn lineage exists. + +### 6.5 Restore request and journal + + type SnapshotRestoreState = + | "requested" + | "validating" + | "fenced" + | "staged" + | "committing" + | "verifying" + | "committed" + | "conflict" + | "recovery_required" + | "failed" + | "expired"; + + interface SnapshotRestoreRequest { + readonly id: string; + readonly idempotencyKey: string; + readonly workspaceId: string; + readonly canonicalRoot: string; + readonly snapshotId: string; + readonly anchorId: string; + readonly targetTurnId?: string; + readonly confirmationId: string; + readonly expectedCurrentFingerprintSha256: string; + readonly expectedPostBoundaryWatermark: number; + readonly fencingEpoch: number; + readonly state: SnapshotRestoreState; + readonly rollbackManifestId: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly failureCode?: string; + readonly failureMessage?: string; + } + + interface SnapshotRestoreJournalEntry { + readonly requestId: string; + readonly relativePath: string; + readonly operation: "create" | "update" | "delete" | "mkdir" | "rmdir" | "symlink"; + readonly expectedBeforeSha256: string; + readonly targetAfterSha256: string; + readonly stagedBlobId?: string; + readonly status: "planned" | "staged" | "applied" | "verified"; + readonly appliedAt?: string; + } + +The rollback manifest is a content-addressed capture of every managed pre-restore entry. It is retained until the request reaches committed and the retention grace period has elapsed. This makes a controlled rollback possible after a write failure; the original snapshot alone is not a preimage of the current workspace. + +## 7. Managed workspace and fingerprint + +### 7.1 Root and exclusions + +The canonical root is the realpath of the workspace binding known to the daemon. The manifest walks that root without following directory symlinks. + +Always excluded: + +- .ling-stack and all descendants; +- .git directories, .git files, and Git administrative metadata; +- sockets, devices, FIFOs, mount points, and entries that disappear during capture; +- paths rejected by the current workspace write policy; +- secret-like paths such as .env, credential/key stores, and configured secret patterns; +- files above the configured single-file limit; +- symlinks resolving outside the canonical root and Windows junctions/reparse points. + +The exclusion policy itself is hashed into the manifest. A later restore must use the same policy hash and must never infer a newly included path from a different policy version. + +### 7.2 Canonical fingerprint + +The managed workspace fingerprint is: + + SHA256( + for entries sorted by UTF-8 byte order of normalized relativePath: + relativePath + NUL + kind + NUL + modeBits(decimal) + NUL + sizeBytes(decimal) + NUL + contentSha256-or-symlinkTarget + NUL + ) + +Path normalization uses /, rejects .., preserves case, and applies the platform's canonical Unicode form before hashing. Case-insensitive collisions are captured as case_collision exclusions and make a full restore anchor ineligible until the collision is resolved. Mtime, owner, inode, directory enumeration order, and Git index state do not participate in the fingerprint. Journal hashes use the same canonical representation for a missing path, directory, file, and symlink, so a file-to-directory or directory-to-link replacement cannot accidentally pass a byte-only check. + +Two fingerprints are required: + +- baseline fingerprint: manifest state before the target boundary; +- expected current fingerprint: state observed after the target boundary and stored in the restore anchor. + +Restore requires an exact match to the expected current fingerprint. It does not compare only touched files because snapshot restore may delete or replace any managed path after the baseline. + +## 8. Capture lifecycle + + capture request + -> validate root, policy, quota, exclusion policy + -> acquire capture lease / boundary fence + -> walk and stream-hash entries + -> stage and verify blobs + -> write manifest + entries in one SQLite transaction + -> commit manifest and release capture lease + -> after target boundary: seal RestoreAnchor with Fpost + +Capture details: + +1. The scheduler pauses new workspace-writing Runs for the capture window. An already active Run makes capture return workspace_busy; capture must not race an untracked write. +2. The walker uses bounded concurrency and streams each file in chunks. It never loads the full workspace or all blobs into memory. +3. A file that changes during hashing is retried once. If it changes again, the capture is snapshot_unverified and no manifest is exposed. +4. Every staged blob is re-hashed from the stored bytes before the manifest transaction. +5. The SQLite transaction inserts the manifest and entries only when every managed blob is present. A crash before commit leaves unreferenced staging objects for GC. +6. The capture result returns a snapshot ID only after the transaction and object fsync succeed. A returned ID is immutable. +7. The post-boundary anchor captures Fpost and the mutation watermark after all target Runs are terminal. If a target Run is still active or effect state is unknown, no anchor is sealed. + +The capture lease is not the restore fence. The restore request obtains a fresh system-owned workspace lease and epoch after confirmation. + +## 9. Restore lifecycle and atomicity boundary + +### 9.1 Validation and fencing + + requested + -> validating + - load manifest, entries, blobs, anchor, confirmation + - verify hashes, root, quota, watermark, expected Fpost + -> fenced + - acquire workspace resource lease + - increment durable fencing epoch + - reject new writing Runs and approval dispatch + -> staged + - capture rollback preimage + - build complete entry plan + - stage all file bytes + -> committing -> verifying -> committed + +Validation must fail before any workspace write when: + +- the snapshot, anchor, blob, or rollback preimage is missing or hash-invalid; +- the current full managed fingerprint differs from the anchor's expected post-boundary fingerprint; +- the current provenance watermark is after the anchor's post-boundary watermark; +- an active Run, pending approval, or another restore/recovery owns the workspace; +- the original Run write scope or current policy rejects a managed path; +- a path is internal, traversal-derived, symlink-escaping, a type collision, or unsupported; +- the confirmation is absent, expired, for another snapshot, or already consumed. + +The fencing epoch is checked by Run admission, queued-run promotion, approval dispatch, and workspace mutation entry points. A stale owner cannot start a new write after the fence is active. Existing active Runs are not killed implicitly; the restore request waits or returns workspace_busy until they are terminal and their approvals are resolved. + +### 9.2 Plan construction + +The plan is computed from the managed current entry set and snapshot entry set: + +- snapshot file absent now: create; +- both present with different digest or mode: update; +- snapshot directory absent now: mkdir; +- snapshot symlink absent/different: remove the current managed entry, then symlink; +- current managed entry absent from snapshot: delete or rmdir; +- unchanged entry: no operation. + +Deletes are ordered deepest-first. Directory creation is shallowest-first. File and link writes occur only after all type collisions have been validated. Excluded/control paths are not candidates for deletion. + +### 9.3 Staging and commit + +Staging is outside the workspace under daemon-owned runtime storage. Each planned file is written to a request-specific temporary object, fsynced, and verified against its target digest. The rollback preimage is captured before the first commit operation. + +There is no cross-platform filesystem transaction. The durable journal is the atomicity boundary: + +1. persist requested/validating and the complete entry plan; +2. persist rollback preimage and mark staged; +3. commit one entry at a time, recording applied after the expected-before check and atomic file replacement/validated delete; +4. re-scan and verify the complete target fingerprint; +5. append snapshot_restore_committed, mark the anchor consumed, and release the fence. + +Before each entry write, the daemon re-checks the current entry digest against expectedBeforeSha256. A mismatch aborts with conflict; it does not overwrite the external change. File replacement uses an atomic temporary-file rename in the same parent directory. Deletion requires the path still to match its expected digest/type and removes only that exact managed path. + +### 9.4 Crash reconciliation + + on daemon restart + -> find restore requests in fenced/staged/committing/verifying + -> reacquire no workspace lease yet; inspect journal and current fingerprint + -> all entries before + expected watermark unchanged => retryable + -> all entries after + target fingerprint verified => append committed + -> mixed entries, external digest change, or missing blob => recovery_required + +recovery_required is a durable visible state. The reconciler may perform an explicit rollback to the captured rollback manifest only if every currently changed entry still matches this request's target digest and the rollback manifest verifies. If any external change is detected, it stops and asks the user to review. It never guesses which subset to restore. + +The same idempotency key can resume a request classified as retryable. A request already classified as committed returns its stored result without a second mutation. A mixed request cannot be retried under the same key until a separate, explicit recovery decision is made. + +## 10. Files, binaries, links, secrets, and large workspaces + +| Entry | Capture | Restore | User-visible result | +| --- | --- | --- | --- | +| UTF-8 text | raw bytes plus SHA-256 | atomic byte replacement | managed | +| binary | raw bytes plus SHA-256 | atomic byte replacement | managed, no text diff required | +| directory | mode and path | mkdir/rmdir with ordering | managed | +| internal relative symlink | link text, target containment check | recreate link without dereference | managed | +| external symlink/junction | record exclusion only | never follow or replace | excluded with reason | +| secret-like path | record exclusion only; no bytes persisted | never mutate | excluded and highlighted | +| file over size limit | record exclusion only | never mutate | excluded; Turn anchor ineligible | +| socket/device/FIFO/mount | unsupported | never mutate | snapshot unavailable for full restore | +| .git / .ling-stack | excluded | never mutate | outside snapshot scope | + +The first implementation must fail closed for a snapshot intended to be shown as a Turn restore if any path in the target boundary is excluded or unsupported. A user-created restore point may still be listed with partial coverage, but the confirmation surface must show the exclusion count and exact reasons. + +Recommended initial limits, overridable per workspace but never above daemon hard limits: + +- 200,000 manifest entries; +- 2 GiB managed bytes per snapshot; +- 256 MiB per file; +- 8 committed snapshots per workspace; +- 30-day default retention; +- 24-hour grace period for unreferenced blobs and rollback preimages. + +Limit failures are quota_exceeded; partial manifests are never exposed. + +## 11. Storage, retention, and garbage collection + +The additive migration introduces: + +- workspace_snapshot_manifests; +- workspace_snapshot_entries; +- workspace_snapshot_blobs; +- workspace_snapshot_anchors; +- snapshot_restore_requests; +- snapshot_restore_journal_entries; +- workspace_fencing_epochs (or an equivalent durable epoch in the workspace settings row). + +Blob bytes live in a daemon-owned content-addressed object directory, not in SQLite and not under the workspace root. Every row is scoped by workspace_id; foreign keys and unique indexes enforce one manifest ID, one (workspace_id, sha256) blob, and one idempotency key per restore request. + +GC is mark-and-sweep: + +1. mark blobs referenced by committed/non-expired manifests, active anchors, restore journals, rollback manifests, and the audit retention window; +2. sweep only unmarked objects older than the grace period; +3. delete the SQLite blob row only after the object is gone and the deletion is durable. + +Quota enforcement may run GC for expired unreferenced data before rejecting a new capture, but it cannot delete a live anchor or a rollback preimage needed by an in-flight request. Restore requests hold a reference until their terminal retention expires. + +## 12. API and UI contract + +### 12.1 API + + POST /workspace/snapshots + GET /workspace/snapshots?cwd=... + GET /workspace/snapshots/:snapshotId?cwd=... + POST /workspace/snapshots/:snapshotId/anchors?cwd=... + POST /workspace/snapshots/:snapshotId/confirmations?cwd=... + POST /workspace/snapshot-confirmations/:confirmationId/confirm?cwd=... + POST /workspace/snapshots/:snapshotId/restore?cwd=... + GET /workspace/snapshot-restores/:requestId?cwd=... + + GET /turns/:turnId/workspace-changes?cwd=... + +The restore endpoint accepts: + + interface CreateSnapshotRestoreRequest { + readonly idempotencyKey: string; + readonly anchorId: string; + readonly confirmationId: string; + readonly expectedCurrentFingerprintSha256: string; + readonly expectedPostBoundaryWatermark: number; + } + +The daemon ignores client-supplied paths, blob IDs, hashes, or operation lists. It reloads the immutable manifest and computes the plan itself. + +Responses: + +- 202 while a durable request is queued, staging, or committing; +- 200 for an already committed idempotent request; +- 403 policy/path/symlink denial; +- 404 unknown workspace, snapshot, anchor, or request; +- 409 active work, stale fence, fingerprint mismatch, watermark drift, or mixed state; +- 410 expired/consumed anchor; +- 413 quota/size limit; +- 422 malformed or unsupported snapshot. + +The Turn workspace-changes response may include snapshotRestoreOptions, but only for anchors that pass read-only validation. It must include scope: managed_workspace, managed/excluded counts, target snapshot ID, expected fingerprint status, and a human-safe blocked reason. It must not return an enabled restore action based on summary text. + +### 12.2 Confirmation and UI + +Restore is a two-step durable local-user action: + +1. UI loads the manifest preview and asks Ling to create an approval/confirmation record with action type workspace_restore. +2. The user confirms the exact snapshot, root, managed path count, additions, deletions, exclusions, and expected current fingerprint. Only then can the restore request execute. + +The UI must show: + +- This changes workspace files, not conversation history. +- snapshot capture time, source Turn/Run/checkpoint, and retention expiry; +- managed paths grouped as create/update/delete and excluded paths with reasons; +- current fingerprint: matching, changed, unavailable, or not yet checked; +- Restore snapshot, Review conflict, or disabled state with a concrete reason; +- progress from staged to verifying and a link to the durable restore audit record. + +No synchronous harness approval wait is involved. If a harness Run is active, the restore is blocked by the Ling daemon fence until the Run reaches a terminal state. + +## 13. Security and policy + +- Resolve and validate the canonical root and every relative path before capture, staging, delete, symlink creation, or download. +- Reject absolute paths, traversal, control paths, type collisions, and symlink escapes. +- Re-check both the Run immutable write-scope snapshot and the current workspace policy. Policy changes can narrow authority, never expand it. +- Store no raw provider transcript, command environment, secret value, or secret-like file bytes in events or SQLite JSON. Blob access is daemon-local and workspace-scoped. +- Do not expose storage keys as filesystem URLs. Snapshot previews use bounded metadata and optional redacted content, never an unrestricted local-file URL. +- Enforce local Origin/workspace binding rules already used by workspace file endpoints. +- Treat a corrupt manifest/blob, missing rollback preimage, or stale epoch as a hard failure. Never repair by trusting the current directory listing. + +## 14. Observability and audit + +Every lifecycle transition records: + +- request ID, idempotency key hash, workspace ID, root, snapshot/anchor IDs; +- actor (local-user, daemon, system-recovery), fencing epoch, and state transition; +- baseline/current/target fingerprints and provenance watermarks; +- managed/excluded counts, bytes, duration, and bounded failure code; +- source Turn/Run/checkpoint refs where present. + +Suggested metrics: + +- workspace_snapshot_capture_total{result}; +- workspace_snapshot_capture_bytes; +- workspace_snapshot_restore_total{result}; +- workspace_snapshot_restore_conflict_total{reason}; +- workspace_snapshot_restore_duration_ms; +- workspace_snapshot_gc_bytes_total; +- workspace_fence_blocked_run_total. + +Never log file contents, link targets that leave the root, command lines with secrets, or full path lists at info level. Debug audit can expose relative paths subject to existing local-daemon access controls. + +## 15. Failure-mode review + +| Failure mode | Required test | Error handling | User result | +| --- | --- | --- | --- | +| File changes while capture hashes | retry-then-abort test | snapshot_unverified, no manifest | capture unavailable | +| Blob write succeeds, SQLite commit crashes | reopen/GC test | unreferenced object swept | no restore option | +| Active Run starts during restore request | fence race test | admission sees epoch/lease | request waits or 409 | +| Pending approval dispatch races fence | approval race test | dispatch denied by fence | approval remains durable, restore blocked | +| Current fingerprint differs | zero-write CAS test | 409 workspace_restore_conflict | review conflict | +| Path type changes file/dir/symlink | plan/type-collision test | fail before write | no partial mutation | +| Crash before first commit | journal replay test | request retryable | no user file changed | +| Crash after partial commit | mixed-state test | recovery_required | explicit recovery required | +| External edit after partial commit | digest-fence test | no automatic rollback | review conflict | +| Final fingerprint mismatch | post-verify test | failed; fence held for review | never claim success | +| Duplicate request | idempotency/reopen test | return stored result | one mutation only | +| Expired anchor/blob | retention test | 410 or 422 | restore unavailable | +| Secret-like or unsupported path | exclusion test | fail closed for Turn anchor | exact exclusion shown | +| Quota exceeded | limit/GC test | 413; no partial manifest | capture/restore unavailable | + +There must be no path where a restore fails silently after changing files. Any failure after the first commit entry is either a verified committed result or a durable recovery_required state. + +## 16. Test and verification matrix + +### Unit tests + +- canonical path normalization and UTF-8 ordering; +- manifest canonical serialization and SHA-256 calculation; +- file, directory, binary, safe symlink, external symlink, and type-collision classification; +- managed-workspace fingerprint stability across enumeration order and mtime changes; +- exclusion-policy hash and secret-like path classification; +- restore plan ordering and no-op elimination; +- quota accounting and retention eligibility; +- state-machine transition and stale fencing epoch rejection. + +### Core/storage integration tests + +- capture, close/reopen SQLite, and reload manifest/entries/blobs; +- content-addressed deduplication across two snapshots; +- foreign-key/integrity checks for deleted/expired anchors; +- blob GC with live anchor, journal, rollback preimage, and grace-period cases; +- restore request idempotency across daemon restart; +- fencing epoch prevents queued Run creation and approval dispatch; +- manifest corruption and missing blob fail closed. + +### Daemon/API tests + +- capture busy workspace and active approval cases; +- restore create/update/delete/mkdir/rmdir/symlink operations; +- exact fingerprint and watermark CAS, policy drift, path escape, internal path, and symlink escape rejection before the first write; +- staged/committing/verifying crash injection at every journal boundary; +- all-before, all-after, mixed, and externally changed reconciliation; +- 202, 200, 403, 404, 409, 410, 413, and 422 response contracts; +- local Origin and trusted workspace binding. + +### UI/E2E tests + +- create restore point before a Turn and show it after compaction; +- preview managed/excluded paths and require explicit confirmation; +- restore success updates workspace review while leaving conversation history unchanged; +- active Run/approval displays a blocked reason and resumes after fence release; +- changed fingerprint displays Review conflict with no success toast; +- browser refresh during 202 polling returns the durable request state; +- no snapshot or excluded snapshot never exposes an enabled restore button. + +Coverage target is every branch in the capture, fence, journal, reconciliation, and API state machines, including all error paths. A single happy-path restore test is not enough. + +## 17. Performance and operational limits + +- Stream file hashing and blob writes with bounded concurrency; never buffer a full file or full workspace in memory. +- Reuse blobs by digest. The manifest stores metadata and references, not duplicate bytes. +- Build restore plans from sorted metadata and stream staged blobs. Memory usage is O(entry_count + bounded_chunk_size), not O(total_workspace_bytes). +- Hashing and capture may run in the background, but restore commit is serialized per workspace fence and reports progress through durable state. +- Capture and restore expose byte/entry counts and elapsed time for support diagnosis. +- A workspace over hard limits returns a typed unavailable result; it never degrades to a partial destructive restore. + +## 18. Implementation slices and entry criteria + +The implementation should land sequentially because all slices share the snapshot schema, fence, and journal. Keep the first code change to these modules; do not introduce a parallel blob service. + +| Slice | Modules | Entry criteria | Exit criteria | +| --- | --- | --- | --- | +| R1 contracts/storage | packages/core/src/run-types.ts, packages/contracts, SQLite migrations/store | P1 provenance and runtime paths available | reopen-safe manifests, entries, blobs, anchors, requests | +| R2 capture/fingerprint | packages/core snapshot service | quota/exclusion policy frozen | verified immutable snapshot and anchor creation | +| R3 fence/restore journal | daemon recovery feature + concurrency admission | resource lease and Run admission hooks available | staged restore, CAS, crash reconciliation | +| R4 API/UI | daemon routes, API client, web workspace review | R3 state machine stable | explicit preview/confirmation/progress/conflict UI | +| R5 rollout/ops | metrics, GC, feature flag, docs/tests | focused and reopen tests green | canary enablement with rollback switch | + +Implementation rule: do not expose the UI action until R1-R3 have zero-write conflict, all-before/all-after restart, and mixed-state tests. Do not enable capture by default until quota and GC tests pass. + +### Parallelization + +This is a sequential implementation, not a useful parallel worktree split. R2 depends on R1 storage; R3 depends on R1 and R2; R4 depends on durable R3 states; R5 depends on all preceding behavior. Splitting the shared schema or fence across worktrees would create merge conflicts and duplicate authority. + +## 19. Rollout and compatibility + +Feature flags: + +- workspace_snapshot_capture_enabled (default off); +- workspace_snapshot_restore_enabled (default off); +- workspace_snapshot_restore_ui_enabled (default off); +- workspace_snapshot_gc_enabled (default on only after storage validation). + +Rollout order: + +1. Ship additive migrations and read-only integrity/GC tooling with all restore flags off. +2. Enable capture for an internal workspace cohort; compare bytes, duration, quota rejects, and corruption count. +3. Enable restore preview without mutation; verify anchors and fingerprint conflict rates. +4. Enable explicit restore for a small cohort; monitor fence blocks, mixed states, and post-verify failures. +5. Expand gradually. Turning restore off blocks new requests but leaves committed snapshots and in-flight journals readable for reconciliation. + +Old Runs and P1 recovery rows remain valid with no snapshot reference. A missing snapshot reference means snapshot_unverified or snapshot unavailable; it does not change selective inverse behavior. Downgrades preserve unknown additive tables and blobs; no destructive migration is permitted. + +## 20. Engineering review verdict + +### Architecture + +- Resolved: Snapshot identity is explicit. A post-target snapshot cannot be used as a pre-target undo baseline. +- Resolved: A resource lock is paired with a durable fencing epoch checked by Run admission and approval dispatch, preventing new writers from entering during restore. +- Resolved: Filesystem non-atomicity is handled with staged blobs, rollback preimages, a per-entry journal, post-verify, and a visible mixed-state recovery outcome. + +### Code quality and boundaries + +- Resolved: Snapshot storage, capture/fingerprint, restore/fence, and API/UI are separate modules. Patch provenance remains the existing authority and is not duplicated. +- Resolved: All client-supplied path/operation lists are hints only; the daemon recomputes the plan from immutable manifest bytes. +- Resolved: Exclusions and unsupported types are first-class data, not silent omissions. + +### Test review + +The matrix covers capture, storage reopen, CAS, fencing, idempotency, every journal boundary, all-before/all-after/mixed reconciliation, unsupported entries, quota, API status codes, and user-visible conflict/refresh flows. No implementation slice is admitted on happy-path tests alone. + +### Performance review + +Streaming, digest deduplication, bounded concurrency, explicit hard limits, and background GC keep the feature from loading or copying an entire workspace into process memory. Restore commit remains serialized per workspace by design; this is the safety tradeoff for a broad filesystem mutation. + +### Unresolved-decision verdict + +There are no unresolved design decisions for implementation. The load-bearing choices are: + +- snapshot restore is explicit and broader than selective inverse undo; +- only a pre-boundary snapshot plus exact post-boundary CAS can produce an enabled restore; +- workspace fencing blocks new writers, and mixed crash state requires visible recovery; +- snapshots exclude control/VCS/secret-like/unsupported paths and disclose coverage; +- additive, content-addressed storage with quota/retention/GC is mandatory. + +**Frozen for implementation.** diff --git a/packages/api-client/src/endpoints/operations.ts b/packages/api-client/src/endpoints/operations.ts index af7f884df..9c93f8455 100644 --- a/packages/api-client/src/endpoints/operations.ts +++ b/packages/api-client/src/endpoints/operations.ts @@ -18,6 +18,11 @@ import type { WorkItemReleaseRequest, WorkItemRouteRequest, WorkspaceChangesResponse, + WorkspaceSnapshotManifest, + WorkspaceSnapshotEntry, + WorkspaceSnapshotPreview, + SnapshotRestoreConfirmation, + SnapshotRestoreRequest, WorkspaceFileOpenOptionsResponse, WorkspaceFileOpenRequest, WorkspaceFileOpenResponse, @@ -106,7 +111,39 @@ export const operationEndpoints = { async getWorkspaceChanges(this: LingDaemonEndpointContext, cwd: string): Promise { return this._get("/workspace/changes", { cwd }); - }, + }, + + async listWorkspaceSnapshots(this: LingDaemonEndpointContext, cwd: string): Promise<{ readonly cwd: string; readonly snapshots: readonly WorkspaceSnapshotManifest[]; readonly restoreUiEnabled?: boolean }> { + return this._get("/workspace/snapshots", { cwd }); + }, + + async getWorkspaceSnapshot(this: LingDaemonEndpointContext, cwd: string, snapshotId: string): Promise { + return this._get(`/workspace/snapshots/${encodeURIComponent(snapshotId)}`, { cwd }); + }, + + async createWorkspaceSnapshot(this: LingDaemonEndpointContext, cwd: string, input: { readonly ttlMs?: number } = {}): Promise<{ readonly manifest: WorkspaceSnapshotManifest; readonly entries: readonly WorkspaceSnapshotEntry[] }> { + return this._post("/workspace/snapshots", { cwd }, input); + }, + + async createWorkspaceSnapshotAnchor(this: LingDaemonEndpointContext, cwd: string, snapshotId: string): Promise { + return this._post(`/workspace/snapshots/${encodeURIComponent(snapshotId)}/anchors`, { cwd }, {}); + }, + + async createWorkspaceSnapshotConfirmation(this: LingDaemonEndpointContext, cwd: string, snapshotId: string, input: { readonly anchorId: string }): Promise { + return this._post(`/workspace/snapshots/${encodeURIComponent(snapshotId)}/confirmations`, { cwd }, input); + }, + + async confirmWorkspaceSnapshotRestore(this: LingDaemonEndpointContext, cwd: string, confirmationId: string): Promise { + return this._post(`/workspace/snapshot-confirmations/${encodeURIComponent(confirmationId)}/confirm`, { cwd }, { state: "confirmed" }); + }, + + async restoreWorkspaceSnapshot(this: LingDaemonEndpointContext, cwd: string, snapshotId: string, input: { readonly idempotencyKey: string; readonly anchorId: string; readonly confirmationId: string; readonly expectedWorkspaceFingerprint?: string; readonly expectedProvenanceWatermark?: string }): Promise { + return this._post(`/workspace/snapshots/${encodeURIComponent(snapshotId)}/restore`, { cwd }, input); + }, + + async getWorkspaceSnapshotRestore(this: LingDaemonEndpointContext, cwd: string, requestId: string): Promise { + return this._get(`/workspace/snapshot-restores/${encodeURIComponent(requestId)}`, { cwd }); + }, async getWorkspaceFileOpenOptions(this: LingDaemonEndpointContext, cwd: string, path: string): Promise { return this._get("/workspace-files/open-options", { cwd, path }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f52e7bd1b..3d2ffe86c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -46,6 +46,7 @@ export type * from "./settings.js"; export type * from "./workspace-directory.js"; export type * from "./workspace-terminal.js"; export * from "./workspace-changes.js"; +export * from "./workspace-snapshots.js"; export type * from "./terminal-reports.js"; export type * from "./startup.js"; export type * from "./profile.js"; diff --git a/packages/contracts/src/workspace-snapshots.ts b/packages/contracts/src/workspace-snapshots.ts new file mode 100644 index 000000000..140298366 --- /dev/null +++ b/packages/contracts/src/workspace-snapshots.ts @@ -0,0 +1,308 @@ +/** Durable snapshot/restore contracts for post-compaction workspace recovery. */ + +export const workspaceSnapshotManifestStates = ["capturing", "committed", "sealed", "snapshot_unverified", "expired", "corrupt", "deleted"] as const; +export type WorkspaceSnapshotManifestState = (typeof workspaceSnapshotManifestStates)[number]; + +export const workspaceSnapshotEntryKinds = ["file", "directory", "symlink"] as const; +export type WorkspaceSnapshotEntryKind = (typeof workspaceSnapshotEntryKinds)[number]; + +export const snapshotRestoreStates = [ + "requested", + "validating", + "fenced", + "staged", + "committing", + "verifying", + "committed", + "recovery_required", + "conflict", + "failed", + "expired", +] as const; +export type SnapshotRestoreState = (typeof snapshotRestoreStates)[number]; + +export const snapshotRestoreJournalStates = ["pending", "applied", "rolled_back", "conflict", "failed"] as const; +export type SnapshotRestoreJournalState = (typeof snapshotRestoreJournalStates)[number]; + +export interface WorkspaceSnapshotEntry { + readonly id: string; + readonly manifestId: string; + /** POSIX-style path relative to the canonical workspace root. */ + readonly relativePath: string; + readonly kind: WorkspaceSnapshotEntryKind; + readonly managed: boolean; + readonly exclusionReason?: WorkspaceSnapshotExclusionReason; + readonly symlinkTarget?: string; + readonly mode?: number; + readonly sizeBytes: number; + readonly sha256?: string; + /** Content-addressed blob digest for regular files. */ + readonly blobSha256?: string; +} + +export type WorkspaceSnapshotExclusionReason = + | "control_path" + | "vcs_metadata" + | "secret_like_path" + | "too_large" + | "unsupported_type" + | "external_symlink" + | "case_collision"; + +export interface WorkspaceSnapshotManifest { + readonly id: string; + readonly workspaceId: string; + readonly cwd: string; + readonly canonicalRoot: string; + readonly formatVersion: 1; + readonly state: WorkspaceSnapshotManifestState; + readonly fingerprint: string; + readonly provenanceWatermark: string; + readonly entryCount: number; + readonly totalBytes: number; + readonly createdAt: string; + readonly sealedAt?: string; + readonly expiresAt?: string; + readonly failureReason?: string; + /** Frozen design aliases retained for API clients during the additive rollout. */ + readonly schemaVersion?: 1; + readonly captureCwd?: string; + readonly captureKind?: "user_restore_point" | "compaction_boundary"; + readonly sourceTurnId?: string; + readonly sourceRunId?: string; + readonly sourceCheckpointId?: string; + readonly exclusionPolicySha256?: string; + readonly managedEntryCount?: number; + readonly excludedEntryCount?: number; + readonly managedBytes?: number; + readonly workspaceFingerprintSha256?: string; + readonly manifestSha256?: string; +} + +export interface CreateWorkspaceSnapshotManifestInput { + readonly id?: string; + readonly workspaceId?: string; + readonly cwd: string; + readonly canonicalRoot: string; + readonly fingerprint: string; + readonly provenanceWatermark: string; + readonly entryCount: number; + readonly totalBytes: number; + readonly createdAt: string; + readonly sealedAt?: string; + readonly expiresAt?: string; + readonly state?: WorkspaceSnapshotManifestState; + readonly failureReason?: string; + readonly schemaVersion?: 1; + readonly captureCwd?: string; + readonly captureKind?: "user_restore_point" | "compaction_boundary"; + readonly sourceTurnId?: string; + readonly sourceRunId?: string; + readonly sourceCheckpointId?: string; + readonly exclusionPolicySha256?: string; + readonly managedEntryCount?: number; + readonly excludedEntryCount?: number; + readonly managedBytes?: number; + readonly workspaceFingerprintSha256?: string; + readonly manifestSha256?: string; +} + +export interface CreateWorkspaceSnapshotEntryInput { + readonly id?: string; + readonly manifestId: string; + readonly relativePath: string; + readonly kind: WorkspaceSnapshotEntryKind; + readonly managed?: boolean; + readonly exclusionReason?: WorkspaceSnapshotExclusionReason; + readonly symlinkTarget?: string; + readonly mode?: number; + readonly sizeBytes: number; + readonly sha256?: string; + readonly blobSha256?: string; +} + +export interface ListWorkspaceSnapshotManifestsOptions { + readonly workspaceId?: string; + readonly state?: WorkspaceSnapshotManifestState; + readonly limit?: number; +} + +export interface WorkspaceSnapshotGcResult { + readonly expiredSnapshots: number; + readonly deletedBlobs: number; + readonly reclaimedBytes: number; +} + +export type WorkspaceSnapshotPreviewOperation = "create" | "update" | "delete" | "unchanged"; +export interface WorkspaceSnapshotPreviewEntry { + readonly relativePath: string; + readonly operation: WorkspaceSnapshotPreviewOperation; + readonly kind: WorkspaceSnapshotEntryKind; + readonly managed: boolean; + readonly exclusionReason?: WorkspaceSnapshotExclusionReason; + readonly sizeBytes: number; +} +export interface WorkspaceSnapshotPreview { + readonly manifest: WorkspaceSnapshotManifest; + readonly entries: readonly WorkspaceSnapshotEntry[]; + readonly operations: readonly WorkspaceSnapshotPreviewEntry[]; + readonly currentFingerprint: string; + readonly targetFingerprint: string; + readonly fingerprintStatus: "matching" | "changed" | "unavailable"; +} + +export interface WorkspaceSnapshotBlob { + readonly sha256: string; + readonly sizeBytes: number; + readonly contentType: "application/octet-stream"; + readonly createdAt: string; + readonly refCount: number; +} + +export interface WorkspaceSnapshotRestoreAnchor { + readonly id: string; + readonly workspaceId: string; + /** Optional for a user-selected restore point that is not tied to compaction. */ + readonly threadId?: string; + readonly targetTurnId?: string; + readonly checkpointId?: string; + readonly baselineSnapshotId: string; + readonly baselineFingerprint: string; + readonly baselineProvenanceWatermark: string; + readonly expectedWorkspaceFingerprint: string; + readonly expectedProvenanceWatermark: string; + readonly createdAt: string; + readonly expiresAt?: string; +} + +export interface CreateWorkspaceSnapshotRestoreAnchorInput { + readonly id?: string; + readonly workspaceId?: string; + readonly threadId?: string; + readonly targetTurnId?: string; + readonly checkpointId?: string; + readonly baselineSnapshotId: string; + readonly baselineFingerprint: string; + readonly baselineProvenanceWatermark: string; + readonly expectedWorkspaceFingerprint: string; + readonly expectedProvenanceWatermark: string; + readonly createdAt: string; + readonly expiresAt?: string; +} + +export interface SnapshotRestoreRequest { + readonly id: string; + readonly idempotencyKey: string; + readonly workspaceId: string; + readonly anchorId: string; + readonly snapshotId: string; + readonly expectedWorkspaceFingerprint: string; + readonly expectedProvenanceWatermark: string; + readonly state: SnapshotRestoreState; + readonly fencingEpoch: number; + readonly journalEntryCount: number; + readonly lastError?: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly confirmationId?: string; +} + +export interface CreateSnapshotRestoreRequestInput { + readonly id?: string; + readonly idempotencyKey: string; + readonly workspaceId?: string; + readonly anchorId: string; + readonly snapshotId: string; + readonly expectedWorkspaceFingerprint: string; + readonly expectedProvenanceWatermark: string; + readonly state?: SnapshotRestoreState; + readonly fencingEpoch?: number; + readonly journalEntryCount?: number; + readonly lastError?: string; + readonly createdAt: string; + readonly updatedAt?: string; + readonly confirmationId?: string; +} + +export type SnapshotRestoreConfirmationState = "pending" | "confirmed" | "denied" | "expired"; +export interface SnapshotRestoreConfirmation { + readonly id: string; + readonly workspaceId: string; + readonly anchorId: string; + readonly snapshotId: string; + readonly state: SnapshotRestoreConfirmationState; + readonly createdAt: string; + readonly resolvedAt?: string; +} +export interface CreateSnapshotRestoreConfirmationInput { + readonly id?: string; + readonly workspaceId?: string; + readonly anchorId: string; + readonly snapshotId: string; + readonly createdAt: string; +} + +export interface UpdateSnapshotRestoreRequestInput { + readonly id: string; + readonly expectedState: SnapshotRestoreState; + readonly state: SnapshotRestoreState; + readonly fencingEpoch?: number; + readonly journalEntryCount?: number; + readonly lastError?: string; + readonly updatedAt: string; +} + +export interface SnapshotRestoreJournalEntry { + readonly id: string; + readonly restoreRequestId: string; + readonly sequence: number; + readonly relativePath: string; + readonly expectedBeforeSha256?: string; + readonly expectedAfterSha256?: string; + readonly rollbackBlobSha256?: string; + readonly rollbackSymlinkTarget?: string; + readonly targetBlobSha256?: string; + readonly targetSymlinkTarget?: string; + readonly rollbackMode?: number; + readonly targetMode?: number; + readonly targetKind: WorkspaceSnapshotEntryKind; + readonly targetExists: boolean; + readonly state: SnapshotRestoreJournalState; + readonly updatedAt: string; +} + +export interface CreateSnapshotRestoreJournalEntryInput { + readonly id?: string; + readonly restoreRequestId: string; + readonly sequence: number; + readonly relativePath: string; + readonly expectedBeforeSha256?: string; + readonly expectedAfterSha256?: string; + readonly rollbackBlobSha256?: string; + readonly rollbackSymlinkTarget?: string; + readonly targetBlobSha256?: string; + readonly targetSymlinkTarget?: string; + readonly rollbackMode?: number; + readonly targetMode?: number; + readonly targetKind: WorkspaceSnapshotEntryKind; + readonly targetExists: boolean; + readonly state?: SnapshotRestoreJournalState; + readonly updatedAt: string; +} + +export interface ListSnapshotRestoreJournalOptions { + readonly restoreRequestId: string; + readonly limit?: number; +} + +export interface ListSnapshotRestoreRequestsOptions { + readonly workspaceId?: string; + readonly limit?: number; +} + +export interface UpdateSnapshotRestoreJournalEntryInput { + readonly id: string; + readonly expectedState: SnapshotRestoreJournalState; + readonly state: SnapshotRestoreJournalState; + readonly updatedAt: string; +} diff --git a/packages/core/src/run-types.ts b/packages/core/src/run-types.ts index 95a29ced0..99fd72ad3 100644 --- a/packages/core/src/run-types.ts +++ b/packages/core/src/run-types.ts @@ -16,6 +16,24 @@ import type { PostCompactionUndoRecord, UpdatePostCompactionUndoRecordInput, WorkspaceMutationRecord, + CreateWorkspaceSnapshotEntryInput, + CreateWorkspaceSnapshotManifestInput, + CreateWorkspaceSnapshotRestoreAnchorInput, + CreateSnapshotRestoreJournalEntryInput, + CreateSnapshotRestoreRequestInput, + ListSnapshotRestoreJournalOptions, + ListWorkspaceSnapshotManifestsOptions, + SnapshotRestoreJournalEntry, + SnapshotRestoreRequest, + UpdateSnapshotRestoreRequestInput, + UpdateSnapshotRestoreJournalEntryInput, + WorkspaceSnapshotBlob, + WorkspaceSnapshotEntry, + WorkspaceSnapshotManifest, + WorkspaceSnapshotRestoreAnchor, + CreateSnapshotRestoreConfirmationInput, + SnapshotRestoreConfirmation, + ListSnapshotRestoreRequestsOptions, } from "@ling-stack/contracts"; import type { ApprovalActionType, @@ -1537,6 +1555,35 @@ export interface RunStore { getPostCompactionUndoRecord(id: string): PostCompactionUndoRecord | undefined; listPostCompactionUndoRecords(options?: ListPostCompactionUndoRecordsOptions): PostCompactionUndoRecord[]; updatePostCompactionUndoRecord(input: UpdatePostCompactionUndoRecordInput): PostCompactionUndoRecord | undefined; + createWorkspaceSnapshotManifest(input: CreateWorkspaceSnapshotManifestInput): WorkspaceSnapshotManifest; + getWorkspaceSnapshotManifest(id: string): WorkspaceSnapshotManifest | undefined; + listWorkspaceSnapshotManifests(options?: ListWorkspaceSnapshotManifestsOptions): WorkspaceSnapshotManifest[]; + createWorkspaceSnapshotEntries(inputs: readonly CreateWorkspaceSnapshotEntryInput[]): WorkspaceSnapshotEntry[]; + listWorkspaceSnapshotEntries(manifestId: string): WorkspaceSnapshotEntry[]; + getWorkspaceSnapshotBlob(sha256: string): WorkspaceSnapshotBlob | undefined; + listWorkspaceSnapshotBlobs(): WorkspaceSnapshotBlob[]; + upsertWorkspaceSnapshotBlob(input: WorkspaceSnapshotBlob): WorkspaceSnapshotBlob; + createWorkspaceSnapshotRestoreAnchor(input: CreateWorkspaceSnapshotRestoreAnchorInput): WorkspaceSnapshotRestoreAnchor; + getWorkspaceSnapshotRestoreAnchor(id: string): WorkspaceSnapshotRestoreAnchor | undefined; + createSnapshotRestoreRequest(input: CreateSnapshotRestoreRequestInput): SnapshotRestoreRequest; + createSnapshotRestoreConfirmation(input: CreateSnapshotRestoreConfirmationInput): SnapshotRestoreConfirmation; + getSnapshotRestoreConfirmation(id: string): SnapshotRestoreConfirmation | undefined; + resolveSnapshotRestoreConfirmation(id: string, state: "confirmed" | "denied" | "expired", now: string): SnapshotRestoreConfirmation | undefined; + getSnapshotRestoreRequest(id: string): SnapshotRestoreRequest | undefined; + listSnapshotRestoreRequests(options?: ListSnapshotRestoreRequestsOptions): SnapshotRestoreRequest[]; + getSnapshotRestoreRequestByIdempotency(workspaceId: string, idempotencyKey: string): SnapshotRestoreRequest | undefined; + updateSnapshotRestoreRequest(input: UpdateSnapshotRestoreRequestInput): SnapshotRestoreRequest | undefined; + createSnapshotRestoreJournalEntries(inputs: readonly CreateSnapshotRestoreJournalEntryInput[]): SnapshotRestoreJournalEntry[]; + listSnapshotRestoreJournalEntries(options: ListSnapshotRestoreJournalOptions): SnapshotRestoreJournalEntry[]; + updateSnapshotRestoreJournalEntry(input: UpdateSnapshotRestoreJournalEntryInput): SnapshotRestoreJournalEntry | undefined; + acquireWorkspaceSnapshotFence(workspaceId: string, ownerRequestId: string, now: string): number; + getWorkspaceSnapshotFence(workspaceId: string): { readonly epoch: number; readonly ownerRequestId?: string }; + releaseWorkspaceSnapshotFence(workspaceId: string, ownerRequestId: string, now: string): boolean; + expireWorkspaceSnapshotManifest(id: string, now: string): WorkspaceSnapshotManifest | undefined; + deleteWorkspaceSnapshotManifest(id: string): boolean; + deleteWorkspaceSnapshotBlob(sha256: string): boolean; + commitWorkspaceSnapshot(input: CreateWorkspaceSnapshotManifestInput, entries: readonly CreateWorkspaceSnapshotEntryInput[]): { readonly manifest: WorkspaceSnapshotManifest; readonly entries: readonly WorkspaceSnapshotEntry[] }; + assertWorkspaceSnapshotFence(cwd: string): void; createRunIntervention(input: CreateRunInterventionInput, now: string): RunIntervention; getRunIntervention(interventionId: string): RunIntervention | undefined; listRunInterventions(options?: ListRunInterventionsOptions): RunIntervention[]; diff --git a/packages/core/src/runtime-paths.ts b/packages/core/src/runtime-paths.ts index 86a4eaced..2f3d2f212 100644 --- a/packages/core/src/runtime-paths.ts +++ b/packages/core/src/runtime-paths.ts @@ -40,6 +40,10 @@ export interface LingStackRuntimePaths { readonly runtimeSettingsPath: string; readonly sessionsDir: string; readonly artifactsDir: string; + /** Daemon-owned content-addressed workspace snapshot objects. */ + readonly snapshotsDir: string; + readonly snapshotBlobsDir: string; + readonly snapshotStagingDir: string; readonly attachmentsDir: string; readonly logsDir: string; readonly piDir: string; @@ -187,6 +191,9 @@ function buildRuntimePaths( runtimeSettingsPath: join(rootDir, "runtime-settings.json"), sessionsDir: join(rootDir, "sessions"), artifactsDir: join(rootDir, "artifacts"), + snapshotsDir: join(rootDir, "snapshots"), + snapshotBlobsDir: join(rootDir, "snapshots", "blobs"), + snapshotStagingDir: join(rootDir, "snapshots", "staging"), attachmentsDir: join(rootDir, "attachments"), logsDir: join(rootDir, "logs"), piDir: join(rootDir, "pi"), @@ -201,6 +208,9 @@ export function ensureRuntimePaths(paths: LingStackRuntimePaths): void { mkdirSync(paths.rootDir, { recursive: true }); mkdirSync(paths.sessionsDir, { recursive: true }); mkdirSync(paths.artifactsDir, { recursive: true }); + mkdirSync(paths.snapshotsDir, { recursive: true }); + mkdirSync(paths.snapshotBlobsDir, { recursive: true }); + mkdirSync(paths.snapshotStagingDir, { recursive: true }); mkdirSync(paths.attachmentsDir, { recursive: true }); mkdirSync(paths.logsDir, { recursive: true }); mkdirSync(paths.piDir, { recursive: true }); diff --git a/packages/core/src/storage/sqlite-run-store.ts b/packages/core/src/storage/sqlite-run-store.ts index 61bde9670..627fd9972 100644 --- a/packages/core/src/storage/sqlite-run-store.ts +++ b/packages/core/src/storage/sqlite-run-store.ts @@ -324,6 +324,24 @@ import type { UpdatePostCompactionUndoRecordInput, CreatePostCompactionUndoRecordInput, ListPostCompactionUndoRecordsOptions, + CreateWorkspaceSnapshotEntryInput, + CreateWorkspaceSnapshotManifestInput, + CreateWorkspaceSnapshotRestoreAnchorInput, + CreateSnapshotRestoreJournalEntryInput, + CreateSnapshotRestoreRequestInput, + CreateSnapshotRestoreConfirmationInput, + SnapshotRestoreConfirmation, + ListSnapshotRestoreJournalOptions, + ListSnapshotRestoreRequestsOptions, + ListWorkspaceSnapshotManifestsOptions, + SnapshotRestoreJournalEntry, + SnapshotRestoreRequest, + UpdateSnapshotRestoreRequestInput, + UpdateSnapshotRestoreJournalEntryInput, + WorkspaceSnapshotBlob, + WorkspaceSnapshotEntry, + WorkspaceSnapshotManifest, + WorkspaceSnapshotRestoreAnchor, } from "@ling-stack/contracts"; import { type AcquireRuntimeSemaphoreSlotInput, @@ -625,6 +643,7 @@ import { SqliteExternalWorkWorkflowCoordinator } from "./sqlite/external-work-wo import { SqliteWorkItemStore } from "./sqlite/work-item-store.js"; import { SqliteWorkspaceProvenanceStore } from "./sqlite/workspace-provenance-store.js"; import { SqlitePostCompactionUndoStore } from "./sqlite/post-compaction-undo-store.js"; +import { SqliteWorkspaceSnapshotStore } from "./sqlite/workspace-snapshot-store.js"; export interface SqliteRunStoreOptions { readonly cwd: string; @@ -717,6 +736,7 @@ export class SqliteRunStore implements RunStore { private readonly workItems: SqliteWorkItemStore; private readonly workspaceProvenance: SqliteWorkspaceProvenanceStore; private readonly postCompactionUndo: SqlitePostCompactionUndoStore; + private readonly workspaceSnapshots: SqliteWorkspaceSnapshotStore; constructor(options: SqliteRunStoreOptions) { this.paths = options.paths ?? resolveRuntimePaths(options.cwd); @@ -727,6 +747,7 @@ export class SqliteRunStore implements RunStore { this.conversationItems = new SqliteConversationItemStore({ database: this.database }); this.workspaceProvenance = new SqliteWorkspaceProvenanceStore({ database: this.database }); this.postCompactionUndo = new SqlitePostCompactionUndoStore({ database: this.database }); + this.workspaceSnapshots = new SqliteWorkspaceSnapshotStore({ database: this.database }); this.conversationTurnIngest = new SqliteConversationTurnIngestStore( { database: this.database }, this.conversationItems, @@ -763,6 +784,7 @@ export class SqliteRunStore implements RunStore { this.approvalCheckpoints.syncApprovalCheckpointForApproval(approval, now); }, rebuildLingThreadMetadataForApproval: (approval, now) => this.tryRebuildLingThreadMetadataForApproval(approval, now), + assertWorkspaceSnapshotFence: (cwd) => this.assertWorkspaceSnapshotFence(cwd), }, ); this.artifacts = new SqliteArtifactStore( @@ -864,6 +886,7 @@ export class SqliteRunStore implements RunStore { recordStateTransitionEvent: (input) => this.recordStateTransitionEvent(input), reconcileLingTurnStatusForRun: (runId, now) => this.lingTurns.reconcileLingTurnStatusForRun(runId, now), updateLinkedWorkUnitRunId: (workUnitId, runId, now) => this.runtimeWorkUnits.updateLinkedRunId(workUnitId, runId, now), + assertWorkspaceSnapshotFence: (cwd) => this.assertWorkspaceSnapshotFence(cwd), }, ); this.runEvents = new SqliteRunEventStore( @@ -1744,6 +1767,123 @@ export class SqliteRunStore implements RunStore { return this.postCompactionUndo.update(input); } + createWorkspaceSnapshotManifest(input: CreateWorkspaceSnapshotManifestInput): WorkspaceSnapshotManifest { + return this.workspaceSnapshots.createManifest(input); + } + + commitWorkspaceSnapshot(input: CreateWorkspaceSnapshotManifestInput, entries: readonly CreateWorkspaceSnapshotEntryInput[]): { readonly manifest: WorkspaceSnapshotManifest; readonly entries: readonly WorkspaceSnapshotEntry[] } { + return this.workspaceSnapshots.createManifestWithEntries(input, entries); + } + + getWorkspaceSnapshotManifest(id: string): WorkspaceSnapshotManifest | undefined { + return this.workspaceSnapshots.getManifest(id); + } + + listWorkspaceSnapshotManifests(options: ListWorkspaceSnapshotManifestsOptions = {}): WorkspaceSnapshotManifest[] { + return this.workspaceSnapshots.listManifests(options); + } + + createWorkspaceSnapshotEntries(inputs: readonly CreateWorkspaceSnapshotEntryInput[]): WorkspaceSnapshotEntry[] { + return this.workspaceSnapshots.createEntries(inputs); + } + + listWorkspaceSnapshotEntries(manifestId: string): WorkspaceSnapshotEntry[] { + return this.workspaceSnapshots.listEntries(manifestId); + } + + getWorkspaceSnapshotBlob(sha256: string): WorkspaceSnapshotBlob | undefined { + return this.workspaceSnapshots.getBlob(sha256); + } + + listWorkspaceSnapshotBlobs(): WorkspaceSnapshotBlob[] { + return this.workspaceSnapshots.listBlobs(); + } + + upsertWorkspaceSnapshotBlob(input: WorkspaceSnapshotBlob): WorkspaceSnapshotBlob { + return this.workspaceSnapshots.upsertBlob(input); + } + + createWorkspaceSnapshotRestoreAnchor(input: CreateWorkspaceSnapshotRestoreAnchorInput): WorkspaceSnapshotRestoreAnchor { + return this.workspaceSnapshots.createAnchor(input); + } + + getWorkspaceSnapshotRestoreAnchor(id: string): WorkspaceSnapshotRestoreAnchor | undefined { + return this.workspaceSnapshots.getAnchor(id); + } + + createSnapshotRestoreRequest(input: CreateSnapshotRestoreRequestInput): SnapshotRestoreRequest { + return this.workspaceSnapshots.createRestoreRequest(input); + } + + createSnapshotRestoreConfirmation(input: CreateSnapshotRestoreConfirmationInput): SnapshotRestoreConfirmation { + return this.workspaceSnapshots.createConfirmation(input); + } + + getSnapshotRestoreConfirmation(id: string): SnapshotRestoreConfirmation | undefined { + return this.workspaceSnapshots.getConfirmation(id); + } + + resolveSnapshotRestoreConfirmation(id: string, state: "confirmed" | "denied" | "expired", now: string): SnapshotRestoreConfirmation | undefined { + return this.workspaceSnapshots.resolveConfirmation(id, state, now); + } + + getSnapshotRestoreRequest(id: string): SnapshotRestoreRequest | undefined { + return this.workspaceSnapshots.getRestoreRequest(id); + } + + listSnapshotRestoreRequests(options: ListSnapshotRestoreRequestsOptions = {}): SnapshotRestoreRequest[] { + return this.workspaceSnapshots.listRestoreRequests(options); + } + + getSnapshotRestoreRequestByIdempotency(workspaceId: string, idempotencyKey: string): SnapshotRestoreRequest | undefined { + return this.workspaceSnapshots.getRestoreRequestByIdempotency(workspaceId, idempotencyKey); + } + + updateSnapshotRestoreRequest(input: UpdateSnapshotRestoreRequestInput): SnapshotRestoreRequest | undefined { + return this.workspaceSnapshots.updateRestoreRequest(input); + } + + createSnapshotRestoreJournalEntries(inputs: readonly CreateSnapshotRestoreJournalEntryInput[]): SnapshotRestoreJournalEntry[] { + return this.workspaceSnapshots.createJournalEntries(inputs); + } + + listSnapshotRestoreJournalEntries(options: ListSnapshotRestoreJournalOptions): SnapshotRestoreJournalEntry[] { + return this.workspaceSnapshots.listJournalEntries(options); + } + + updateSnapshotRestoreJournalEntry(input: UpdateSnapshotRestoreJournalEntryInput): SnapshotRestoreJournalEntry | undefined { + return this.workspaceSnapshots.updateJournalEntry(input); + } + + acquireWorkspaceSnapshotFence(workspaceId: string, ownerRequestId: string, now: string): number { + return this.workspaceSnapshots.acquireFence(workspaceId, ownerRequestId, now); + } + + getWorkspaceSnapshotFence(workspaceId: string): { readonly epoch: number; readonly ownerRequestId?: string } { + return this.workspaceSnapshots.getFence(workspaceId); + } + + releaseWorkspaceSnapshotFence(workspaceId: string, ownerRequestId: string, now: string): boolean { + return this.workspaceSnapshots.releaseFence(workspaceId, ownerRequestId, now); + } + + expireWorkspaceSnapshotManifest(id: string, now: string): WorkspaceSnapshotManifest | undefined { + return this.workspaceSnapshots.expireManifest(id, now); + } + + deleteWorkspaceSnapshotManifest(id: string): boolean { + return this.workspaceSnapshots.deleteManifest(id); + } + + deleteWorkspaceSnapshotBlob(sha256: string): boolean { + return this.workspaceSnapshots.deleteBlob(sha256); + } + + assertWorkspaceSnapshotFence(_cwd: string): void { + const fence = this.workspaceSnapshots.getFence("workspace_default"); + if (fence.ownerRequestId) throw new Error(`Workspace restore fence is active at epoch ${fence.epoch}.`); + } + createRunIntervention(input: CreateRunInterventionInput, now: string): RunIntervention { return this.runInterventions.createRunIntervention(input, now); } diff --git a/packages/core/src/storage/sqlite/approval-request-store.ts b/packages/core/src/storage/sqlite/approval-request-store.ts index 0493b1932..88089ebfe 100644 --- a/packages/core/src/storage/sqlite/approval-request-store.ts +++ b/packages/core/src/storage/sqlite/approval-request-store.ts @@ -20,6 +20,7 @@ export interface SqliteApprovalRequestStorePorts { readonly createApprovalCheckpoint: (approval: ApprovalRequest, now: string) => void; readonly syncApprovalCheckpointForApproval: (approval: ApprovalRequest, now: string) => void; readonly rebuildLingThreadMetadataForApproval: (approval: ApprovalRequest | undefined, now: string) => void; + readonly assertWorkspaceSnapshotFence?: (cwd: string) => void; } export class SqliteApprovalRequestStore { @@ -50,6 +51,10 @@ export class SqliteApprovalRequestStore { if (runId && !this.ports.hasRun(runId)) { throw new Error(`Run not found: ${runId}`); } + if (runId) { + const run = this.context.database.prepare("SELECT cwd FROM run_sessions WHERE id = ?").get(runId) as { cwd?: unknown } | undefined; + if (typeof run?.cwd === "string") this.ports.assertWorkspaceSnapshotFence?.(run.cwd); + } if (mediaJobId && !this.ports.hasMediaJob(mediaJobId)) { throw new Error(`Media job not found: ${mediaJobId}`); } diff --git a/packages/core/src/storage/sqlite/migrations.ts b/packages/core/src/storage/sqlite/migrations.ts index 3c9de20f0..7b3330e4e 100644 --- a/packages/core/src/storage/sqlite/migrations.ts +++ b/packages/core/src/storage/sqlite/migrations.ts @@ -8,6 +8,7 @@ import { } from "./run-context-view-lineage-migration.js"; import { applyWorkspaceProvenanceMigration } from "./workspace-provenance-migration.js"; import { applyPostCompactionUndoMigration } from "./post-compaction-undo-migration.js"; +import { applyWorkspaceSnapshotMigration } from "./workspace-snapshot-migration.js"; export interface SqliteRunStoreMigrationContext { readonly database: DatabaseSync; @@ -5665,4 +5666,10 @@ export function applySqliteRunStoreMigrations(context: SqliteRunStoreMigrationCo hasTable: context.hasTable.bind(context), recordMigration: context.recordMigration.bind(context), }); + applyWorkspaceSnapshotMigration({ + database, + hasMigration: context.hasMigration.bind(context), + hasTable: context.hasTable.bind(context), + recordMigration: context.recordMigration.bind(context), + }); } diff --git a/packages/core/src/storage/sqlite/run-session-store.ts b/packages/core/src/storage/sqlite/run-session-store.ts index bda290c4f..653972d1a 100644 --- a/packages/core/src/storage/sqlite/run-session-store.ts +++ b/packages/core/src/storage/sqlite/run-session-store.ts @@ -35,6 +35,8 @@ export interface SqliteRunSessionStorePorts { readonly recordStateTransitionEvent: (input: CreateStateTransitionEventInput) => StateTransitionEvent; readonly reconcileLingTurnStatusForRun: (runId: string, now: string) => void; readonly updateLinkedWorkUnitRunId: (workUnitId: string, runId: string, now: string) => number; + /** Reject new/promoted execution while an explicit workspace restore owns the fence. */ + readonly assertWorkspaceSnapshotFence?: (cwd: string) => void; } export class SqliteRunSessionStore { @@ -45,6 +47,7 @@ export class SqliteRunSessionStore { ) {} createRun(input: CreateRunInput): RunSession { + this.ports.assertWorkspaceSnapshotFence?.(input.cwd); const eventLogPath = getRunEventLogPath(this.paths, input.id); const redactedTaskSnapshot = redactSecrets(input.taskSnapshot); const redactedProviderSnapshot = redactSecrets(input.providerSnapshot); @@ -249,6 +252,9 @@ export class SqliteRunSessionStore { if (!result.ok) { return result; } + if (["queued", "running", "waiting_for_approval"].includes(result.to)) { + this.ports.assertWorkspaceSnapshotFence?.(run.cwd); + } const update = this.context.database .prepare(` diff --git a/packages/core/src/storage/sqlite/workspace-snapshot-migration.ts b/packages/core/src/storage/sqlite/workspace-snapshot-migration.ts new file mode 100644 index 000000000..1e9c4b92c --- /dev/null +++ b/packages/core/src/storage/sqlite/workspace-snapshot-migration.ts @@ -0,0 +1,461 @@ +import type { DatabaseSync } from "node:sqlite"; + +export const WORKSPACE_SNAPSHOT_MIGRATION_VERSION = 125; +export const WORKSPACE_SNAPSHOT_MIGRATION_NAME = "create_workspace_snapshot_restore"; + +export interface WorkspaceSnapshotMigrationInput { + readonly database: DatabaseSync; + readonly hasMigration: (version: number) => boolean; + readonly hasTable: (tableName: string) => boolean; + readonly recordMigration: (version: number, name: string) => void; +} + +export function applyWorkspaceSnapshotMigration(input: WorkspaceSnapshotMigrationInput): boolean { + const complete = input.hasTable("workspace_snapshot_manifests") + && input.hasTable("workspace_snapshot_entries") + && input.hasTable("workspace_snapshot_blobs") + && input.hasTable("workspace_snapshot_restore_anchors") + && input.hasTable("workspace_snapshot_restore_requests") + && input.hasTable("workspace_snapshot_restore_confirmations") + && input.hasTable("workspace_snapshot_restore_journal") + && input.hasTable("workspace_snapshot_fences") + && hasColumn(input.database, "workspace_snapshot_manifests", "manifest_sha256") + && hasColumn(input.database, "workspace_snapshot_entries", "managed") + && hasColumn(input.database, "workspace_snapshot_entries", "symlink_target") + && hasColumn(input.database, "workspace_snapshot_restore_journal", "target_symlink_target") + && hasColumn(input.database, "workspace_snapshot_restore_journal", "rollback_mode") + && hasNullableAnchorLineage(input.database) + && tableSql(input.database, "workspace_snapshot_manifests").includes("'committed'") + && tableSql(input.database, "workspace_snapshot_entries").includes("'symlink'") + && tableSql(input.database, "workspace_snapshot_restore_journal").includes("'symlink'") + && tableSql(input.database, "workspace_snapshot_restore_requests").includes("'fenced'") + && tableSql(input.database, "workspace_snapshot_restore_requests").includes("'staged'") + && tableSql(input.database, "workspace_snapshot_restore_requests").includes("'committing'") + && tableSql(input.database, "workspace_snapshot_restore_requests").includes("'verifying'") + && !tableSql(input.database, "workspace_snapshot_restore_requests").includes("'prepared'") + && !tableSql(input.database, "workspace_snapshot_restore_requests").includes("'applying'"); + if (complete && input.hasMigration(WORKSPACE_SNAPSHOT_MIGRATION_VERSION)) return false; + + input.database.exec(` + CREATE TABLE IF NOT EXISTS workspace_snapshot_manifests ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + cwd TEXT NOT NULL, + canonical_root TEXT NOT NULL, + format_version INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL CHECK(state IN ('capturing', 'committed', 'sealed', 'snapshot_unverified', 'expired', 'corrupt', 'deleted')), + fingerprint TEXT NOT NULL, + provenance_watermark TEXT NOT NULL, + entry_count INTEGER NOT NULL, + total_bytes INTEGER NOT NULL, + created_at TEXT NOT NULL, + sealed_at TEXT, + expires_at TEXT, + failure_reason TEXT, + schema_version INTEGER NOT NULL DEFAULT 1, + capture_cwd TEXT, + capture_kind TEXT, + source_turn_id TEXT, + source_run_id TEXT, + source_checkpoint_id TEXT, + exclusion_policy_sha256 TEXT, + managed_entry_count INTEGER, + excluded_entry_count INTEGER, + managed_bytes INTEGER, + workspace_fingerprint_sha256 TEXT, + manifest_sha256 TEXT + ); + CREATE INDEX IF NOT EXISTS idx_workspace_snapshot_manifests_workspace_created + ON workspace_snapshot_manifests(workspace_id, created_at DESC, id DESC); + + CREATE TABLE IF NOT EXISTS workspace_snapshot_entries ( + id TEXT PRIMARY KEY, + manifest_id TEXT NOT NULL, + relative_path TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('file', 'directory', 'symlink')), + managed INTEGER NOT NULL DEFAULT 1 CHECK(managed IN (0, 1)), + exclusion_reason TEXT, + symlink_target TEXT, + mode INTEGER, + size_bytes INTEGER NOT NULL, + sha256 TEXT, + blob_sha256 TEXT, + FOREIGN KEY(manifest_id) REFERENCES workspace_snapshot_manifests(id) ON DELETE CASCADE, + UNIQUE(manifest_id, relative_path) + ); + CREATE INDEX IF NOT EXISTS idx_workspace_snapshot_entries_manifest + ON workspace_snapshot_entries(manifest_id, relative_path); + + CREATE TABLE IF NOT EXISTS workspace_snapshot_blobs ( + sha256 TEXT PRIMARY KEY, + size_bytes INTEGER NOT NULL, + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + created_at TEXT NOT NULL, + ref_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS workspace_snapshot_restore_anchors ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + thread_id TEXT, + target_turn_id TEXT, + checkpoint_id TEXT, + baseline_snapshot_id TEXT NOT NULL, + baseline_fingerprint TEXT NOT NULL, + baseline_provenance_watermark TEXT NOT NULL, + expected_workspace_fingerprint TEXT NOT NULL, + expected_provenance_watermark TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + FOREIGN KEY(thread_id) REFERENCES ling_threads(id) ON DELETE CASCADE, + FOREIGN KEY(target_turn_id) REFERENCES ling_turns(id) ON DELETE RESTRICT, + FOREIGN KEY(checkpoint_id) REFERENCES compaction_checkpoints(id) ON DELETE RESTRICT, + FOREIGN KEY(baseline_snapshot_id) REFERENCES workspace_snapshot_manifests(id) ON DELETE RESTRICT + ); + CREATE INDEX IF NOT EXISTS idx_workspace_snapshot_anchors_target + ON workspace_snapshot_restore_anchors(workspace_id, thread_id, target_turn_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS workspace_snapshot_restore_requests ( + id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL, + workspace_id TEXT NOT NULL, + anchor_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + expected_workspace_fingerprint TEXT NOT NULL, + expected_provenance_watermark TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('requested', 'validating', 'fenced', 'staged', 'committing', 'verifying', 'committed', 'recovery_required', 'conflict', 'failed', 'expired')), + fencing_epoch INTEGER NOT NULL DEFAULT 0, + journal_entry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + confirmation_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(anchor_id) REFERENCES workspace_snapshot_restore_anchors(id) ON DELETE RESTRICT, + FOREIGN KEY(snapshot_id) REFERENCES workspace_snapshot_manifests(id) ON DELETE RESTRICT, + UNIQUE(workspace_id, idempotency_key) + ); + CREATE TABLE IF NOT EXISTS workspace_snapshot_restore_confirmations ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + anchor_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('pending', 'confirmed', 'denied', 'expired')), + created_at TEXT NOT NULL, + resolved_at TEXT, + FOREIGN KEY(anchor_id) REFERENCES workspace_snapshot_restore_anchors(id) ON DELETE RESTRICT, + FOREIGN KEY(snapshot_id) REFERENCES workspace_snapshot_manifests(id) ON DELETE RESTRICT + ); + CREATE INDEX IF NOT EXISTS idx_workspace_snapshot_confirmations_target + ON workspace_snapshot_restore_confirmations(workspace_id, anchor_id, snapshot_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_workspace_snapshot_restore_requests_workspace + ON workspace_snapshot_restore_requests(workspace_id, updated_at DESC, id DESC); + + CREATE TABLE IF NOT EXISTS workspace_snapshot_restore_journal ( + id TEXT PRIMARY KEY, + restore_request_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + relative_path TEXT NOT NULL, + expected_before_sha256 TEXT, + expected_after_sha256 TEXT, + rollback_blob_sha256 TEXT, + rollback_symlink_target TEXT, + target_blob_sha256 TEXT, + target_symlink_target TEXT, + rollback_mode INTEGER, + target_mode INTEGER, + target_kind TEXT NOT NULL CHECK(target_kind IN ('file', 'directory', 'symlink')), + target_exists INTEGER NOT NULL DEFAULT 1 CHECK(target_exists IN (0, 1)), + state TEXT NOT NULL CHECK(state IN ('pending', 'applied', 'rolled_back', 'conflict', 'failed')), + updated_at TEXT NOT NULL, + FOREIGN KEY(restore_request_id) REFERENCES workspace_snapshot_restore_requests(id) ON DELETE CASCADE, + UNIQUE(restore_request_id, sequence), + UNIQUE(restore_request_id, relative_path) + ); + + CREATE TABLE IF NOT EXISTS workspace_snapshot_fences ( + workspace_id TEXT PRIMARY KEY, + epoch INTEGER NOT NULL DEFAULT 0, + owner_request_id TEXT, + updated_at TEXT NOT NULL + ); + `); + if (!hasColumn(input.database, "workspace_snapshot_restore_journal", "target_exists")) { + input.database.exec("ALTER TABLE workspace_snapshot_restore_journal ADD COLUMN target_exists INTEGER NOT NULL DEFAULT 1 CHECK(target_exists IN (0, 1));"); + } + if (!hasColumn(input.database, "workspace_snapshot_restore_requests", "confirmation_id")) { + input.database.exec("ALTER TABLE workspace_snapshot_restore_requests ADD COLUMN confirmation_id TEXT;"); + } + const manifestColumns: ReadonlyArray = [ + ["schema_version", "INTEGER NOT NULL DEFAULT 1"], + ["capture_cwd", "TEXT"], + ["capture_kind", "TEXT"], + ["source_turn_id", "TEXT"], + ["source_run_id", "TEXT"], + ["source_checkpoint_id", "TEXT"], + ["exclusion_policy_sha256", "TEXT"], + ["managed_entry_count", "INTEGER"], + ["excluded_entry_count", "INTEGER"], + ["managed_bytes", "INTEGER"], + ["workspace_fingerprint_sha256", "TEXT"], + ["manifest_sha256", "TEXT"], + ]; + for (const [column, definition] of manifestColumns) { + if (!hasColumn(input.database, "workspace_snapshot_manifests", column)) { + input.database.exec(`ALTER TABLE workspace_snapshot_manifests ADD COLUMN ${column} ${definition};`); + } + } + const entryColumns: ReadonlyArray = [ + ["managed", "INTEGER NOT NULL DEFAULT 1 CHECK(managed IN (0, 1))"], + ["exclusion_reason", "TEXT"], + ["symlink_target", "TEXT"], + ]; + for (const [column, definition] of entryColumns) { + if (!hasColumn(input.database, "workspace_snapshot_entries", column)) { + input.database.exec(`ALTER TABLE workspace_snapshot_entries ADD COLUMN ${column} ${definition};`); + } + } + const journalColumns: ReadonlyArray = [ + ["rollback_symlink_target", "TEXT"], + ["target_symlink_target", "TEXT"], + ["rollback_mode", "INTEGER"], + ["target_mode", "INTEGER"], + ]; + for (const [column, definition] of journalColumns) { + if (!hasColumn(input.database, "workspace_snapshot_restore_journal", column)) { + input.database.exec(`ALTER TABLE workspace_snapshot_restore_journal ADD COLUMN ${column} ${definition};`); + } + } + ensureManifestConstraint(input.database); + ensureSymlinkEntryConstraint(input.database); + ensureSymlinkJournalConstraint(input.database); + ensureRestoreRequestConstraint(input.database); + ensureAnchorLineageNullable(input.database); + input.database.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_snapshot_restore_confirmation_once ON workspace_snapshot_restore_requests(confirmation_id) WHERE confirmation_id IS NOT NULL;"); + if (!input.hasMigration(WORKSPACE_SNAPSHOT_MIGRATION_VERSION)) { + input.recordMigration(WORKSPACE_SNAPSHOT_MIGRATION_VERSION, WORKSPACE_SNAPSHOT_MIGRATION_NAME); + } + return true; +} + +function hasColumn(database: DatabaseSync, tableName: string, columnName: string): boolean { + return (database.prepare(`PRAGMA table_info(${tableName})`).all() as Array>).some((row) => row.name === columnName); +} + +function hasNullableAnchorLineage(database: DatabaseSync): boolean { + const columns = database.prepare("PRAGMA table_info(workspace_snapshot_restore_anchors)").all() as Array>; + return ["thread_id", "target_turn_id", "checkpoint_id"].every((name) => columns.some((column) => column.name === name && Number(column.notnull) === 0)); +} + +function tableSql(database: DatabaseSync, tableName: string): string { + const row = database.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName) as { sql?: unknown } | undefined; + return typeof row?.sql === "string" ? row.sql : ""; +} + +function ensureSymlinkEntryConstraint(database: DatabaseSync): void { + if (tableSql(database, "workspace_snapshot_entries").includes("'symlink'")) return; + database.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + DROP INDEX IF EXISTS idx_workspace_snapshot_entries_manifest; + CREATE TABLE workspace_snapshot_entries_next ( + id TEXT PRIMARY KEY, + manifest_id TEXT NOT NULL, + relative_path TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('file', 'directory', 'symlink')), + managed INTEGER NOT NULL DEFAULT 1 CHECK(managed IN (0, 1)), + exclusion_reason TEXT, + symlink_target TEXT, + mode INTEGER, + size_bytes INTEGER NOT NULL, + sha256 TEXT, + blob_sha256 TEXT, + FOREIGN KEY(manifest_id) REFERENCES workspace_snapshot_manifests(id) ON DELETE CASCADE, + UNIQUE(manifest_id, relative_path) + ); + INSERT INTO workspace_snapshot_entries_next ( + id, manifest_id, relative_path, kind, managed, exclusion_reason, symlink_target, + mode, size_bytes, sha256, blob_sha256 + ) SELECT id, manifest_id, relative_path, kind, managed, exclusion_reason, symlink_target, + mode, size_bytes, sha256, blob_sha256 FROM workspace_snapshot_entries; + DROP TABLE workspace_snapshot_entries; + ALTER TABLE workspace_snapshot_entries_next RENAME TO workspace_snapshot_entries; + CREATE INDEX idx_workspace_snapshot_entries_manifest ON workspace_snapshot_entries(manifest_id, relative_path); + COMMIT; + PRAGMA foreign_keys = ON; + `); +} + +function ensureManifestConstraint(database: DatabaseSync): void { + if (tableSql(database, "workspace_snapshot_manifests").includes("'committed'")) return; + database.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + CREATE TABLE workspace_snapshot_manifests_next ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + cwd TEXT NOT NULL, + canonical_root TEXT NOT NULL, + format_version INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL CHECK(state IN ('capturing', 'committed', 'sealed', 'snapshot_unverified', 'expired', 'corrupt', 'deleted')), + fingerprint TEXT NOT NULL, + provenance_watermark TEXT NOT NULL, + entry_count INTEGER NOT NULL, + total_bytes INTEGER NOT NULL, + created_at TEXT NOT NULL, + sealed_at TEXT, + expires_at TEXT, + failure_reason TEXT, + schema_version INTEGER NOT NULL DEFAULT 1, + capture_cwd TEXT, + capture_kind TEXT, + source_turn_id TEXT, + source_run_id TEXT, + source_checkpoint_id TEXT, + exclusion_policy_sha256 TEXT, + managed_entry_count INTEGER, + excluded_entry_count INTEGER, + managed_bytes INTEGER, + workspace_fingerprint_sha256 TEXT, + manifest_sha256 TEXT + ); + INSERT INTO workspace_snapshot_manifests_next SELECT id, workspace_id, cwd, canonical_root, format_version, state, + fingerprint, provenance_watermark, entry_count, total_bytes, created_at, sealed_at, expires_at, failure_reason, + schema_version, capture_cwd, capture_kind, source_turn_id, source_run_id, source_checkpoint_id, + exclusion_policy_sha256, managed_entry_count, excluded_entry_count, managed_bytes, workspace_fingerprint_sha256, manifest_sha256 + FROM workspace_snapshot_manifests; + DROP TABLE workspace_snapshot_manifests; + ALTER TABLE workspace_snapshot_manifests_next RENAME TO workspace_snapshot_manifests; + CREATE INDEX idx_workspace_snapshot_manifests_workspace_created ON workspace_snapshot_manifests(workspace_id, created_at DESC, id DESC); + COMMIT; + PRAGMA foreign_keys = ON; + `); +} + +function ensureSymlinkJournalConstraint(database: DatabaseSync): void { + if (tableSql(database, "workspace_snapshot_restore_journal").includes("'symlink'")) return; + database.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + CREATE TABLE workspace_snapshot_restore_journal_next ( + id TEXT PRIMARY KEY, + restore_request_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + relative_path TEXT NOT NULL, + expected_before_sha256 TEXT, + expected_after_sha256 TEXT, + rollback_blob_sha256 TEXT, + rollback_symlink_target TEXT, + target_blob_sha256 TEXT, + target_symlink_target TEXT, + rollback_mode INTEGER, + target_mode INTEGER, + target_kind TEXT NOT NULL CHECK(target_kind IN ('file', 'directory', 'symlink')), + target_exists INTEGER NOT NULL DEFAULT 1 CHECK(target_exists IN (0, 1)), + state TEXT NOT NULL CHECK(state IN ('pending', 'applied', 'rolled_back', 'conflict', 'failed')), + updated_at TEXT NOT NULL, + FOREIGN KEY(restore_request_id) REFERENCES workspace_snapshot_restore_requests(id) ON DELETE CASCADE, + UNIQUE(restore_request_id, sequence), + UNIQUE(restore_request_id, relative_path) + ); + INSERT INTO workspace_snapshot_restore_journal_next ( + id, restore_request_id, sequence, relative_path, expected_before_sha256, + expected_after_sha256, rollback_blob_sha256, rollback_symlink_target, + target_blob_sha256, target_symlink_target, rollback_mode, target_mode, + target_kind, target_exists, state, updated_at + ) SELECT id, restore_request_id, sequence, relative_path, expected_before_sha256, + expected_after_sha256, rollback_blob_sha256, rollback_symlink_target, + target_blob_sha256, target_symlink_target, rollback_mode, target_mode, + target_kind, target_exists, state, updated_at + FROM workspace_snapshot_restore_journal; + DROP TABLE workspace_snapshot_restore_journal; + ALTER TABLE workspace_snapshot_restore_journal_next RENAME TO workspace_snapshot_restore_journal; + COMMIT; + PRAGMA foreign_keys = ON; + `); +} + +function ensureRestoreRequestConstraint(database: DatabaseSync): void { + const sql = tableSql(database, "workspace_snapshot_restore_requests"); + if (sql.includes("'fenced'") && sql.includes("'staged'") && sql.includes("'committing'") && sql.includes("'verifying'") && !sql.includes("'prepared'") && !sql.includes("'applying'")) return; + database.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + DROP INDEX IF EXISTS idx_workspace_snapshot_restore_requests_workspace; + DROP INDEX IF EXISTS idx_workspace_snapshot_restore_confirmation_once; + CREATE TABLE workspace_snapshot_restore_requests_next ( + id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL, + workspace_id TEXT NOT NULL, + anchor_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + expected_workspace_fingerprint TEXT NOT NULL, + expected_provenance_watermark TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('requested', 'validating', 'fenced', 'staged', 'committing', 'verifying', 'committed', 'recovery_required', 'conflict', 'failed', 'expired')), + fencing_epoch INTEGER NOT NULL DEFAULT 0, + journal_entry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + confirmation_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(anchor_id) REFERENCES workspace_snapshot_restore_anchors(id) ON DELETE RESTRICT, + FOREIGN KEY(snapshot_id) REFERENCES workspace_snapshot_manifests(id) ON DELETE RESTRICT, + UNIQUE(workspace_id, idempotency_key) + ); + INSERT INTO workspace_snapshot_restore_requests_next ( + id, idempotency_key, workspace_id, anchor_id, snapshot_id, + expected_workspace_fingerprint, expected_provenance_watermark, state, + fencing_epoch, journal_entry_count, last_error, confirmation_id, created_at, updated_at + ) SELECT id, idempotency_key, workspace_id, anchor_id, snapshot_id, + expected_workspace_fingerprint, expected_provenance_watermark, state, + fencing_epoch, journal_entry_count, last_error, confirmation_id, created_at, updated_at + FROM workspace_snapshot_restore_requests; + DROP TABLE workspace_snapshot_restore_requests; + ALTER TABLE workspace_snapshot_restore_requests_next RENAME TO workspace_snapshot_restore_requests; + CREATE INDEX idx_workspace_snapshot_restore_requests_workspace + ON workspace_snapshot_restore_requests(workspace_id, updated_at DESC, id DESC); + COMMIT; + PRAGMA foreign_keys = ON; + `); +} + +function ensureAnchorLineageNullable(database: DatabaseSync): void { + if (hasNullableAnchorLineage(database)) return; + const referencesAvailable = ["ling_threads", "ling_turns", "compaction_checkpoints", "workspace_snapshot_manifests"].every((tableName) => Boolean(database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName))); + const foreignKeys = referencesAvailable ? `, + FOREIGN KEY(thread_id) REFERENCES ling_threads(id) ON DELETE CASCADE, + FOREIGN KEY(target_turn_id) REFERENCES ling_turns(id) ON DELETE RESTRICT, + FOREIGN KEY(checkpoint_id) REFERENCES compaction_checkpoints(id) ON DELETE RESTRICT, + FOREIGN KEY(baseline_snapshot_id) REFERENCES workspace_snapshot_manifests(id) ON DELETE RESTRICT` : ""; + database.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + CREATE TABLE workspace_snapshot_restore_anchors_next ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + thread_id TEXT, + target_turn_id TEXT, + checkpoint_id TEXT, + baseline_snapshot_id TEXT NOT NULL, + baseline_fingerprint TEXT NOT NULL, + baseline_provenance_watermark TEXT NOT NULL, + expected_workspace_fingerprint TEXT NOT NULL, + expected_provenance_watermark TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT${foreignKeys} + ); + INSERT INTO workspace_snapshot_restore_anchors_next ( + id, workspace_id, thread_id, target_turn_id, checkpoint_id, + baseline_snapshot_id, baseline_fingerprint, baseline_provenance_watermark, + expected_workspace_fingerprint, expected_provenance_watermark, created_at, expires_at + ) SELECT id, workspace_id, thread_id, target_turn_id, checkpoint_id, + baseline_snapshot_id, baseline_fingerprint, baseline_provenance_watermark, + expected_workspace_fingerprint, expected_provenance_watermark, created_at, expires_at + FROM workspace_snapshot_restore_anchors; + DROP TABLE workspace_snapshot_restore_anchors; + ALTER TABLE workspace_snapshot_restore_anchors_next RENAME TO workspace_snapshot_restore_anchors; + CREATE INDEX idx_workspace_snapshot_anchors_target + ON workspace_snapshot_restore_anchors(workspace_id, thread_id, target_turn_id, created_at DESC); + COMMIT; + PRAGMA foreign_keys = ON; + `); +} diff --git a/packages/core/src/storage/sqlite/workspace-snapshot-store.ts b/packages/core/src/storage/sqlite/workspace-snapshot-store.ts new file mode 100644 index 000000000..0ddd0cd18 --- /dev/null +++ b/packages/core/src/storage/sqlite/workspace-snapshot-store.ts @@ -0,0 +1,562 @@ +import { randomUUID } from "node:crypto"; +import type { SQLOutputValue } from "node:sqlite"; +import { + snapshotRestoreJournalStates, + snapshotRestoreStates, + workspaceSnapshotEntryKinds, + workspaceSnapshotManifestStates, + type CreateSnapshotRestoreJournalEntryInput, + type CreateSnapshotRestoreRequestInput, + type CreateWorkspaceSnapshotEntryInput, + type CreateWorkspaceSnapshotManifestInput, + type CreateWorkspaceSnapshotRestoreAnchorInput, + type CreateSnapshotRestoreConfirmationInput, + type SnapshotRestoreConfirmation, + type ListSnapshotRestoreJournalOptions, + type ListSnapshotRestoreRequestsOptions, + type ListWorkspaceSnapshotManifestsOptions, + type SnapshotRestoreJournalEntry, + type SnapshotRestoreRequest, + type UpdateSnapshotRestoreRequestInput, + type UpdateSnapshotRestoreJournalEntryInput, + type WorkspaceSnapshotBlob, + type WorkspaceSnapshotEntry, + type WorkspaceSnapshotManifest, + type WorkspaceSnapshotRestoreAnchor, +} from "@ling-stack/contracts"; +import { DEFAULT_WORKSPACE_ID } from "../../workspace-types.js"; +import { parseJson, readNullableString, readNumber, readString, stringifyJson } from "./helpers.js"; +import type { SqliteStoreContext } from "./store-context.js"; + +const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u; +const digestPattern = /^[0-9a-f]{64}$/u; + +export class SqliteWorkspaceSnapshotStore { + constructor(private readonly context: SqliteStoreContext) {} + + createManifest(input: CreateWorkspaceSnapshotManifestInput): WorkspaceSnapshotManifest { + validateManifestInput(input); + const manifest: WorkspaceSnapshotManifest = { + id: input.id ?? `workspace_snapshot_${randomUUID()}`, + workspaceId: normalize(input.workspaceId ?? DEFAULT_WORKSPACE_ID, "snapshot workspace id"), + cwd: normalize(input.cwd, "snapshot cwd"), + canonicalRoot: normalize(input.canonicalRoot, "snapshot canonical root"), + formatVersion: 1, + schemaVersion: input.schemaVersion ?? 1, + state: input.state ?? "sealed", + fingerprint: input.fingerprint, + provenanceWatermark: input.provenanceWatermark, + entryCount: input.entryCount, + totalBytes: input.totalBytes, + createdAt: input.createdAt, + ...(input.sealedAt ? { sealedAt: input.sealedAt } : {}), + ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}), + ...(input.failureReason ? { failureReason: input.failureReason } : {}), + captureCwd: input.captureCwd ?? input.cwd, + captureKind: input.captureKind ?? "user_restore_point", + ...(input.sourceTurnId ? { sourceTurnId: input.sourceTurnId } : {}), + ...(input.sourceRunId ? { sourceRunId: input.sourceRunId } : {}), + ...(input.sourceCheckpointId ? { sourceCheckpointId: input.sourceCheckpointId } : {}), + ...(input.exclusionPolicySha256 ? { exclusionPolicySha256: input.exclusionPolicySha256 } : {}), + managedEntryCount: input.managedEntryCount ?? input.entryCount, + excludedEntryCount: input.excludedEntryCount ?? 0, + managedBytes: input.managedBytes ?? input.totalBytes, + workspaceFingerprintSha256: input.workspaceFingerprintSha256 ?? input.fingerprint, + ...(input.manifestSha256 ? { manifestSha256: input.manifestSha256 } : {}), + }; + this.context.database.prepare(` + INSERT INTO workspace_snapshot_manifests ( + id, workspace_id, cwd, canonical_root, format_version, state, + fingerprint, provenance_watermark, entry_count, total_bytes, + created_at, sealed_at, expires_at, failure_reason, schema_version, + capture_cwd, capture_kind, source_turn_id, source_run_id, source_checkpoint_id, + exclusion_policy_sha256, managed_entry_count, excluded_entry_count, managed_bytes, + workspace_fingerprint_sha256, manifest_sha256 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + manifest.id, manifest.workspaceId, manifest.cwd, manifest.canonicalRoot, manifest.formatVersion, + manifest.state, manifest.fingerprint, manifest.provenanceWatermark, manifest.entryCount, manifest.totalBytes, + manifest.createdAt, manifest.sealedAt ?? null, manifest.expiresAt ?? null, manifest.failureReason ?? null, + manifest.schemaVersion ?? 1, manifest.captureCwd ?? manifest.cwd, manifest.captureKind ?? "user_restore_point", + manifest.sourceTurnId ?? null, manifest.sourceRunId ?? null, manifest.sourceCheckpointId ?? null, + manifest.exclusionPolicySha256 ?? null, manifest.managedEntryCount ?? manifest.entryCount, + manifest.excludedEntryCount ?? 0, manifest.managedBytes ?? manifest.totalBytes, + manifest.workspaceFingerprintSha256 ?? manifest.fingerprint, manifest.manifestSha256 ?? null, + ); + return manifest; + } + + createManifestWithEntries( + input: CreateWorkspaceSnapshotManifestInput, + inputs: readonly CreateWorkspaceSnapshotEntryInput[], + ): { readonly manifest: WorkspaceSnapshotManifest; readonly entries: readonly WorkspaceSnapshotEntry[] } { + validateManifestInput(input); + inputs.forEach(validateEntryInput); + this.context.database.exec("BEGIN IMMEDIATE"); + try { + const manifest = this.insertManifest(input); + const entries = inputs.map((entry) => ({ ...entry, manifestId: manifest.id })); + const created = this.insertEntries(entries, manifest); + this.context.database.exec("COMMIT"); + return { manifest, entries: created }; + } catch (error) { + this.context.database.exec("ROLLBACK"); + throw error; + } + } + + getManifest(id: string): WorkspaceSnapshotManifest | undefined { + const row = this.context.database.prepare(`${manifestSelectSql} WHERE id = ?`).get(id); + return row ? mapManifest(row as Record) : undefined; + } + + listManifests(options: ListWorkspaceSnapshotManifestsOptions = {}): WorkspaceSnapshotManifest[] { + const clauses: string[] = []; + const params: (string | number)[] = []; + if (options.workspaceId) { clauses.push("workspace_id = ?"); params.push(options.workspaceId); } + if (options.state) { clauses.push("state = ?"); params.push(options.state); } + params.push(Math.min(Math.max(options.limit ?? 100, 1), 1_000)); + return this.context.database.prepare(` + ${manifestSelectSql} + ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY created_at DESC, id DESC LIMIT ? + `).all(...params).map((row) => mapManifest(row as Record)); + } + + createEntries(inputs: readonly CreateWorkspaceSnapshotEntryInput[]): WorkspaceSnapshotEntry[] { + if (!inputs.length) return []; + inputs.forEach(validateEntryInput); + this.context.database.exec("BEGIN IMMEDIATE"); + try { + const entries = inputs.map((input) => ({ + id: input.id ?? `workspace_snapshot_entry_${randomUUID()}`, + manifestId: input.manifestId, + relativePath: normalizeRelativePath(input.relativePath), + kind: input.kind, + managed: input.managed ?? true, + ...(input.exclusionReason ? { exclusionReason: input.exclusionReason } : {}), + ...(input.symlinkTarget !== undefined ? { symlinkTarget: input.symlinkTarget } : {}), + ...(input.mode === undefined ? {} : { mode: input.mode }), + sizeBytes: input.sizeBytes, + ...(input.sha256 ? { sha256: input.sha256 } : {}), + ...(input.blobSha256 ? { blobSha256: input.blobSha256 } : {}), + } satisfies WorkspaceSnapshotEntry)); + const manifest = this.getManifest(entries[0]!.manifestId); + if (!manifest || manifest.state === "deleted" || manifest.state === "corrupt") throw new Error(`Snapshot manifest not found: ${entries[0]!.manifestId}`); + if (entries.some((entry) => entry.manifestId !== manifest.id)) throw new Error("Snapshot entries must use one manifest."); + const insert = this.context.database.prepare(` + INSERT INTO workspace_snapshot_entries ( + id, manifest_id, relative_path, kind, managed, exclusion_reason, symlink_target, + mode, size_bytes, sha256, blob_sha256 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const incrementBlob = this.context.database.prepare("UPDATE workspace_snapshot_blobs SET ref_count = ref_count + 1 WHERE sha256 = ?"); + for (const entry of entries) { + if (entry.blobSha256 && !this.getBlob(entry.blobSha256)) throw new Error(`Snapshot blob not found: ${entry.blobSha256}`); + insert.run(entry.id, entry.manifestId, entry.relativePath, entry.kind, entry.managed ? 1 : 0, + entry.exclusionReason ?? null, entry.symlinkTarget ?? null, entry.mode ?? null, + entry.sizeBytes, entry.sha256 ?? null, entry.blobSha256 ?? null); + if (entry.blobSha256) incrementBlob.run(entry.blobSha256); + } + this.context.database.exec("COMMIT"); + return entries; + } catch (error) { + this.context.database.exec("ROLLBACK"); + throw error; + } + } + + private insertManifest(input: CreateWorkspaceSnapshotManifestInput): WorkspaceSnapshotManifest { + const manifest: WorkspaceSnapshotManifest = { + id: input.id ?? `workspace_snapshot_${randomUUID()}`, + workspaceId: normalize(input.workspaceId ?? DEFAULT_WORKSPACE_ID, "snapshot workspace id"), + cwd: normalize(input.cwd, "snapshot cwd"), canonicalRoot: normalize(input.canonicalRoot, "snapshot canonical root"), + formatVersion: 1, schemaVersion: input.schemaVersion ?? 1, state: input.state ?? "sealed", + fingerprint: input.fingerprint, provenanceWatermark: input.provenanceWatermark, entryCount: input.entryCount, + totalBytes: input.totalBytes, createdAt: input.createdAt, + ...(input.sealedAt ? { sealedAt: input.sealedAt } : {}), ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}), + ...(input.failureReason ? { failureReason: input.failureReason } : {}), captureCwd: input.captureCwd ?? input.cwd, + captureKind: input.captureKind ?? "user_restore_point", ...(input.sourceTurnId ? { sourceTurnId: input.sourceTurnId } : {}), + ...(input.sourceRunId ? { sourceRunId: input.sourceRunId } : {}), ...(input.sourceCheckpointId ? { sourceCheckpointId: input.sourceCheckpointId } : {}), + ...(input.exclusionPolicySha256 ? { exclusionPolicySha256: input.exclusionPolicySha256 } : {}), managedEntryCount: input.managedEntryCount ?? input.entryCount, + excludedEntryCount: input.excludedEntryCount ?? 0, managedBytes: input.managedBytes ?? input.totalBytes, + workspaceFingerprintSha256: input.workspaceFingerprintSha256 ?? input.fingerprint, ...(input.manifestSha256 ? { manifestSha256: input.manifestSha256 } : {}), + }; + this.context.database.prepare(` + INSERT INTO workspace_snapshot_manifests ( + id, workspace_id, cwd, canonical_root, format_version, state, fingerprint, provenance_watermark, + entry_count, total_bytes, created_at, sealed_at, expires_at, failure_reason, schema_version, + capture_cwd, capture_kind, source_turn_id, source_run_id, source_checkpoint_id, exclusion_policy_sha256, + managed_entry_count, excluded_entry_count, managed_bytes, workspace_fingerprint_sha256, manifest_sha256 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(manifest.id, manifest.workspaceId, manifest.cwd, manifest.canonicalRoot, 1, manifest.state, manifest.fingerprint, + manifest.provenanceWatermark, manifest.entryCount, manifest.totalBytes, manifest.createdAt, manifest.sealedAt ?? null, + manifest.expiresAt ?? null, manifest.failureReason ?? null, 1, manifest.captureCwd ?? manifest.cwd, manifest.captureKind ?? "user_restore_point", + manifest.sourceTurnId ?? null, manifest.sourceRunId ?? null, manifest.sourceCheckpointId ?? null, manifest.exclusionPolicySha256 ?? null, + manifest.managedEntryCount ?? manifest.entryCount, manifest.excludedEntryCount ?? 0, manifest.managedBytes ?? manifest.totalBytes, + manifest.workspaceFingerprintSha256 ?? manifest.fingerprint, manifest.manifestSha256 ?? null); + return manifest; + } + + private insertEntries(inputs: readonly CreateWorkspaceSnapshotEntryInput[], manifest: WorkspaceSnapshotManifest): WorkspaceSnapshotEntry[] { + const entries = inputs.map((input) => ({ id: input.id ?? `workspace_snapshot_entry_${randomUUID()}`, manifestId: manifest.id, + relativePath: normalizeRelativePath(input.relativePath), kind: input.kind, managed: input.managed ?? true, + ...(input.exclusionReason ? { exclusionReason: input.exclusionReason } : {}), ...(input.symlinkTarget !== undefined ? { symlinkTarget: input.symlinkTarget } : {}), + ...(input.mode === undefined ? {} : { mode: input.mode }), sizeBytes: input.sizeBytes, + ...(input.sha256 ? { sha256: input.sha256 } : {}), ...(input.blobSha256 ? { blobSha256: input.blobSha256 } : {}) } satisfies WorkspaceSnapshotEntry)); + const insert = this.context.database.prepare(`INSERT INTO workspace_snapshot_entries (id, manifest_id, relative_path, kind, managed, exclusion_reason, symlink_target, mode, size_bytes, sha256, blob_sha256) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + const incrementBlob = this.context.database.prepare("UPDATE workspace_snapshot_blobs SET ref_count = ref_count + 1 WHERE sha256 = ?"); + for (const entry of entries) { + if (entry.blobSha256 && !this.getBlob(entry.blobSha256)) throw new Error(`Snapshot blob not found: ${entry.blobSha256}`); + insert.run(entry.id, entry.manifestId, entry.relativePath, entry.kind, entry.managed ? 1 : 0, entry.exclusionReason ?? null, + entry.symlinkTarget ?? null, entry.mode ?? null, entry.sizeBytes, entry.sha256 ?? null, entry.blobSha256 ?? null); + if (entry.blobSha256) incrementBlob.run(entry.blobSha256); + } + return entries; + } + + listEntries(manifestId: string): WorkspaceSnapshotEntry[] { + return this.context.database.prepare(`${entrySelectSql} WHERE manifest_id = ? ORDER BY relative_path ASC`).all(manifestId) + .map((row) => mapEntry(row as Record)); + } + + getBlob(sha256: string): WorkspaceSnapshotBlob | undefined { + const row = this.context.database.prepare(`${blobSelectSql} WHERE sha256 = ?`).get(sha256); + return row ? mapBlob(row as Record) : undefined; + } + + listBlobs(): WorkspaceSnapshotBlob[] { + return this.context.database.prepare(`${blobSelectSql} ORDER BY created_at ASC, sha256 ASC`).all().map((row) => mapBlob(row as Record)); + } + + upsertBlob(input: WorkspaceSnapshotBlob): WorkspaceSnapshotBlob { + if (!digestPattern.test(input.sha256) || !Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 0 || !Number.isSafeInteger(input.refCount) || input.refCount < 0) { + throw new Error("Invalid workspace snapshot blob metadata."); + } + assertTimestamp(input.createdAt); + this.context.database.prepare(` + INSERT INTO workspace_snapshot_blobs (sha256, size_bytes, content_type, created_at, ref_count) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(sha256) DO UPDATE SET size_bytes = excluded.size_bytes, ref_count = excluded.ref_count + `).run(input.sha256, input.sizeBytes, input.contentType, input.createdAt, input.refCount); + return this.getBlob(input.sha256)!; + } + + createAnchor(input: CreateWorkspaceSnapshotRestoreAnchorInput): WorkspaceSnapshotRestoreAnchor { + validateAnchorInput(input); + const workspaceId = normalize(input.workspaceId ?? DEFAULT_WORKSPACE_ID, "snapshot anchor workspace id"); + const anchor: WorkspaceSnapshotRestoreAnchor = { + id: input.id ?? `workspace_snapshot_anchor_${randomUUID()}`, + workspaceId, + ...(input.threadId ? { threadId: input.threadId } : {}), + ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}), + ...(input.checkpointId ? { checkpointId: input.checkpointId } : {}), + baselineSnapshotId: input.baselineSnapshotId, + baselineFingerprint: input.baselineFingerprint, + baselineProvenanceWatermark: input.baselineProvenanceWatermark, + expectedWorkspaceFingerprint: input.expectedWorkspaceFingerprint, + expectedProvenanceWatermark: input.expectedProvenanceWatermark, + createdAt: input.createdAt, + ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}), + }; + this.assertAnchorReferences(anchor); + this.context.database.prepare(` + INSERT INTO workspace_snapshot_restore_anchors ( + id, workspace_id, thread_id, target_turn_id, checkpoint_id, + baseline_snapshot_id, baseline_fingerprint, baseline_provenance_watermark, + expected_workspace_fingerprint, expected_provenance_watermark, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(anchor.id, anchor.workspaceId, anchor.threadId ?? null, anchor.targetTurnId ?? null, anchor.checkpointId ?? null, anchor.baselineSnapshotId, + anchor.baselineFingerprint, anchor.baselineProvenanceWatermark, anchor.expectedWorkspaceFingerprint, + anchor.expectedProvenanceWatermark, anchor.createdAt, anchor.expiresAt ?? null); + return anchor; + } + + getAnchor(id: string): WorkspaceSnapshotRestoreAnchor | undefined { + const row = this.context.database.prepare(`${anchorSelectSql} WHERE id = ?`).get(id); + return row ? mapAnchor(row as Record) : undefined; + } + + createRestoreRequest(input: CreateSnapshotRestoreRequestInput): SnapshotRestoreRequest { + validateRestoreRequestInput(input); + const workspaceId = normalize(input.workspaceId ?? DEFAULT_WORKSPACE_ID, "snapshot restore workspace id"); + const existing = this.getRestoreRequestByIdempotency(workspaceId, input.idempotencyKey); + if (existing) { + if (existing.anchorId !== input.anchorId || existing.snapshotId !== input.snapshotId) throw new Error("Snapshot restore idempotency key is bound to another target."); + return existing; + } + const request: SnapshotRestoreRequest = { + id: input.id ?? `workspace_snapshot_restore_${randomUUID()}`, + idempotencyKey: input.idempotencyKey, + workspaceId, + anchorId: input.anchorId, + snapshotId: input.snapshotId, + expectedWorkspaceFingerprint: input.expectedWorkspaceFingerprint, + expectedProvenanceWatermark: input.expectedProvenanceWatermark, + state: input.state ?? "requested", + fencingEpoch: input.fencingEpoch ?? 0, + journalEntryCount: input.journalEntryCount ?? 0, + ...(input.lastError ? { lastError: input.lastError } : {}), + createdAt: input.createdAt, + updatedAt: input.updatedAt ?? input.createdAt, + ...(input.confirmationId ? { confirmationId: input.confirmationId } : {}), + }; + this.assertRestoreRequestReferences(request); + this.context.database.prepare(` + INSERT INTO workspace_snapshot_restore_requests ( + id, idempotency_key, workspace_id, anchor_id, snapshot_id, + expected_workspace_fingerprint, expected_provenance_watermark, state, + fencing_epoch, journal_entry_count, last_error, confirmation_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(request.id, request.idempotencyKey, request.workspaceId, request.anchorId, request.snapshotId, + request.expectedWorkspaceFingerprint, request.expectedProvenanceWatermark, request.state, request.fencingEpoch, + request.journalEntryCount, request.lastError ?? null, request.confirmationId ?? null, request.createdAt, request.updatedAt); + return request; + } + + getRestoreRequest(id: string): SnapshotRestoreRequest | undefined { + const row = this.context.database.prepare(`${restoreRequestSelectSql} WHERE id = ?`).get(id); + return row ? mapRestoreRequest(row as Record) : undefined; + } + + listRestoreRequests(options: ListSnapshotRestoreRequestsOptions = {}): SnapshotRestoreRequest[] { + const clauses: string[] = []; + const params: (string | number)[] = []; + if (options.workspaceId) { clauses.push("workspace_id = ?"); params.push(options.workspaceId); } + params.push(Math.min(Math.max(options.limit ?? 10_000, 1), 100_000)); + return this.context.database.prepare(`${restoreRequestSelectSql} ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY updated_at DESC, id DESC LIMIT ?`).all(...params) + .map((row) => mapRestoreRequest(row as Record)); + } + + createConfirmation(input: CreateSnapshotRestoreConfirmationInput): SnapshotRestoreConfirmation { + assertTimestamp(input.createdAt); + const workspaceId = normalize(input.workspaceId ?? DEFAULT_WORKSPACE_ID, "snapshot confirmation workspace id"); + const anchor = this.getAnchor(input.anchorId); + const manifest = this.getManifest(input.snapshotId); + if (!anchor || anchor.workspaceId !== workspaceId || !manifest || manifest.workspaceId !== workspaceId || anchor.baselineSnapshotId !== manifest.id) throw new Error("Snapshot confirmation target mismatch."); + const confirmation: SnapshotRestoreConfirmation = { id: input.id ?? `workspace_snapshot_confirmation_${randomUUID()}`, workspaceId, anchorId: input.anchorId, snapshotId: input.snapshotId, state: "pending", createdAt: input.createdAt }; + this.context.database.prepare("INSERT INTO workspace_snapshot_restore_confirmations (id, workspace_id, anchor_id, snapshot_id, state, created_at, resolved_at) VALUES (?, ?, ?, ?, 'pending', ?, NULL)").run(confirmation.id, confirmation.workspaceId, confirmation.anchorId, confirmation.snapshotId, confirmation.createdAt); + return confirmation; + } + + getConfirmation(id: string): SnapshotRestoreConfirmation | undefined { + const row = this.context.database.prepare("SELECT id, workspace_id, anchor_id, snapshot_id, state, created_at, resolved_at FROM workspace_snapshot_restore_confirmations WHERE id = ?").get(id) as Record | undefined; + if (!row) return undefined; + return { id: readString(row, "id"), workspaceId: readString(row, "workspace_id"), anchorId: readString(row, "anchor_id"), snapshotId: readString(row, "snapshot_id"), state: readString(row, "state") as SnapshotRestoreConfirmation["state"], createdAt: readString(row, "created_at"), ...(readNullableString(row, "resolved_at") ? { resolvedAt: readNullableString(row, "resolved_at") } : {}) }; + } + + resolveConfirmation(id: string, state: "confirmed" | "denied" | "expired", now: string): SnapshotRestoreConfirmation | undefined { + assertTimestamp(now); + const result = this.context.database.prepare("UPDATE workspace_snapshot_restore_confirmations SET state = ?, resolved_at = ? WHERE id = ? AND state = 'pending'").run(state, now, id); + return Number(result.changes) === 1 ? this.getConfirmation(id) : this.getConfirmation(id); + } + + getRestoreRequestByIdempotency(workspaceId: string, idempotencyKey: string): SnapshotRestoreRequest | undefined { + const row = this.context.database.prepare(`${restoreRequestSelectSql} WHERE workspace_id = ? AND idempotency_key = ?`).get(workspaceId, idempotencyKey); + return row ? mapRestoreRequest(row as Record) : undefined; + } + + updateRestoreRequest(input: UpdateSnapshotRestoreRequestInput): SnapshotRestoreRequest | undefined { + if (!snapshotRestoreStates.includes(input.expectedState) || !snapshotRestoreStates.includes(input.state)) throw new Error("Invalid snapshot restore state."); + if (!Number.isSafeInteger(input.fencingEpoch ?? 0) || (input.fencingEpoch ?? 0) < 0) throw new Error("Invalid snapshot restore fence epoch."); + assertTimestamp(input.updatedAt); + const result = this.context.database.prepare(` + UPDATE workspace_snapshot_restore_requests + SET state = ?, fencing_epoch = COALESCE(?, fencing_epoch), journal_entry_count = COALESCE(?, journal_entry_count), last_error = ?, updated_at = ? + WHERE id = ? AND state = ? + `).run(input.state, input.fencingEpoch ?? null, input.journalEntryCount ?? null, input.lastError ?? null, input.updatedAt, input.id, input.expectedState); + if (Number(result.changes) !== 1) return undefined; + return this.getRestoreRequest(input.id); + } + + createJournalEntries(inputs: readonly CreateSnapshotRestoreJournalEntryInput[]): SnapshotRestoreJournalEntry[] { + if (!inputs.length) return []; + inputs.forEach(validateJournalInput); + this.context.database.exec("BEGIN IMMEDIATE"); + try { + const entries = inputs.map((input) => ({ + id: input.id ?? `workspace_snapshot_journal_${randomUUID()}`, + restoreRequestId: input.restoreRequestId, + sequence: input.sequence, + relativePath: normalizeRelativePath(input.relativePath), + ...(input.expectedBeforeSha256 ? { expectedBeforeSha256: input.expectedBeforeSha256 } : {}), + ...(input.expectedAfterSha256 ? { expectedAfterSha256: input.expectedAfterSha256 } : {}), + ...(input.rollbackBlobSha256 ? { rollbackBlobSha256: input.rollbackBlobSha256 } : {}), + ...(input.rollbackSymlinkTarget !== undefined ? { rollbackSymlinkTarget: input.rollbackSymlinkTarget } : {}), + ...(input.targetBlobSha256 ? { targetBlobSha256: input.targetBlobSha256 } : {}), + ...(input.targetSymlinkTarget !== undefined ? { targetSymlinkTarget: input.targetSymlinkTarget } : {}), + ...(input.rollbackMode !== undefined ? { rollbackMode: input.rollbackMode } : {}), + ...(input.targetMode !== undefined ? { targetMode: input.targetMode } : {}), + targetKind: input.targetKind, + targetExists: input.targetExists, + state: input.state ?? "pending", + updatedAt: input.updatedAt, + } satisfies SnapshotRestoreJournalEntry)); + const insert = this.context.database.prepare(` + INSERT INTO workspace_snapshot_restore_journal ( + id, restore_request_id, sequence, relative_path, expected_before_sha256, + expected_after_sha256, rollback_blob_sha256, rollback_symlink_target, + target_blob_sha256, target_symlink_target, rollback_mode, target_mode, + target_kind, target_exists, state, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const entry of entries) insert.run(entry.id, entry.restoreRequestId, entry.sequence, entry.relativePath, entry.expectedBeforeSha256 ?? null, + entry.expectedAfterSha256 ?? null, entry.rollbackBlobSha256 ?? null, entry.rollbackSymlinkTarget ?? null, + entry.targetBlobSha256 ?? null, entry.targetSymlinkTarget ?? null, entry.rollbackMode ?? null, + entry.targetMode ?? null, entry.targetKind, entry.targetExists ? 1 : 0, entry.state, entry.updatedAt); + this.context.database.exec("COMMIT"); + return entries; + } catch (error) { + this.context.database.exec("ROLLBACK"); + throw error; + } + } + + listJournalEntries(options: ListSnapshotRestoreJournalOptions): SnapshotRestoreJournalEntry[] { + const limit = Math.min(Math.max(options.limit ?? 10_000, 1), 100_000); + return this.context.database.prepare(`${journalSelectSql} WHERE restore_request_id = ? ORDER BY sequence ASC LIMIT ?`).all(options.restoreRequestId, limit) + .map((row) => mapJournalEntry(row as Record)); + } + + updateJournalEntry(input: UpdateSnapshotRestoreJournalEntryInput): SnapshotRestoreJournalEntry | undefined { + if (!snapshotRestoreJournalStates.includes(input.expectedState) || !snapshotRestoreJournalStates.includes(input.state)) throw new Error("Invalid restore journal state."); + assertTimestamp(input.updatedAt); + const result = this.context.database.prepare("UPDATE workspace_snapshot_restore_journal SET state = ?, updated_at = ? WHERE id = ? AND state = ?").run(input.state, input.updatedAt, input.id, input.expectedState); + if (Number(result.changes) !== 1) return undefined; + const row = this.context.database.prepare(`${journalSelectSql} WHERE id = ?`).get(input.id); + return row ? mapJournalEntry(row as Record) : undefined; + } + + acquireFence(workspaceId: string, ownerRequestId: string, now: string): number { + assertTimestamp(now); + const normalizedWorkspace = normalize(workspaceId, "snapshot fence workspace id"); + this.context.database.exec("BEGIN IMMEDIATE"); + try { + const current = this.context.database.prepare("SELECT epoch, owner_request_id FROM workspace_snapshot_fences WHERE workspace_id = ?").get(normalizedWorkspace) as Record | undefined; + const priorOwner = current?.owner_request_id; + if (typeof priorOwner === "string" && priorOwner && priorOwner !== ownerRequestId) throw new Error("Workspace snapshot restore fence is already owned."); + const epoch = (current ? readNumber(current, "epoch") : 0) + 1; + this.context.database.prepare(` + INSERT INTO workspace_snapshot_fences (workspace_id, epoch, owner_request_id, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET epoch = excluded.epoch, owner_request_id = excluded.owner_request_id, updated_at = excluded.updated_at + `).run(normalizedWorkspace, epoch, ownerRequestId, now); + this.context.database.exec("COMMIT"); + return epoch; + } catch (error) { + this.context.database.exec("ROLLBACK"); + throw error; + } + } + + getFence(workspaceId: string): { readonly epoch: number; readonly ownerRequestId?: string } { + const row = this.context.database.prepare("SELECT epoch, owner_request_id FROM workspace_snapshot_fences WHERE workspace_id = ?").get(workspaceId) as Record | undefined; + return row ? { epoch: readNumber(row, "epoch"), ...(readNullableString(row, "owner_request_id") ? { ownerRequestId: readNullableString(row, "owner_request_id") } : {}) } : { epoch: 0 }; + } + + releaseFence(workspaceId: string, ownerRequestId: string, now: string): boolean { + assertTimestamp(now); + const result = this.context.database.prepare("UPDATE workspace_snapshot_fences SET owner_request_id = NULL, updated_at = ? WHERE workspace_id = ? AND owner_request_id = ?").run(now, workspaceId, ownerRequestId); + return Number(result.changes) === 1; + } + + expireManifest(id: string, now: string): WorkspaceSnapshotManifest | undefined { + assertTimestamp(now); + const protectedByAnchor = this.context.database.prepare("SELECT 1 FROM workspace_snapshot_restore_anchors WHERE baseline_snapshot_id = ? LIMIT 1").get(id); + const protectedByRequest = this.context.database.prepare("SELECT 1 FROM workspace_snapshot_restore_requests WHERE snapshot_id = ? AND state NOT IN ('committed', 'failed', 'conflict', 'expired') LIMIT 1").get(id); + if (protectedByAnchor || protectedByRequest) return undefined; + const result = this.context.database.prepare("UPDATE workspace_snapshot_manifests SET state = 'expired' WHERE id = ? AND state IN ('committed', 'sealed')").run(id); + return Number(result.changes) === 1 ? this.getManifest(id) : undefined; + } + + deleteManifest(id: string): boolean { + this.context.database.exec("BEGIN IMMEDIATE"); + try { + const protectedByAnchor = this.context.database.prepare("SELECT 1 FROM workspace_snapshot_restore_anchors WHERE baseline_snapshot_id = ? LIMIT 1").get(id); + const protectedByRequest = this.context.database.prepare("SELECT 1 FROM workspace_snapshot_restore_requests WHERE snapshot_id = ? LIMIT 1").get(id); + if (protectedByAnchor || protectedByRequest) { this.context.database.exec("ROLLBACK"); return false; } + const blobs = this.context.database.prepare("SELECT blob_sha256, COUNT(*) AS refs FROM workspace_snapshot_entries WHERE manifest_id = ? AND blob_sha256 IS NOT NULL GROUP BY blob_sha256").all(id) as Array>; + const result = this.context.database.prepare("DELETE FROM workspace_snapshot_manifests WHERE id = ? AND state = 'expired'").run(id); + if (Number(result.changes) === 1) { + const decrement = this.context.database.prepare("UPDATE workspace_snapshot_blobs SET ref_count = MAX(0, ref_count - ?) WHERE sha256 = ?"); + for (const blob of blobs) decrement.run(readNumber(blob, "refs"), readString(blob, "blob_sha256")); + } + this.context.database.exec("COMMIT"); + return Number(result.changes) === 1; + } catch (error) { + this.context.database.exec("ROLLBACK"); + throw error; + } + } + + deleteBlob(sha256: string): boolean { + const result = this.context.database.prepare("DELETE FROM workspace_snapshot_blobs WHERE sha256 = ? AND ref_count <= 0").run(sha256); + return Number(result.changes) === 1; + } + + private assertAnchorReferences(anchor: WorkspaceSnapshotRestoreAnchor): void { + const manifest = this.context.database.prepare("SELECT workspace_id, state FROM workspace_snapshot_manifests WHERE id = ?").get(anchor.baselineSnapshotId) as Record | undefined; + if ((anchor.threadId === undefined) !== (anchor.targetTurnId === undefined) || (anchor.threadId === undefined) !== (anchor.checkpointId === undefined)) { + throw new Error("Snapshot anchor Turn lineage must be complete or omitted."); + } + if (anchor.threadId && anchor.targetTurnId && anchor.checkpointId) { + const checkpoint = this.context.database.prepare("SELECT workspace_id, thread_id FROM compaction_checkpoints WHERE id = ?").get(anchor.checkpointId) as Record | undefined; + const turn = this.context.database.prepare("SELECT workspace_id, thread_id FROM ling_turns WHERE id = ?").get(anchor.targetTurnId) as Record | undefined; + if (!checkpoint || readString(checkpoint, "workspace_id") !== anchor.workspaceId || readString(checkpoint, "thread_id") !== anchor.threadId) throw new Error(`Snapshot anchor checkpoint does not match: ${anchor.checkpointId}`); + if (!turn || readString(turn, "workspace_id") !== anchor.workspaceId || readString(turn, "thread_id") !== anchor.threadId) throw new Error(`Snapshot anchor target Turn does not match: ${anchor.targetTurnId}`); + } + if (!manifest || readString(manifest, "workspace_id") !== anchor.workspaceId || !["committed", "sealed"].includes(readString(manifest, "state"))) throw new Error(`Snapshot anchor baseline manifest is not committed: ${anchor.baselineSnapshotId}`); + } + + private assertRestoreRequestReferences(request: SnapshotRestoreRequest): void { + const anchor = this.context.database.prepare("SELECT workspace_id, baseline_snapshot_id FROM workspace_snapshot_restore_anchors WHERE id = ?").get(request.anchorId) as Record | undefined; + const manifest = this.context.database.prepare("SELECT workspace_id FROM workspace_snapshot_manifests WHERE id = ?").get(request.snapshotId) as Record | undefined; + if (!anchor || readString(anchor, "workspace_id") !== request.workspaceId || readString(anchor, "baseline_snapshot_id") !== request.snapshotId) throw new Error(`Snapshot restore anchor does not match: ${request.anchorId}`); + if (!manifest || readString(manifest, "workspace_id") !== request.workspaceId) throw new Error(`Snapshot restore manifest does not match: ${request.snapshotId}`); + } +} + +const manifestSelectSql = "SELECT id, workspace_id, cwd, canonical_root, format_version, state, fingerprint, provenance_watermark, entry_count, total_bytes, created_at, sealed_at, expires_at, failure_reason, schema_version, capture_cwd, capture_kind, source_turn_id, source_run_id, source_checkpoint_id, exclusion_policy_sha256, managed_entry_count, excluded_entry_count, managed_bytes, workspace_fingerprint_sha256, manifest_sha256 FROM workspace_snapshot_manifests"; +const entrySelectSql = "SELECT id, manifest_id, relative_path, kind, managed, exclusion_reason, symlink_target, mode, size_bytes, sha256, blob_sha256 FROM workspace_snapshot_entries"; +const blobSelectSql = "SELECT sha256, size_bytes, content_type, created_at, ref_count FROM workspace_snapshot_blobs"; +const anchorSelectSql = "SELECT id, workspace_id, thread_id, target_turn_id, checkpoint_id, baseline_snapshot_id, baseline_fingerprint, baseline_provenance_watermark, expected_workspace_fingerprint, expected_provenance_watermark, created_at, expires_at FROM workspace_snapshot_restore_anchors"; +const restoreRequestSelectSql = "SELECT id, idempotency_key, workspace_id, anchor_id, snapshot_id, expected_workspace_fingerprint, expected_provenance_watermark, state, fencing_epoch, journal_entry_count, last_error, confirmation_id, created_at, updated_at FROM workspace_snapshot_restore_requests"; +const journalSelectSql = "SELECT id, restore_request_id, sequence, relative_path, expected_before_sha256, expected_after_sha256, rollback_blob_sha256, rollback_symlink_target, target_blob_sha256, target_symlink_target, rollback_mode, target_mode, target_kind, target_exists, state, updated_at FROM workspace_snapshot_restore_journal"; + +function mapManifest(row: Record): WorkspaceSnapshotManifest { + return { + id: readString(row, "id"), workspaceId: readString(row, "workspace_id"), cwd: readString(row, "cwd"), canonicalRoot: readString(row, "canonical_root"), + formatVersion: 1, state: readString(row, "state") as WorkspaceSnapshotManifest["state"], fingerprint: readString(row, "fingerprint"), + provenanceWatermark: readString(row, "provenance_watermark"), entryCount: readNumber(row, "entry_count"), totalBytes: readNumber(row, "total_bytes"), + createdAt: readString(row, "created_at"), ...(readNullableString(row, "sealed_at") ? { sealedAt: readNullableString(row, "sealed_at") } : {}), + ...(readNullableString(row, "expires_at") ? { expiresAt: readNullableString(row, "expires_at") } : {}), ...(readNullableString(row, "failure_reason") ? { failureReason: readNullableString(row, "failure_reason") } : {}), + schemaVersion: 1, captureCwd: readNullableString(row, "capture_cwd") ?? readString(row, "cwd"), + captureKind: (readNullableString(row, "capture_kind") ?? "user_restore_point") as WorkspaceSnapshotManifest["captureKind"], + ...(readNullableString(row, "source_turn_id") ? { sourceTurnId: readNullableString(row, "source_turn_id") } : {}), + ...(readNullableString(row, "source_run_id") ? { sourceRunId: readNullableString(row, "source_run_id") } : {}), + ...(readNullableString(row, "source_checkpoint_id") ? { sourceCheckpointId: readNullableString(row, "source_checkpoint_id") } : {}), + ...(readNullableString(row, "exclusion_policy_sha256") ? { exclusionPolicySha256: readNullableString(row, "exclusion_policy_sha256") } : {}), + managedEntryCount: row.managed_entry_count === null ? readNumber(row, "entry_count") : readNumber(row, "managed_entry_count"), + excludedEntryCount: row.excluded_entry_count === null ? 0 : readNumber(row, "excluded_entry_count"), + managedBytes: row.managed_bytes === null ? readNumber(row, "total_bytes") : readNumber(row, "managed_bytes"), + workspaceFingerprintSha256: readNullableString(row, "workspace_fingerprint_sha256") ?? readString(row, "fingerprint"), + ...(readNullableString(row, "manifest_sha256") ? { manifestSha256: readNullableString(row, "manifest_sha256") } : {}), + }; +} +function mapEntry(row: Record): WorkspaceSnapshotEntry { + return { id: readString(row, "id"), manifestId: readString(row, "manifest_id"), relativePath: readString(row, "relative_path"), kind: readString(row, "kind") as WorkspaceSnapshotEntry["kind"], managed: readNumber(row, "managed") === 1, ...(readNullableString(row, "exclusion_reason") ? { exclusionReason: readNullableString(row, "exclusion_reason") as WorkspaceSnapshotEntry["exclusionReason"] } : {}), ...(readNullableString(row, "symlink_target") !== undefined ? { symlinkTarget: readNullableString(row, "symlink_target") } : {}), ...(row.mode === null ? {} : { mode: readNumber(row, "mode") }), sizeBytes: readNumber(row, "size_bytes"), ...(readNullableString(row, "sha256") ? { sha256: readNullableString(row, "sha256") } : {}), ...(readNullableString(row, "blob_sha256") ? { blobSha256: readNullableString(row, "blob_sha256") } : {}) }; +} +function mapBlob(row: Record): WorkspaceSnapshotBlob { return { sha256: readString(row, "sha256"), sizeBytes: readNumber(row, "size_bytes"), contentType: "application/octet-stream", createdAt: readString(row, "created_at"), refCount: readNumber(row, "ref_count") }; } +function mapAnchor(row: Record): WorkspaceSnapshotRestoreAnchor { return { id: readString(row, "id"), workspaceId: readString(row, "workspace_id"), ...(readNullableString(row, "thread_id") ? { threadId: readNullableString(row, "thread_id") } : {}), ...(readNullableString(row, "target_turn_id") ? { targetTurnId: readNullableString(row, "target_turn_id") } : {}), ...(readNullableString(row, "checkpoint_id") ? { checkpointId: readNullableString(row, "checkpoint_id") } : {}), baselineSnapshotId: readString(row, "baseline_snapshot_id"), baselineFingerprint: readString(row, "baseline_fingerprint"), baselineProvenanceWatermark: readString(row, "baseline_provenance_watermark"), expectedWorkspaceFingerprint: readString(row, "expected_workspace_fingerprint"), expectedProvenanceWatermark: readString(row, "expected_provenance_watermark"), createdAt: readString(row, "created_at"), ...(readNullableString(row, "expires_at") ? { expiresAt: readNullableString(row, "expires_at") } : {}) }; } +function mapRestoreRequest(row: Record): SnapshotRestoreRequest { return { id: readString(row, "id"), idempotencyKey: readString(row, "idempotency_key"), workspaceId: readString(row, "workspace_id"), anchorId: readString(row, "anchor_id"), snapshotId: readString(row, "snapshot_id"), expectedWorkspaceFingerprint: readString(row, "expected_workspace_fingerprint"), expectedProvenanceWatermark: readString(row, "expected_provenance_watermark"), state: readString(row, "state") as SnapshotRestoreRequest["state"], fencingEpoch: readNumber(row, "fencing_epoch"), journalEntryCount: readNumber(row, "journal_entry_count"), ...(readNullableString(row, "last_error") ? { lastError: readNullableString(row, "last_error") } : {}), ...(readNullableString(row, "confirmation_id") ? { confirmationId: readNullableString(row, "confirmation_id") } : {}), createdAt: readString(row, "created_at"), updatedAt: readString(row, "updated_at") }; } +function mapJournalEntry(row: Record): SnapshotRestoreJournalEntry { return { id: readString(row, "id"), restoreRequestId: readString(row, "restore_request_id"), sequence: readNumber(row, "sequence"), relativePath: readString(row, "relative_path"), ...(readNullableString(row, "expected_before_sha256") ? { expectedBeforeSha256: readNullableString(row, "expected_before_sha256") } : {}), ...(readNullableString(row, "expected_after_sha256") ? { expectedAfterSha256: readNullableString(row, "expected_after_sha256") } : {}), ...(readNullableString(row, "rollback_blob_sha256") ? { rollbackBlobSha256: readNullableString(row, "rollback_blob_sha256") } : {}), ...(readNullableString(row, "rollback_symlink_target") !== undefined ? { rollbackSymlinkTarget: readNullableString(row, "rollback_symlink_target") } : {}), ...(readNullableString(row, "target_blob_sha256") ? { targetBlobSha256: readNullableString(row, "target_blob_sha256") } : {}), ...(readNullableString(row, "target_symlink_target") !== undefined ? { targetSymlinkTarget: readNullableString(row, "target_symlink_target") } : {}), ...(row.rollback_mode === null ? {} : { rollbackMode: readNumber(row, "rollback_mode") }), ...(row.target_mode === null ? {} : { targetMode: readNumber(row, "target_mode") }), targetKind: readString(row, "target_kind") as SnapshotRestoreJournalEntry["targetKind"], targetExists: readNumber(row, "target_exists") === 1, state: readString(row, "state") as SnapshotRestoreJournalEntry["state"], updatedAt: readString(row, "updated_at") }; } + +function validateManifestInput(input: CreateWorkspaceSnapshotManifestInput): void { normalize(input.cwd, "snapshot cwd"); normalize(input.canonicalRoot, "snapshot canonical root"); if (!digestPattern.test(input.fingerprint)) throw new Error("Snapshot fingerprint must be SHA-256."); normalize(input.provenanceWatermark, "snapshot provenance watermark"); if (!Number.isSafeInteger(input.entryCount) || input.entryCount < 0 || !Number.isSafeInteger(input.totalBytes) || input.totalBytes < 0) throw new Error("Invalid snapshot manifest counters."); for (const value of [input.managedEntryCount, input.excludedEntryCount, input.managedBytes]) if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) throw new Error("Invalid snapshot manifest coverage counters."); if (input.exclusionPolicySha256 && !digestPattern.test(input.exclusionPolicySha256)) throw new Error("Invalid snapshot exclusion policy digest."); if (input.manifestSha256 && !digestPattern.test(input.manifestSha256)) throw new Error("Invalid snapshot manifest digest."); if (input.state && !workspaceSnapshotManifestStates.includes(input.state)) throw new Error("Invalid snapshot manifest state."); assertTimestamp(input.createdAt); if (input.sealedAt) assertTimestamp(input.sealedAt); if (input.expiresAt) assertTimestamp(input.expiresAt); } +function validateEntryInput(input: CreateWorkspaceSnapshotEntryInput): void { normalizeRelativePath(input.relativePath); if (!workspaceSnapshotEntryKinds.includes(input.kind)) throw new Error("Invalid snapshot entry kind."); if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 0) throw new Error("Invalid snapshot entry size."); if (input.sha256 && !digestPattern.test(input.sha256)) throw new Error("Invalid snapshot entry digest."); if (input.blobSha256 && !digestPattern.test(input.blobSha256)) throw new Error("Invalid snapshot blob digest."); if (input.kind === "symlink" && input.managed !== false && input.symlinkTarget === undefined) throw new Error("Managed snapshot symlink requires a target."); if (input.managed === false && !input.exclusionReason) throw new Error("Excluded snapshot entry requires a reason."); } +function validateAnchorInput(input: CreateWorkspaceSnapshotRestoreAnchorInput): void { for (const [value, label] of [[input.threadId, "thread"], [input.targetTurnId, "target turn"], [input.checkpointId, "checkpoint"]] as const) if (value !== undefined) normalize(value, `snapshot anchor ${label}`); if ((input.threadId === undefined) !== (input.targetTurnId === undefined) || (input.threadId === undefined) !== (input.checkpointId === undefined)) throw new Error("Snapshot anchor Turn lineage must be complete or omitted."); normalize(input.baselineSnapshotId, "snapshot anchor snapshot"); for (const value of [input.baselineFingerprint, input.expectedWorkspaceFingerprint]) if (!digestPattern.test(value)) throw new Error("Snapshot anchor fingerprint must be SHA-256."); normalize(input.baselineProvenanceWatermark, "baseline watermark"); normalize(input.expectedProvenanceWatermark, "expected watermark"); assertTimestamp(input.createdAt); if (input.expiresAt) assertTimestamp(input.expiresAt); } +function validateRestoreRequestInput(input: CreateSnapshotRestoreRequestInput): void { normalize(input.idempotencyKey, "snapshot restore idempotency key"); normalize(input.anchorId, "snapshot restore anchor"); normalize(input.snapshotId, "snapshot restore snapshot"); if (!digestPattern.test(input.expectedWorkspaceFingerprint)) throw new Error("Snapshot restore fingerprint must be SHA-256."); normalize(input.expectedProvenanceWatermark, "snapshot restore watermark"); if (input.state && !snapshotRestoreStates.includes(input.state)) throw new Error("Invalid snapshot restore state."); if (input.fencingEpoch !== undefined && (!Number.isSafeInteger(input.fencingEpoch) || input.fencingEpoch < 0)) throw new Error("Invalid snapshot restore epoch."); assertTimestamp(input.createdAt); if (input.updatedAt) assertTimestamp(input.updatedAt); } +function validateJournalInput(input: CreateSnapshotRestoreJournalEntryInput): void { normalize(input.restoreRequestId, "restore request"); normalizeRelativePath(input.relativePath); if (!Number.isSafeInteger(input.sequence) || input.sequence < 0) throw new Error("Invalid restore journal sequence."); if (typeof input.targetExists !== "boolean") throw new Error("Invalid restore journal target existence flag."); if (!workspaceSnapshotEntryKinds.includes(input.targetKind) || (input.state && !snapshotRestoreJournalStates.includes(input.state))) throw new Error("Invalid restore journal state."); for (const digest of [input.expectedBeforeSha256, input.expectedAfterSha256, input.rollbackBlobSha256, input.targetBlobSha256]) if (digest && !digestPattern.test(digest)) throw new Error("Invalid restore journal digest."); for (const mode of [input.rollbackMode, input.targetMode]) if (mode !== undefined && (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o777)) throw new Error("Invalid restore journal mode."); assertTimestamp(input.updatedAt); } +function mapNormalize(value: string): string { return value.trim(); } +function normalize(value: string, label: string): string { const result = mapNormalize(value); if (!result) throw new Error(`${label} must not be empty.`); return result; } +function normalizeRelativePath(value: string): string { const result = normalize(value, "snapshot relative path").replaceAll("\\", "/"); if (result.startsWith("/") || result === "." || result.split("/").includes("..")) throw new Error(`Snapshot path escapes workspace: ${value}`); return result; } +function assertTimestamp(value: string): void { if (!timestampPattern.test(value) || Number.isNaN(Date.parse(value))) throw new Error(`Timestamp must be canonical UTC: ${value}`); } diff --git a/packages/core/test/workspace-snapshot-migration.test.mjs b/packages/core/test/workspace-snapshot-migration.test.mjs new file mode 100644 index 000000000..e3030bd11 --- /dev/null +++ b/packages/core/test/workspace-snapshot-migration.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; +import { + applyWorkspaceSnapshotMigration, + WORKSPACE_SNAPSHOT_MIGRATION_VERSION, +} from "../dist/storage/sqlite/workspace-snapshot-migration.js"; + +test("workspace snapshot migration upgrades legacy constraints across reopen and is idempotent", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-workspace-snapshot-migration-")); + const databasePath = resolve(cwd, "legacy.sqlite"); + let database = new DatabaseSync(databasePath); + try { + database.exec(` + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL); + CREATE TABLE workspace_snapshot_manifests ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, cwd TEXT NOT NULL, canonical_root TEXT NOT NULL, + format_version INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL CHECK(state IN ('capturing', 'sealed', 'snapshot_unverified', 'expired', 'corrupt', 'deleted')), + fingerprint TEXT NOT NULL, provenance_watermark TEXT NOT NULL, entry_count INTEGER NOT NULL, + total_bytes INTEGER NOT NULL, created_at TEXT NOT NULL, sealed_at TEXT, expires_at TEXT, failure_reason TEXT + ); + CREATE TABLE workspace_snapshot_entries ( + id TEXT PRIMARY KEY, manifest_id TEXT NOT NULL, relative_path TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('file', 'directory')), mode INTEGER, size_bytes INTEGER NOT NULL, + sha256 TEXT, blob_sha256 TEXT, UNIQUE(manifest_id, relative_path) + ); + CREATE TABLE workspace_snapshot_blobs ( + sha256 TEXT PRIMARY KEY, size_bytes INTEGER NOT NULL, content_type TEXT NOT NULL, + created_at TEXT NOT NULL, ref_count INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE workspace_snapshot_restore_anchors ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, thread_id TEXT NOT NULL, target_turn_id TEXT NOT NULL, + checkpoint_id TEXT NOT NULL, baseline_snapshot_id TEXT NOT NULL, baseline_fingerprint TEXT NOT NULL, + baseline_provenance_watermark TEXT NOT NULL, expected_workspace_fingerprint TEXT NOT NULL, + expected_provenance_watermark TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT + ); + CREATE TABLE workspace_snapshot_restore_requests ( + id TEXT PRIMARY KEY, idempotency_key TEXT NOT NULL, workspace_id TEXT NOT NULL, anchor_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, expected_workspace_fingerprint TEXT NOT NULL, + expected_provenance_watermark TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('requested', 'validating', 'prepared', 'applying', 'committed', 'recovery_required', 'conflict', 'failed', 'expired')), + fencing_epoch INTEGER NOT NULL DEFAULT 0, journal_entry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + UNIQUE(workspace_id, idempotency_key) + ); + CREATE TABLE workspace_snapshot_restore_confirmations ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, anchor_id TEXT NOT NULL, snapshot_id TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('pending', 'confirmed', 'denied', 'expired')), + created_at TEXT NOT NULL, resolved_at TEXT + ); + CREATE TABLE workspace_snapshot_restore_journal ( + id TEXT PRIMARY KEY, restore_request_id TEXT NOT NULL, sequence INTEGER NOT NULL, + relative_path TEXT NOT NULL, expected_before_sha256 TEXT, expected_after_sha256 TEXT, + rollback_blob_sha256 TEXT, target_blob_sha256 TEXT, + target_kind TEXT NOT NULL CHECK(target_kind IN ('file', 'directory')), + state TEXT NOT NULL CHECK(state IN ('pending', 'applied', 'rolled_back', 'conflict', 'failed')), + updated_at TEXT NOT NULL, UNIQUE(restore_request_id, sequence), UNIQUE(restore_request_id, relative_path) + ); + CREATE TABLE workspace_snapshot_fences (workspace_id TEXT PRIMARY KEY, epoch INTEGER NOT NULL DEFAULT 0, owner_request_id TEXT, updated_at TEXT NOT NULL); + `); + + database.prepare(`INSERT INTO workspace_snapshot_manifests (id, workspace_id, cwd, canonical_root, state, fingerprint, provenance_watermark, entry_count, total_bytes, created_at) VALUES ('legacy-manifest', 'workspace_default', '/tmp', '/tmp', 'sealed', ?, 'workspace_default:0', 0, 0, '2026-08-26T00:00:00.000Z')`).run("a".repeat(64)); + database.close(); + database = new DatabaseSync(databasePath); + const input = { + database, + hasMigration: (version) => Boolean(database.prepare("SELECT 1 FROM schema_migrations WHERE version = ?").get(version)), + hasTable: (name) => Boolean(database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)), + recordMigration: (version, name) => database.prepare("INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)").run(version, name, "2026-08-26T00:00:00.000Z"), + }; + + assert.equal(applyWorkspaceSnapshotMigration(input), true); + const manifestSql = database.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workspace_snapshot_manifests'").get().sql; + const entrySql = database.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workspace_snapshot_entries'").get().sql; + const journalSql = database.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workspace_snapshot_restore_journal'").get().sql; + const requestSql = database.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workspace_snapshot_restore_requests'").get().sql; + assert.match(manifestSql, /'committed'/u); + assert.match(entrySql, /'symlink'/u); + assert.match(journalSql, /'symlink'/u); + assert.match(requestSql, /'fenced'/u); + assert.match(requestSql, /'staged'/u); + assert.match(requestSql, /'committing'/u); + assert.match(requestSql, /'verifying'/u); + assert.doesNotMatch(requestSql, /'prepared'/u); + assert.doesNotMatch(requestSql, /'applying'/u); + assert.equal(database.prepare("SELECT state FROM workspace_snapshot_manifests WHERE id = 'legacy-manifest'").get().state, "sealed"); + database.prepare(`INSERT INTO workspace_snapshot_restore_anchors (id, workspace_id, thread_id, target_turn_id, checkpoint_id, baseline_snapshot_id, baseline_fingerprint, baseline_provenance_watermark, expected_workspace_fingerprint, expected_provenance_watermark, created_at) VALUES ('legacy-anchor', 'workspace_default', 'thread', 'turn', 'checkpoint', 'legacy-manifest', ?, 'workspace_default:0', ?, 'workspace_default:0', '2026-08-26T00:00:00.000Z')`).run("a".repeat(64), "b".repeat(64)); + database.prepare(`INSERT INTO workspace_snapshot_restore_requests (id, idempotency_key, workspace_id, anchor_id, snapshot_id, expected_workspace_fingerprint, expected_provenance_watermark, state, created_at, updated_at) VALUES ('new-request', 'k', 'workspace_default', 'legacy-anchor', 'legacy-manifest', ?, 'workspace_default:0', 'fenced', '2026-08-26T00:00:00.000Z', '2026-08-26T00:00:00.000Z')`).run("b".repeat(64)); + database.close(); + database = new DatabaseSync(databasePath); + const reopenedInput = { + database, + hasMigration: (version) => Boolean(database.prepare("SELECT 1 FROM schema_migrations WHERE version = ?").get(version)), + hasTable: (name) => Boolean(database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)), + recordMigration: (version, name) => database.prepare("INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)").run(version, name, "2026-08-26T00:00:00.000Z"), + }; + assert.equal(applyWorkspaceSnapshotMigration(reopenedInput), false); + assert.ok(database.prepare("SELECT 1 FROM schema_migrations WHERE version = ?").get(WORKSPACE_SNAPSHOT_MIGRATION_VERSION)); + assert.equal(database.prepare("SELECT state FROM workspace_snapshot_restore_requests WHERE id = 'new-request'").get().state, "fenced"); + } finally { + try { database.close(); } catch {} + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts index 6a48d8d32..932523674 100644 --- a/packages/daemon/src/config.ts +++ b/packages/daemon/src/config.ts @@ -13,6 +13,19 @@ export interface DaemonConfig { readonly channels?: ChannelFeatureConfig; readonly cuaDriver?: CuaDriverFeatureConfig; readonly remoteAccess?: RemoteAccessFeatureConfig; + readonly workspaceSnapshots?: WorkspaceSnapshotFeatureConfig; +} + +export interface WorkspaceSnapshotFeatureConfig { + readonly captureEnabled?: boolean; + readonly restoreEnabled?: boolean; + readonly restoreUiEnabled?: boolean; + readonly gcEnabled?: boolean; + readonly maxEntries?: number; + readonly maxBytes?: number; + readonly maxFileBytes?: number; + readonly maxSnapshots?: number; + readonly retentionDays?: number; } export interface RemoteAccessFeatureConfig { @@ -149,6 +162,7 @@ export interface ResolvedDaemonConfig { readonly listenerPort: number; readonly managementAllowedOrigins: readonly string[]; }; + readonly workspaceSnapshots: Required; } export const defaultDaemonConfig: DaemonConfig = { @@ -208,9 +222,27 @@ export function resolveConfig(config: DaemonConfig): ResolvedDaemonConfig { "app://ling", ], }, + workspaceSnapshots: { + captureEnabled: config.workspaceSnapshots?.captureEnabled ?? readBooleanEnv(process.env.LING_STACK_WORKSPACE_SNAPSHOT_CAPTURE, false), + restoreEnabled: config.workspaceSnapshots?.restoreEnabled ?? readBooleanEnv(process.env.LING_STACK_WORKSPACE_SNAPSHOT_RESTORE, false), + restoreUiEnabled: config.workspaceSnapshots?.restoreUiEnabled ?? readBooleanEnv(process.env.LING_STACK_WORKSPACE_SNAPSHOT_UI, false), + gcEnabled: config.workspaceSnapshots?.gcEnabled ?? readBooleanEnv(process.env.LING_STACK_WORKSPACE_SNAPSHOT_GC, false), + maxEntries: boundedPositive(config.workspaceSnapshots?.maxEntries, 200_000, 200_000), + maxBytes: boundedPositive(config.workspaceSnapshots?.maxBytes, 2 * 1024 * 1024 * 1024, 2 * 1024 * 1024 * 1024), + maxFileBytes: boundedPositive(config.workspaceSnapshots?.maxFileBytes, 256 * 1024 * 1024, 256 * 1024 * 1024), + maxSnapshots: boundedPositive(config.workspaceSnapshots?.maxSnapshots, 8, 8), + retentionDays: boundedPositive(config.workspaceSnapshots?.retentionDays, 30, 3650), + }, }; } +function boundedPositive(value: number | undefined, fallback: number, hardMax: number): number { + if (value === undefined || !Number.isSafeInteger(value) || value <= 0) { + return fallback; + } + return Math.min(value, hardMax); +} + function readBooleanEnv(value: string | undefined, fallback: boolean): boolean { if (value === undefined) { return fallback; diff --git a/packages/daemon/src/features/runs/workspace-snapshot-restore.ts b/packages/daemon/src/features/runs/workspace-snapshot-restore.ts new file mode 100644 index 000000000..cecdb34d4 --- /dev/null +++ b/packages/daemon/src/features/runs/workspace-snapshot-restore.ts @@ -0,0 +1,818 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { chmod, lstat, mkdir, readdir, readlink, realpath, rename, rm, rmdir, stat, symlink, unlink } from "node:fs/promises"; +import { once } from "node:events"; +import { pipeline } from "node:stream/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { + CreateWorkspaceSnapshotEntryInput, + SnapshotRestoreRequest, + WorkspaceSnapshotEntry, + WorkspaceSnapshotManifest, + WorkspaceSnapshotPreview, +} from "@ling-stack/contracts"; +import { DEFAULT_WORKSPACE_ID, type RunStore } from "@ling-stack/core"; + +const activeRestoreLocks = new Map>(); +const queuedRestoreJobs = new Map>(); +const excludedRoots = new Set([".git", ".ling-stack"]); +const secretPartPattern = /(^|[._-])(env|secret|secrets|token|tokens|credential|credentials|private[-_]?key)([._-]|$)/iu; +const digestPattern = /^[0-9a-f]{64}$/u; + +export class WorkspaceSnapshotRestoreError extends Error { + constructor(readonly statusCode: number, readonly code: string, message: string) { + super(message); + this.name = "WorkspaceSnapshotRestoreError"; + } +} + +export interface WorkspaceSnapshotCaptureOptions { + readonly workspaceId?: string; + readonly ttlMs?: number; + readonly now?: string; + readonly maxEntries?: number; + readonly maxBytes?: number; + readonly maxFileBytes?: number; + readonly maxSnapshots?: number; + readonly captureKind?: "user_restore_point" | "compaction_boundary"; + readonly sourceTurnId?: string; + readonly sourceRunId?: string; + readonly sourceCheckpointId?: string; +} + +export interface WorkspaceSnapshotCaptureResult { + readonly manifest: WorkspaceSnapshotManifest; + readonly entries: readonly WorkspaceSnapshotEntry[]; + readonly excludedPaths: readonly string[]; +} + +export async function captureWorkspaceSnapshot( + store: RunStore, + cwd: string, + options: WorkspaceSnapshotCaptureOptions = {}, +): Promise { + const root = await realpath(resolve(cwd)); + const now = options.now ?? new Date().toISOString(); + const workspaceId = options.workspaceId ?? DEFAULT_WORKSPACE_ID; + return withWorkspaceSnapshotFence(store, root, workspaceId, "capture", async () => { + if (options.maxSnapshots !== undefined && store.listWorkspaceSnapshotManifests({ workspaceId, limit: 10_000 }).filter((manifest) => ["committed", "sealed"].includes(manifest.state)).length >= options.maxSnapshots) { + throw new WorkspaceSnapshotRestoreError(413, "quota_exceeded", `Workspace already has ${options.maxSnapshots} committed snapshots.`); + } + let scan = await scanWorkspace(root, store, true, options); + if (scan.unstablePaths.length > 0) scan = await scanWorkspace(root, store, true, options); + if (scan.unstablePaths.length > 0) throw new WorkspaceSnapshotRestoreError(409, "snapshot_unverified", `Workspace changed while snapshotting: ${scan.unstablePaths.join(", ")}`); + const managedEntries = scan.entries.filter((entry) => entry.managed); + const excludedEntries = scan.entries.filter((entry) => !entry.managed); + if (options.captureKind === "compaction_boundary" && excludedEntries.some((entry) => entry.exclusionReason !== "control_path" && entry.exclusionReason !== "vcs_metadata")) { + throw new WorkspaceSnapshotRestoreError(422, "snapshot_excluded_paths", `Compaction-boundary snapshot contains ${excludedEntries.length} excluded path(s).`); + } + const manifestSha256 = hashManifest({ root, fingerprint: scan.fingerprint, provenanceWatermark: store.getLatestWorkspaceProvenanceWatermark(workspaceId), entries: scan.entries }); + const result = store.commitWorkspaceSnapshot({ + id: `workspace_snapshot_${randomUUID()}`, + workspaceId, + cwd: root, + canonicalRoot: root, + fingerprint: scan.fingerprint, + provenanceWatermark: store.getLatestWorkspaceProvenanceWatermark(workspaceId), + entryCount: managedEntries.length, + totalBytes: scan.managedBytes, + createdAt: now, + sealedAt: now, + expiresAt: options.ttlMs && options.ttlMs > 0 ? new Date(Date.parse(now) + options.ttlMs).toISOString() : undefined, + state: "committed", + schemaVersion: 1, + captureCwd: root, + captureKind: options.captureKind ?? "user_restore_point", + ...(options.sourceTurnId ? { sourceTurnId: options.sourceTurnId } : {}), + ...(options.sourceRunId ? { sourceRunId: options.sourceRunId } : {}), + ...(options.sourceCheckpointId ? { sourceCheckpointId: options.sourceCheckpointId } : {}), + exclusionPolicySha256: exclusionPolicySha256(), + managedEntryCount: managedEntries.length, + excludedEntryCount: excludedEntries.length, + managedBytes: scan.managedBytes, + workspaceFingerprintSha256: scan.fingerprint, + manifestSha256, + }, scan.entries.map((entry) => ({ ...entry, manifestId: "pending" }))); + return { manifest: result.manifest, entries: result.entries, excludedPaths: scan.excludedPaths }; + }); +} + +export async function fingerprintWorkspace(cwd: string): Promise { + return (await scanWorkspace(await realpath(resolve(cwd)), undefined, false, {})).fingerprint; +} + +/** Build a read-only preview; it never creates an anchor and never mutates the workspace. */ +export async function previewWorkspaceSnapshot(store: RunStore, cwd: string, snapshotId: string): Promise { + const manifest = store.getWorkspaceSnapshotManifest(snapshotId); + if (!manifest) throw new WorkspaceSnapshotRestoreError(404, "snapshot_not_found", "Snapshot was not found."); + const root = await realpath(resolve(cwd)); + if (resolve(manifest.canonicalRoot) !== root) throw new WorkspaceSnapshotRestoreError(409, "snapshot_anchor_mismatch", "Snapshot belongs to another workspace root."); + if (!["committed", "sealed"].includes(manifest.state)) throw new WorkspaceSnapshotRestoreError(410, "snapshot_unavailable", "Snapshot is no longer restorable."); + const entries = store.listWorkspaceSnapshotEntries(manifest.id); + const current = await scanWorkspace(root, store, false, {}); + const targetManaged = new Map(entries.filter((entry) => entry.managed).map((entry) => [entry.relativePath, entry])); + const currentManaged = new Map(current.entries.filter((entry) => entry.managed).map((entry) => [entry.relativePath, entry])); + const operations = [...new Set([...targetManaged.keys(), ...currentManaged.keys()])].sort().map((relativePath) => { + const target = targetManaged.get(relativePath); + const observed = currentManaged.get(relativePath); + const operation = !observed ? "create" : !target ? "delete" : entryDigest(target) === entryDigest(observed) && target.kind === observed.kind && target.mode === observed.mode ? "unchanged" : "update"; + return { relativePath, operation, kind: target?.kind ?? observed?.kind ?? "file", managed: true, sizeBytes: target?.sizeBytes ?? observed?.sizeBytes ?? 0 } as const; + }); + return { manifest, entries, operations, currentFingerprint: current.fingerprint, targetFingerprint: manifest.fingerprint, fingerprintStatus: current.fingerprint === manifest.fingerprint ? "matching" : "changed" }; +} + +/** Create an anchor for an explicit user restore point with no Turn/compaction lineage. */ +export async function createWorkspaceSnapshotUserRestoreAnchor(store: RunStore, cwd: string, snapshotId: string, now = new Date().toISOString()) { + const manifest = store.getWorkspaceSnapshotManifest(snapshotId); + if (!manifest) throw new WorkspaceSnapshotRestoreError(404, "snapshot_not_found", "Snapshot was not found."); + const root = await realpath(resolve(cwd)); + if (resolve(manifest.canonicalRoot) !== root) throw new WorkspaceSnapshotRestoreError(409, "snapshot_anchor_mismatch", "Snapshot belongs to another workspace root."); + if (!manifest.exclusionPolicySha256 || manifest.exclusionPolicySha256 !== exclusionPolicySha256()) throw new WorkspaceSnapshotRestoreError(409, "snapshot_policy_drift", "Snapshot exclusion policy differs from the current daemon policy."); + const current = await scanWorkspace(root, store, false, {}); + return store.createWorkspaceSnapshotRestoreAnchor({ + workspaceId: manifest.workspaceId, + baselineSnapshotId: manifest.id, + baselineFingerprint: manifest.fingerprint, + baselineProvenanceWatermark: manifest.provenanceWatermark, + expectedWorkspaceFingerprint: current.fingerprint, + expectedProvenanceWatermark: store.getLatestWorkspaceProvenanceWatermark(manifest.workspaceId), + createdAt: now, + ...(manifest.expiresAt ? { expiresAt: manifest.expiresAt } : {}), + }); +} + +export async function createWorkspaceSnapshotRestoreAnchor( + store: RunStore, + input: Parameters[0], +) { + if (!input.threadId || !input.targetTurnId || !input.checkpointId) throw new WorkspaceSnapshotRestoreError(422, "snapshot_anchor_lineage_required", "Turn restore anchors require thread, target Turn, and compaction checkpoint lineage."); + const manifest = store.getWorkspaceSnapshotManifest(input.baselineSnapshotId); + if (!manifest || !["committed", "sealed"].includes(manifest.state)) throw new WorkspaceSnapshotRestoreError(409, "snapshot_unverified", "Baseline snapshot is not committed."); + if (manifest.fingerprint !== input.baselineFingerprint || manifest.provenanceWatermark !== input.baselineProvenanceWatermark) { + throw new WorkspaceSnapshotRestoreError(409, "snapshot_anchor_mismatch", "Baseline snapshot does not match the requested restore anchor."); + } + const excludedEntries = store.listWorkspaceSnapshotEntries(manifest.id).filter((entry) => !entry.managed); + if (excludedEntries.some((entry) => entry.exclusionReason !== "control_path" && entry.exclusionReason !== "vcs_metadata")) throw new WorkspaceSnapshotRestoreError(422, "snapshot_excluded_paths", "A Turn restore anchor cannot use a partially covered snapshot."); + const checkpoint = store.getCompactionCheckpoint(input.checkpointId); + if (!checkpoint || checkpoint.threadId !== input.threadId || checkpoint.workspaceId !== (input.workspaceId ?? checkpoint.workspaceId)) { + throw new WorkspaceSnapshotRestoreError(409, "snapshot_anchor_mismatch", "Compaction checkpoint does not match the restore anchor."); + } + return store.createWorkspaceSnapshotRestoreAnchor(input); +} + +export async function restoreWorkspaceSnapshot( + store: RunStore, + cwd: string, + input: { + readonly idempotencyKey: string; + readonly anchorId: string; + readonly snapshotId: string; + readonly expectedWorkspaceFingerprint?: string; + readonly expectedProvenanceWatermark?: string; + readonly confirmationId?: string; + readonly now?: string; + }, +): Promise { + const root = await realpath(resolve(cwd)); + if (!input.idempotencyKey.trim()) throw new WorkspaceSnapshotRestoreError(400, "idempotency_key_required", "Snapshot restore requires an idempotency key."); + return withRestoreLock(root, async () => restoreWorkspaceSnapshotLocked(store, root, input)); +} + +/** + * Queue a restore behind a durable request. The caller receives 202 for a new + * or in-flight request; the existing synchronous function remains the worker + * used by recovery/replay and by direct daemon integrations. + */ +export async function queueWorkspaceSnapshotRestore( + store: RunStore, + cwd: string, + input: Parameters[2], +): Promise { + const root = await realpath(resolve(cwd)); + if (!input.idempotencyKey.trim()) throw new WorkspaceSnapshotRestoreError(400, "idempotency_key_required", "Snapshot restore requires an idempotency key."); + const now = input.now ?? new Date().toISOString(); + const anchor = store.getWorkspaceSnapshotRestoreAnchor(input.anchorId); + const manifest = store.getWorkspaceSnapshotManifest(input.snapshotId); + if (!anchor || !manifest) throw new WorkspaceSnapshotRestoreError(404, "snapshot_not_found", "Snapshot restore anchor or manifest was not found."); + if (anchor.expiresAt && Date.parse(anchor.expiresAt) <= Date.parse(now)) throw new WorkspaceSnapshotRestoreError(410, "anchor_expired", "Snapshot restore anchor has expired."); + if (manifest.expiresAt && Date.parse(manifest.expiresAt) <= Date.parse(now)) throw new WorkspaceSnapshotRestoreError(410, "snapshot_expired", "Snapshot has expired."); + if (anchor.baselineSnapshotId !== manifest.id || manifest.workspaceId !== anchor.workspaceId || resolve(manifest.canonicalRoot) !== root) throw new WorkspaceSnapshotRestoreError(409, "snapshot_anchor_mismatch", "Snapshot restore target does not match this workspace."); + if (!["committed", "sealed"].includes(manifest.state)) throw new WorkspaceSnapshotRestoreError(422, "snapshot_unverified", "Only a committed snapshot can be restored."); + if (!input.confirmationId) throw new WorkspaceSnapshotRestoreError(409, "confirmation_required", "A durable local-user restore confirmation is required."); + const confirmation = store.getSnapshotRestoreConfirmation(input.confirmationId); + if (!confirmation || confirmation.workspaceId !== anchor.workspaceId || confirmation.anchorId !== anchor.id || confirmation.snapshotId !== manifest.id) throw new WorkspaceSnapshotRestoreError(409, "confirmation_mismatch", "Restore confirmation does not match this snapshot anchor."); + if (confirmation.state !== "confirmed") throw new WorkspaceSnapshotRestoreError(409, "confirmation_required", "A durable local-user restore confirmation is required."); + const expectedFingerprint = input.expectedWorkspaceFingerprint ?? anchor.expectedWorkspaceFingerprint; + const expectedWatermark = input.expectedProvenanceWatermark ?? anchor.expectedProvenanceWatermark; + let request = store.getSnapshotRestoreRequestByIdempotency(anchor.workspaceId, input.idempotencyKey); + if (request) { + if (request.anchorId !== anchor.id || request.snapshotId !== manifest.id || request.expectedWorkspaceFingerprint !== expectedFingerprint || request.expectedProvenanceWatermark !== expectedWatermark) throw new WorkspaceSnapshotRestoreError(409, "idempotency_conflict", "Idempotency key is bound to another restore target."); + if (request.state === "committed") return request; + if (["failed", "conflict", "expired", "recovery_required"].includes(request.state)) throw new WorkspaceSnapshotRestoreError(409, "restore_terminal", request.lastError ?? `Restore request is ${request.state}; use a new idempotency key.`); + } else { + request = store.createSnapshotRestoreRequest({ idempotencyKey: input.idempotencyKey, workspaceId: anchor.workspaceId, anchorId: anchor.id, snapshotId: manifest.id, expectedWorkspaceFingerprint: expectedFingerprint, expectedProvenanceWatermark: expectedWatermark, confirmationId: input.confirmationId, createdAt: now, updatedAt: now }); + } + if (!queuedRestoreJobs.has(request.id)) { + const job = Promise.resolve().then(() => restoreWorkspaceSnapshot(store, root, { ...input, expectedWorkspaceFingerprint: expectedFingerprint, expectedProvenanceWatermark: expectedWatermark })).catch((error) => { + const current = store.getSnapshotRestoreRequest(request!.id); + if (current && !["committed", "failed", "conflict", "expired", "recovery_required"].includes(current.state)) { + const state = error instanceof WorkspaceSnapshotRestoreError && error.statusCode === 410 ? "expired" : error instanceof WorkspaceSnapshotRestoreError && error.statusCode === 409 ? "conflict" : "failed"; + return store.updateSnapshotRestoreRequest({ id: current.id, expectedState: current.state, state, lastError: error instanceof Error ? error.message : String(error), updatedAt: new Date().toISOString() }) ?? current; + } + // The HTTP request has already returned the durable request ID. Keep + // background failures observable through that record, not as an + // unhandled promise rejection in the daemon process. + return current ?? request!; + }).finally(() => { queuedRestoreJobs.delete(request!.id); }); + queuedRestoreJobs.set(request.id, job); + } + return request; +} + +export async function isWorkspaceSnapshotRestoreActive(cwd: string): Promise { + return activeRestoreLocks.has(await realpath(resolve(cwd))); +} + +export async function reconcileWorkspaceSnapshotRestore(store: RunStore, cwd: string, restoreRequestId: string): Promise { + const request = store.getSnapshotRestoreRequest(restoreRequestId); + if (!request) throw new WorkspaceSnapshotRestoreError(404, "restore_not_found", "Snapshot restore request was not found."); + if (["committed", "failed", "conflict", "expired", "recovery_required"].includes(request.state)) return request; + const manifest = store.getWorkspaceSnapshotManifest(request.snapshotId); + if (!manifest) throw new WorkspaceSnapshotRestoreError(409, "snapshot_missing", "Snapshot manifest is missing."); + const journal = store.listSnapshotRestoreJournalEntries({ restoreRequestId, limit: 100_000 }); + const root = await realpath(resolve(cwd)); + const currentFingerprint = await fingerprintWorkspace(root); + const currentWatermark = store.getLatestWorkspaceProvenanceWatermark(request.workspaceId); + if (!journal.length) { + if (currentFingerprint === request.expectedWorkspaceFingerprint && currentWatermark === request.expectedProvenanceWatermark) { + store.releaseWorkspaceSnapshotFence(request.workspaceId, request.id, new Date().toISOString()); + if (request.state === "requested") return request; + return store.updateSnapshotRestoreRequest({ id: request.id, expectedState: request.state, state: "requested", updatedAt: new Date().toISOString() }) ?? store.getSnapshotRestoreRequest(request.id)!; + } + return store.updateSnapshotRestoreRequest({ id: request.id, expectedState: request.state, state: "recovery_required", lastError: "Workspace changed before the restore plan was durably staged; manual review is required.", updatedAt: new Date().toISOString() }) ?? store.getSnapshotRestoreRequest(request.id)!; + } + let allAfter = true; + let allBefore = true; + for (const entry of journal) { + const current = await pathDigest(root, entry.relativePath); + if (entry.expectedAfterSha256 !== current) allAfter = false; + if (entry.expectedBeforeSha256 !== current) allBefore = false; + } + const now = new Date().toISOString(); + if (allAfter && currentFingerprint === manifest.fingerprint) { + for (const entry of journal) { + if (entry.state === "pending") { + store.updateSnapshotRestoreJournalEntry({ id: entry.id, expectedState: "pending", state: "applied", updatedAt: now }); + } + } + return store.updateSnapshotRestoreRequest({ id: request.id, expectedState: request.state, state: "committed", updatedAt: now }) ?? store.getSnapshotRestoreRequest(request.id)!; + } + if (allBefore && currentFingerprint === request.expectedWorkspaceFingerprint && currentWatermark === request.expectedProvenanceWatermark) { + store.releaseWorkspaceSnapshotFence(request.workspaceId, request.id, now); + return store.updateSnapshotRestoreRequest({ id: request.id, expectedState: request.state, state: "requested", updatedAt: now }) ?? store.getSnapshotRestoreRequest(request.id)!; + } + return store.updateSnapshotRestoreRequest({ id: request.id, expectedState: request.state, state: "recovery_required", lastError: "Workspace is in a mixed restore state; manual review is required.", updatedAt: now }) ?? store.getSnapshotRestoreRequest(request.id)!; +} + +export async function garbageCollectWorkspaceSnapshots( + store: RunStore, + options: { readonly maxSnapshots?: number; readonly retentionDays?: number; readonly now?: string } = {}, +): Promise<{ readonly expiredSnapshots: number; readonly deletedBlobs: number; readonly reclaimedBytes: number }> { + const now = options.now ?? new Date().toISOString(); + const cutoff = Date.parse(now) - (options.retentionDays ?? 30) * 86_400_000; + const workspaceId = DEFAULT_WORKSPACE_ID; + const manifests = store.listWorkspaceSnapshotManifests({ workspaceId, limit: 10_000 }); + let expiredSnapshots = 0; + let reclaimedBytes = 0; + const maxSnapshots = options.maxSnapshots ?? 8; + const committed = manifests.filter((manifest) => ["committed", "sealed"].includes(manifest.state)); + const expire = committed.filter((manifest, index) => (manifest.expiresAt && Date.parse(manifest.expiresAt) <= Date.parse(now)) || Date.parse(manifest.createdAt) < cutoff || index >= maxSnapshots); + for (const manifest of expire) { + if (store.expireWorkspaceSnapshotManifest(manifest.id, now)) { + expiredSnapshots += 1; + reclaimedBytes += manifest.totalBytes; + store.deleteWorkspaceSnapshotManifest(manifest.id); + } + } + // Rows with refCount=0 are safe only after their object age exceeds the grace period. + const expired = store.listWorkspaceSnapshotManifests({ workspaceId, state: "expired", limit: 10_000 }); + const activeRequestSnapshotIds = new Set(store.listSnapshotRestoreRequests({ workspaceId, limit: 100_000 }).filter((request) => !["committed", "failed", "conflict", "expired"].includes(request.state)).map((request) => request.snapshotId)); + for (const manifest of expired) if (!activeRequestSnapshotIds.has(manifest.id)) store.deleteWorkspaceSnapshotManifest(manifest.id); + let deletedBlobs = 0; + const protectedBlobs = new Set(); + for (const request of store.listSnapshotRestoreRequests({ workspaceId, limit: 100_000 })) { + for (const journal of store.listSnapshotRestoreJournalEntries({ restoreRequestId: request.id, limit: 100_000 })) { + if (journal.rollbackBlobSha256) protectedBlobs.add(journal.rollbackBlobSha256); + if (journal.targetBlobSha256) protectedBlobs.add(journal.targetBlobSha256); + } + } + for (const blob of store.listWorkspaceSnapshotBlobs()) { + if (blob.refCount > 0 || protectedBlobs.has(blob.sha256) || Date.parse(blob.createdAt) > Date.parse(now) - 86_400_000) continue; + const objectPath = join(store.paths.snapshotBlobsDir, blob.sha256); + if (await exists(objectPath)) { + const size = (await stat(objectPath)).size; + await rm(objectPath, { force: true }); + if (store.deleteWorkspaceSnapshotBlob(blob.sha256)) { deletedBlobs += 1; reclaimedBytes += size; } + } else if (store.deleteWorkspaceSnapshotBlob(blob.sha256)) { + deletedBlobs += 1; + } + } + try { + for (const name of await readdir(store.paths.snapshotStagingDir)) { + const stagingPath = join(store.paths.snapshotStagingDir, name); + const info = await stat(stagingPath); + if (info.mtimeMs <= Date.parse(now) - 86_400_000) await rm(stagingPath, { force: true }); + } + } catch { + // Staging cleanup is best effort; object/SQLite GC remains authoritative. + } + return { expiredSnapshots, deletedBlobs, reclaimedBytes }; +} + +async function restoreWorkspaceSnapshotLocked( + store: RunStore, + root: string, + input: Parameters[2], +): Promise { + const now = input.now ?? new Date().toISOString(); + const anchor = store.getWorkspaceSnapshotRestoreAnchor(input.anchorId); + const manifest = store.getWorkspaceSnapshotManifest(input.snapshotId); + if (!anchor || !manifest) throw new WorkspaceSnapshotRestoreError(404, "snapshot_not_found", "Snapshot restore anchor or manifest was not found."); + if (anchor.expiresAt && Date.parse(anchor.expiresAt) <= Date.parse(now)) throw new WorkspaceSnapshotRestoreError(410, "anchor_expired", "Snapshot restore anchor has expired."); + const workspaceId = anchor.workspaceId ?? DEFAULT_WORKSPACE_ID; + if (anchor.baselineSnapshotId !== manifest.id || anchor.workspaceId !== workspaceId || manifest.workspaceId !== workspaceId || resolve(manifest.canonicalRoot) !== root) { + throw new WorkspaceSnapshotRestoreError(409, "snapshot_anchor_mismatch", "Snapshot restore target does not match this workspace."); + } + if (!["committed", "sealed"].includes(manifest.state)) throw new WorkspaceSnapshotRestoreError(409, "snapshot_unverified", "Only a committed snapshot can be restored."); + if (manifest.exclusionPolicySha256 && manifest.exclusionPolicySha256 !== exclusionPolicySha256()) throw new WorkspaceSnapshotRestoreError(409, "snapshot_policy_drift", "Snapshot exclusion policy differs from the current daemon policy."); + if (manifest.expiresAt && Date.parse(manifest.expiresAt) <= Date.parse(now)) throw new WorkspaceSnapshotRestoreError(410, "snapshot_expired", "Snapshot has expired."); + const expectedFingerprint = input.expectedWorkspaceFingerprint ?? anchor.expectedWorkspaceFingerprint; + const expectedWatermark = input.expectedProvenanceWatermark ?? anchor.expectedProvenanceWatermark; + let existing = store.getSnapshotRestoreRequestByIdempotency(workspaceId, input.idempotencyKey); + if (existing) { + if (existing.anchorId !== anchor.id || existing.snapshotId !== manifest.id) throw new WorkspaceSnapshotRestoreError(409, "idempotency_conflict", "Idempotency key is bound to another restore target."); + if (existing.expectedWorkspaceFingerprint !== expectedFingerprint || existing.expectedProvenanceWatermark !== expectedWatermark) throw new WorkspaceSnapshotRestoreError(409, "idempotency_conflict", "Idempotency key is bound to a different workspace compare-and-swap target."); + if (existing.state === "committed") return existing; + if (existing.state === "recovery_required") throw new WorkspaceSnapshotRestoreError(409, "recovery_required", existing.lastError ?? "Restore requires manual recovery."); + if (["conflict", "failed", "expired"].includes(existing.state)) throw new WorkspaceSnapshotRestoreError(409, "restore_terminal", existing.lastError ?? `Restore request is ${existing.state}; use a new idempotency key.`); + if (["validating", "fenced", "staged", "committing", "verifying"].includes(existing.state)) { + const reconciled = await reconcileWorkspaceSnapshotRestore(store, root, existing.id); + if (reconciled.state === "committed") return reconciled; + if (reconciled.state === "recovery_required") throw new WorkspaceSnapshotRestoreError(409, "recovery_required", reconciled.lastError ?? "Restore requires manual recovery."); + if (reconciled.state !== "requested") throw new WorkspaceSnapshotRestoreError(409, "restore_claim_conflict", `Restore request is already in ${reconciled.state}.`); + existing = reconciled; + } + } + if (!input.confirmationId) throw new WorkspaceSnapshotRestoreError(409, "confirmation_required", "A durable local-user restore confirmation is required."); + const confirmation = store.getSnapshotRestoreConfirmation(input.confirmationId); + if (!confirmation || confirmation.workspaceId !== workspaceId || confirmation.anchorId !== anchor.id || confirmation.snapshotId !== manifest.id) throw new WorkspaceSnapshotRestoreError(409, "confirmation_mismatch", "Restore confirmation does not match this snapshot anchor."); + if (confirmation.state !== "confirmed") throw new WorkspaceSnapshotRestoreError(409, "confirmation_required", "A durable local-user restore confirmation is required."); + const confirmationUse = store.listSnapshotRestoreRequests({ workspaceId, limit: 100_000 }).find((request) => request.confirmationId === input.confirmationId && request.id !== existing?.id); + if (confirmationUse) throw new WorkspaceSnapshotRestoreError(409, "confirmation_consumed", "Restore confirmation has already been bound to another request."); + if (await hasActiveWorkspaceRun(store, root)) throw new WorkspaceSnapshotRestoreError(409, "workspace_busy", "Workspace has an active Run or approval; restore is fenced until it is terminal."); + const current = await scanWorkspace(root, store, false, {}); + if (current.fingerprint !== expectedFingerprint) throw new WorkspaceSnapshotRestoreError(409, "workspace_fingerprint_conflict", "Workspace fingerprint changed since the restore anchor was created."); + if (store.getLatestWorkspaceProvenanceWatermark(workspaceId) !== expectedWatermark) throw new WorkspaceSnapshotRestoreError(409, "provenance_watermark_conflict", "Workspace provenance changed since the restore anchor was created."); + const entries = store.listWorkspaceSnapshotEntries(manifest.id); + assertNoExcludedCollision(current.entries, entries.filter((entry) => entry.managed)); + await assertSnapshotIntegrity(store, root, entries); + if (manifest.manifestSha256 && hashManifest({ root: manifest.canonicalRoot, fingerprint: manifest.fingerprint, provenanceWatermark: manifest.provenanceWatermark, entries: entries.map((entry) => ({ relativePath: entry.relativePath, kind: entry.kind, managed: entry.managed, exclusionReason: entry.exclusionReason, symlinkTarget: entry.symlinkTarget, sizeBytes: entry.sizeBytes, sha256: entry.sha256, blobSha256: entry.blobSha256, mode: entry.mode })) }) !== manifest.manifestSha256) { + throw new WorkspaceSnapshotRestoreError(409, "snapshot_corrupt", "Snapshot manifest integrity verification failed."); + } + const request = existing ?? store.createSnapshotRestoreRequest({ + idempotencyKey: input.idempotencyKey, + workspaceId, + anchorId: anchor.id, + snapshotId: manifest.id, + expectedWorkspaceFingerprint: expectedFingerprint, + expectedProvenanceWatermark: expectedWatermark, + confirmationId: input.confirmationId, + createdAt: now, + updatedAt: now, + }); + let lifecycleState = request.state; + if (lifecycleState === "requested") { + const validating = store.updateSnapshotRestoreRequest({ id: request.id, expectedState: "requested", state: "validating", updatedAt: now }); + if (!validating) throw new WorkspaceSnapshotRestoreError(409, "restore_claim_conflict", "Another restore request owns this idempotency key."); + lifecycleState = "validating"; + } + if (lifecycleState !== "validating") throw new WorkspaceSnapshotRestoreError(409, "restore_claim_conflict", `Restore request is already in ${lifecycleState}.`); + const epoch = store.acquireWorkspaceSnapshotFence(workspaceId, request.id, now); + if (await hasActiveWorkspaceRun(store, root)) { + store.releaseWorkspaceSnapshotFence(workspaceId, request.id, new Date().toISOString()); + throw new WorkspaceSnapshotRestoreError(409, "workspace_busy", "A Run became active while restore fencing was acquired."); + } + const fenced = store.updateSnapshotRestoreRequest({ id: request.id, expectedState: "validating", state: "fenced", fencingEpoch: epoch, updatedAt: now }); + if (!fenced) { + store.releaseWorkspaceSnapshotFence(workspaceId, request.id, new Date().toISOString()); + throw new WorkspaceSnapshotRestoreError(409, "restore_claim_conflict", "Another restore request owns this idempotency key."); + } + try { + let journal = store.listSnapshotRestoreJournalEntries({ restoreRequestId: request.id, limit: 100_000 }); + if (journal.length === 0) { + const plan = await buildRestorePlan(root, entries.filter((entry) => entry.managed), store, current.entries.filter((entry) => entry.managed)); + journal = store.createSnapshotRestoreJournalEntries(plan.map((entry, sequence) => ({ ...entry, restoreRequestId: request.id, sequence, updatedAt: now }))); + } else { + // A restart may reconcile a staged request back to `requested` after + // confirming that every entry is still at its pre-restore digest. Keep + // the durable plan and reset only recoverable journal states; inserting + // a second plan would violate the request's unique path constraint. + for (const entry of journal) { + if (entry.state === "pending") continue; + if (entry.state !== "applied" && entry.state !== "rolled_back") { + throw new WorkspaceSnapshotRestoreError(409, "recovery_required", `Restore journal entry ${entry.relativePath} is not retryable.`); + } + if (!store.updateSnapshotRestoreJournalEntry({ id: entry.id, expectedState: entry.state, state: "pending", updatedAt: now })) { + throw new WorkspaceSnapshotRestoreError(409, "restore_journal_conflict", `Restore journal entry was superseded: ${entry.relativePath}`); + } + } + journal = store.listSnapshotRestoreJournalEntries({ restoreRequestId: request.id, limit: 100_000 }); + } + const staged = store.updateSnapshotRestoreRequest({ id: request.id, expectedState: "fenced", state: "staged", fencingEpoch: epoch, journalEntryCount: journal.length, updatedAt: now }); + if (!staged) throw new WorkspaceSnapshotRestoreError(409, "restore_claim_conflict", "Restore request was superseded."); + const committing = store.updateSnapshotRestoreRequest({ id: request.id, expectedState: "staged", state: "committing", fencingEpoch: epoch, updatedAt: new Date().toISOString() }); + if (!committing) throw new WorkspaceSnapshotRestoreError(409, "restore_claim_conflict", "Restore request was superseded."); + const applied: typeof journal = []; + try { + for (const entry of journal) { + const beforeFence = store.getWorkspaceSnapshotFence(workspaceId); + if (beforeFence.ownerRequestId !== request.id || beforeFence.epoch !== epoch) throw new WorkspaceSnapshotRestoreError(409, "stale_restore_fence", "Workspace restore fence was superseded before apply."); + const before = await pathDigest(root, entry.relativePath); + if (before !== entry.expectedBeforeSha256) throw new WorkspaceSnapshotRestoreError(409, "workspace_changed_during_restore", `Workspace changed before ${entry.relativePath} was applied.`); + await applyJournalEntry(root, entry, store); + applied.push(entry); + const after = await pathDigest(root, entry.relativePath); + if (after !== entry.expectedAfterSha256) throw new WorkspaceSnapshotRestoreError(500, "restore_verify_failed", `Restore verification failed for ${entry.relativePath}.`); + if (!store.updateSnapshotRestoreJournalEntry({ id: entry.id, expectedState: "pending", state: "applied", updatedAt: new Date().toISOString() })) throw new WorkspaceSnapshotRestoreError(409, "restore_journal_conflict", `Restore journal entry was superseded: ${entry.relativePath}`); + const fence = store.getWorkspaceSnapshotFence(workspaceId); + if (fence.ownerRequestId !== request.id || fence.epoch !== epoch) throw new WorkspaceSnapshotRestoreError(409, "stale_restore_fence", "Workspace restore fence was superseded during apply."); + } + const verifying = store.updateSnapshotRestoreRequest({ id: request.id, expectedState: "committing", state: "verifying", fencingEpoch: epoch, updatedAt: new Date().toISOString() }); + if (!verifying) throw new WorkspaceSnapshotRestoreError(409, "restore_claim_conflict", "Restore request was superseded."); + const finalFingerprint = await fingerprintWorkspace(root); + if (finalFingerprint !== manifest.fingerprint) throw new WorkspaceSnapshotRestoreError(500, "restore_fingerprint_failed", "Workspace fingerprint does not match the snapshot after restore."); + } catch (error) { + const rollbackError = await rollbackAppliedEntries(root, applied, store); + const message = rollbackError ? `${error instanceof Error ? error.message : String(error)}; rollback failed: ${rollbackError}` : (error instanceof Error ? error.message : String(error)); + const currentState = store.getSnapshotRestoreRequest(request.id)?.state ?? "committing"; + store.updateSnapshotRestoreRequest({ id: request.id, expectedState: currentState, state: rollbackError ? "recovery_required" : "failed", fencingEpoch: epoch, lastError: message, updatedAt: new Date().toISOString() }); + throw error instanceof WorkspaceSnapshotRestoreError ? error : new WorkspaceSnapshotRestoreError(500, "restore_failed", message); + } + return store.updateSnapshotRestoreRequest({ id: request.id, expectedState: "verifying", state: "committed", fencingEpoch: epoch, updatedAt: new Date().toISOString() }) ?? store.getSnapshotRestoreRequest(request.id)!; + } catch (error) { + const currentRequest = store.getSnapshotRestoreRequest(request.id); + if (currentRequest && !["committed", "failed", "conflict", "expired", "recovery_required"].includes(currentRequest.state)) { + store.updateSnapshotRestoreRequest({ + id: request.id, + expectedState: currentRequest.state, + state: "failed", + fencingEpoch: epoch, + lastError: error instanceof Error ? error.message : String(error), + updatedAt: new Date().toISOString(), + }); + } + throw error; + } finally { + store.releaseWorkspaceSnapshotFence(workspaceId, request.id, new Date().toISOString()); + } +} + +function assertNoExcludedCollision(currentEntries: readonly ScannedEntry[], targetEntries: readonly WorkspaceSnapshotEntry[]): void { + const excluded = currentEntries.filter((entry) => !entry.managed); + for (const target of targetEntries) { + for (const entry of excluded) { + const targetCoversExcluded = target.kind !== "directory" && entry.relativePath.startsWith(`${target.relativePath}/`); + const targetIsInsideExcluded = target.relativePath.startsWith(`${entry.relativePath}/`); + if (entry.relativePath === target.relativePath || targetCoversExcluded || targetIsInsideExcluded) { + throw new WorkspaceSnapshotRestoreError(409, "excluded_path_conflict", `Restore target intersects excluded path ${entry.relativePath} (${entry.exclusionReason ?? "unsupported_type"}).`); + } + } + } +} + +interface ScanResult { + readonly entries: readonly ScannedEntry[]; + readonly fingerprint: string; + readonly managedBytes: number; + readonly excludedPaths: readonly string[]; + readonly unstablePaths: readonly string[]; +} +interface ScannedEntry { + readonly relativePath: string; + readonly kind: "file" | "directory" | "symlink"; + readonly managed: boolean; + readonly exclusionReason?: "control_path" | "vcs_metadata" | "secret_like_path" | "too_large" | "unsupported_type" | "external_symlink" | "case_collision"; + readonly symlinkTarget?: string; + readonly sizeBytes: number; + readonly sha256?: string; + readonly blobSha256?: string; + readonly mode?: number; +} + +async function scanWorkspace(root: string, store: RunStore | undefined, persistBlobs: boolean, options: WorkspaceSnapshotCaptureOptions): Promise { + const entries: ScannedEntry[] = []; + const excludedPaths: string[] = []; + const unstablePaths: string[] = []; + let managedBytes = 0; + const seenCaseInsensitive = new Map(); + const addExcluded = async (absolute: string, rel: string, reason: ScannedEntry["exclusionReason"]): Promise => { + const info = await lstat(absolute); + const kind = info.isDirectory() ? "directory" : info.isSymbolicLink() ? "symlink" : info.isFile() ? "file" : "file"; + entries.push({ relativePath: rel, kind, managed: false, exclusionReason: reason, sizeBytes: info.isFile() ? info.size : 0, mode: info.mode & 0o777, + ...(info.isSymbolicLink() ? { symlinkTarget: await readlink(absolute) } : {}) }); + excludedPaths.push(rel); + }; + const isWithinRoot = (candidate: string): boolean => { + const relation = relative(root, resolve(candidate)); + return relation === "" || (!relation.startsWith("..") && !relation.startsWith(sep) && !relation.includes(`${sep}..${sep}`)); + }; + const walk = async (directory: string): Promise => { + const children = await readdir(directory, { withFileTypes: true }); + for (const child of children) { + const absolute = join(directory, child.name); + const rel = relative(root, absolute).split(sep).join("/"); + if (excludedRoots.has(child.name)) { await addExcluded(absolute, rel, child.name === ".git" ? "vcs_metadata" : "control_path"); continue; } + if (secretPartPattern.test(child.name)) { await addExcluded(absolute, rel, "secret_like_path"); continue; } + const caseKey = rel.normalize("NFC").toLocaleLowerCase(); + const priorCasePath = seenCaseInsensitive.get(caseKey); + if (priorCasePath && priorCasePath !== rel) { await addExcluded(absolute, rel, "case_collision"); continue; } + seenCaseInsensitive.set(caseKey, rel); + if (child.isSymbolicLink()) { + const target = await readlink(absolute); + const targetPath = resolve(dirname(absolute), target); + const targetRelation = relative(root, targetPath); + const internal = !isAbsolute(target) && isWithinRoot(targetPath) && !targetRelation.split(sep).some((part) => excludedRoots.has(part) || secretPartPattern.test(part)); + const info = await lstat(absolute); + if (!internal) { entries.push({ relativePath: rel, kind: "symlink", managed: false, exclusionReason: "external_symlink", symlinkTarget: target, sizeBytes: 0, mode: info.mode & 0o777 }); excludedPaths.push(rel); continue; } + entries.push({ relativePath: rel, kind: "symlink", managed: true, symlinkTarget: target, sizeBytes: 0, mode: info.mode & 0o777 }); + continue; + } + if (child.isDirectory()) { + entries.push({ relativePath: rel, kind: "directory", managed: true, sizeBytes: 0, mode: (await lstat(absolute)).mode & 0o777 }); + await walk(absolute); + continue; + } + if (!child.isFile()) { await addExcluded(absolute, rel, "unsupported_type"); continue; } + const before = await lstat(absolute); + if (options.maxFileBytes !== undefined && before.size > options.maxFileBytes) { + entries.push({ relativePath: rel, kind: "file", managed: false, exclusionReason: "too_large", sizeBytes: before.size, mode: before.mode & 0o777 }); + excludedPaths.push(rel); + continue; + } + const digest = await hashFile(absolute, persistBlobs ? store!.paths.snapshotStagingDir : undefined); + const after = await lstat(absolute); + if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) unstablePaths.push(rel); + managedBytes += after.size; + if (options.maxBytes !== undefined && managedBytes > options.maxBytes) { + if (digest.tempPath) await rm(digest.tempPath, { force: true }); + throw new WorkspaceSnapshotRestoreError(413, "quota_exceeded", `Snapshot exceeds the workspace byte quota (${options.maxBytes}).`); + } + if (persistBlobs && store) { + const blobPath = join(store.paths.snapshotBlobsDir, digest.sha256); + await mkdir(dirname(blobPath), { recursive: true }); + if (!(await exists(blobPath))) await rename(digest.tempPath!, blobPath); + else await rm(digest.tempPath!, { force: true }); + if ((await hashFile(blobPath)).sha256 !== digest.sha256) throw new WorkspaceSnapshotRestoreError(500, "snapshot_blob_integrity", `Stored snapshot blob failed integrity verification: ${rel}.`); + store.upsertWorkspaceSnapshotBlob({ sha256: digest.sha256, sizeBytes: after.size, contentType: "application/octet-stream", createdAt: new Date().toISOString(), refCount: store.getWorkspaceSnapshotBlob(digest.sha256)?.refCount ?? 0 }); + } + entries.push({ relativePath: rel, kind: "file", managed: true, sizeBytes: after.size, sha256: digest.sha256, blobSha256: digest.sha256, mode: after.mode & 0o777 }); + } + }; + await walk(root); + if (options.maxEntries !== undefined && entries.length > options.maxEntries) throw new WorkspaceSnapshotRestoreError(413, "quota_exceeded", `Snapshot exceeds the workspace entry quota (${options.maxEntries}).`); + return { entries, fingerprint: fingerprintEntries(entries.filter((entry) => entry.managed)), managedBytes, excludedPaths, unstablePaths }; +} + +async function buildRestorePlan(root: string, targetEntries: readonly WorkspaceSnapshotEntry[], store: RunStore, currentEntries: readonly ScannedEntry[]) { + const target = new Map(targetEntries.map((entry) => [entry.relativePath, entry])); + const current = new Map(currentEntries.map((entry) => [entry.relativePath, entry])); + const depth = (value: string) => value.split("/").length; + const targetFilePaths = new Set(targetEntries.filter((entry) => entry.kind === "file" || entry.kind === "symlink").map((entry) => entry.relativePath)); + const mustDeleteFirst = [...current.keys()].filter((path) => !target.has(path) || [...targetFilePaths].some((filePath) => path.startsWith(`${filePath}/`))) + .sort((a, b) => depth(b) - depth(a) || a.localeCompare(b)); + const targetPaths = [...target.keys()].sort((a, b) => depth(a) - depth(b) || a.localeCompare(b)); + const ordered = [...new Set([...mustDeleteFirst, ...targetPaths])]; + const plan = [] as Array<{ + relativePath: string; expectedBeforeSha256?: string; expectedAfterSha256?: string; + rollbackBlobSha256?: string; rollbackSymlinkTarget?: string; targetBlobSha256?: string; targetSymlinkTarget?: string; + rollbackMode?: number; targetMode?: number; targetKind: "file" | "directory" | "symlink"; targetExists: boolean; + }>; + for (const relativePath of ordered) { + const before = current.get(relativePath); + const after = target.get(relativePath); + let rollbackBlobSha256: string | undefined; + if (before?.kind === "file" && before.sha256) rollbackBlobSha256 = before.sha256; + if (before?.kind === "file" && before.sha256 && !store.getWorkspaceSnapshotBlob(before.sha256)) { + const source = join(root, relativePath); const digest = await hashAndStoreBlob(source, store); rollbackBlobSha256 = digest; + } + plan.push({ relativePath, + ...(before ? { expectedBeforeSha256: before.sha256 ?? entryDigest(before) } : {}), + ...(after ? { expectedAfterSha256: after.sha256 ?? entryDigest(after) } : {}), + ...(rollbackBlobSha256 ? { rollbackBlobSha256 } : {}), ...(before?.symlinkTarget !== undefined ? { rollbackSymlinkTarget: before.symlinkTarget } : {}), + ...(before?.mode === undefined ? {} : { rollbackMode: before.mode }), ...(after?.blobSha256 ? { targetBlobSha256: after.blobSha256 } : {}), + ...(after?.symlinkTarget !== undefined ? { targetSymlinkTarget: after.symlinkTarget } : {}), ...(after?.mode === undefined ? {} : { targetMode: after.mode }), + targetKind: after?.kind ?? before?.kind ?? "file", targetExists: Boolean(after) }); + } + return plan; +} + +async function applyJournalEntry(root: string, entry: { readonly relativePath: string; readonly targetBlobSha256?: string; readonly targetSymlinkTarget?: string; readonly targetMode?: number; readonly targetKind: "file" | "directory" | "symlink"; readonly targetExists: boolean }, store: RunStore): Promise { + const absolute = safePath(root, entry.relativePath); + if (!entry.targetExists) { + await removeEntry(absolute); + return; + } + if (entry.targetKind === "symlink" && entry.targetSymlinkTarget !== undefined) { + await removeEntry(absolute); + await mkdir(dirname(absolute), { recursive: true }); + await symlink(entry.targetSymlinkTarget, absolute); + return; + } + if (!entry.targetBlobSha256 && entry.targetKind === "directory") { + try { + const info = await lstat(absolute); + if (!info.isDirectory()) await removeEntry(absolute); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await mkdir(absolute, { recursive: true }); + if (entry.targetMode !== undefined) await chmod(absolute, entry.targetMode); + return; + } + if (!entry.targetBlobSha256) { + await removeEntry(absolute); + return; + } + const blobPath = join(store.paths.snapshotBlobsDir, entry.targetBlobSha256); + if (!(await exists(blobPath))) throw new WorkspaceSnapshotRestoreError(409, "snapshot_blob_missing", `Snapshot blob is missing: ${entry.targetBlobSha256}`); + await mkdir(dirname(absolute), { recursive: true }); + try { + const info = await lstat(absolute); + if (info.isDirectory() || info.isSymbolicLink()) await removeEntry(absolute); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const temp = `${absolute}.ling-restore-${randomUUID()}`; + try { + await copyFileStreaming(blobPath, temp); + await rename(temp, absolute); + } catch (error) { + await rm(temp, { force: true }); + throw error; + } + if (entry.targetMode !== undefined) await chmod(absolute, entry.targetMode); +} + +async function rollbackAppliedEntries(root: string, entries: readonly { readonly relativePath: string; readonly rollbackBlobSha256?: string; readonly rollbackSymlinkTarget?: string; readonly rollbackMode?: number; readonly expectedBeforeSha256?: string; readonly expectedAfterSha256?: string; readonly targetKind: "file" | "directory" | "symlink"; readonly targetExists: boolean; readonly id?: string }[], store: RunStore): Promise { + try { + for (const entry of [...entries].reverse()) { + const absolute = safePath(root, entry.relativePath); + const current = await pathDigest(root, entry.relativePath); + if (current !== entry.expectedAfterSha256) throw new Error(`Cannot rollback ${entry.relativePath}: workspace changed after the restore entry was applied.`); + if (entry.rollbackSymlinkTarget !== undefined) { + await removeEntry(absolute); await mkdir(dirname(absolute), { recursive: true }); await symlink(entry.rollbackSymlinkTarget, absolute); + } else if (entry.rollbackBlobSha256) { + const blobPath = join(store.paths.snapshotBlobsDir, entry.rollbackBlobSha256); + const temporary = `${absolute}.ling-rollback-${randomUUID()}`; + await mkdir(dirname(absolute), { recursive: true }); + try { + const info = await lstat(absolute); + if (info.isDirectory() || info.isSymbolicLink()) await removeEntry(absolute); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + try { + await copyFileStreaming(blobPath, temporary); + await rename(temporary, absolute); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } + if (entry.rollbackMode !== undefined) await chmod(absolute, entry.rollbackMode); + } else if (entry.expectedBeforeSha256 === directoryDigest(entry.relativePath)) { + await removeEntry(absolute); + await mkdir(absolute, { recursive: true }); + if (entry.rollbackMode !== undefined) await chmod(absolute, entry.rollbackMode); + } else if (entry.expectedBeforeSha256 === undefined) { + await removeEntry(absolute); + } + if ("id" in entry && typeof entry.id === "string") { + const updatedAt = new Date().toISOString(); + store.updateSnapshotRestoreJournalEntry({ id: entry.id, expectedState: "applied", state: "rolled_back", updatedAt }) + ?? store.updateSnapshotRestoreJournalEntry({ id: entry.id, expectedState: "pending", state: "rolled_back", updatedAt }); + } + } + return undefined; + } catch (error) { return error instanceof Error ? error.message : String(error); } +} + +async function assertSnapshotIntegrity(store: RunStore, root: string, entries: readonly WorkspaceSnapshotEntry[]): Promise { + for (const entry of entries) { + if (!entry.managed) continue; + if (entry.kind === "file") { + if (!entry.blobSha256 || !digestPattern.test(entry.blobSha256) || !store.getWorkspaceSnapshotBlob(entry.blobSha256)) throw new WorkspaceSnapshotRestoreError(409, "snapshot_blob_missing", `Snapshot blob is missing for ${entry.relativePath}.`); + if (entry.sha256 !== entry.blobSha256) throw new WorkspaceSnapshotRestoreError(409, "snapshot_corrupt", `Snapshot entry digest mismatch for ${entry.relativePath}.`); + const blobPath = join(store.paths.snapshotBlobsDir, entry.blobSha256); + if (!(await exists(blobPath)) || (await hashFile(blobPath)).sha256 !== entry.blobSha256) throw new WorkspaceSnapshotRestoreError(409, "snapshot_corrupt", `Snapshot blob is corrupt for ${entry.relativePath}.`); + } + if (entry.kind === "symlink") { + if (entry.symlinkTarget === undefined) throw new WorkspaceSnapshotRestoreError(409, "snapshot_corrupt", `Snapshot symlink target is missing for ${entry.relativePath}.`); + const target = resolve(dirname(safePath(root, entry.relativePath)), entry.symlinkTarget); + const relation = relative(root, target); + if (relation.startsWith("..") || relation.startsWith(sep) || relation.split(sep).some((part) => excludedRoots.has(part) || secretPartPattern.test(part))) { + throw new WorkspaceSnapshotRestoreError(409, "snapshot_symlink_escape", `Snapshot symlink escapes the managed workspace: ${entry.relativePath}.`); + } + } + } +} + +async function hashAndStoreBlob(path: string, store: RunStore): Promise { + const digest = await hashFile(path, store.paths.snapshotStagingDir); + const finalPath = join(store.paths.snapshotBlobsDir, digest.sha256); await mkdir(dirname(finalPath), { recursive: true }); + if (!(await exists(finalPath))) await rename(digest.tempPath!, finalPath); else await rm(digest.tempPath!, { force: true }); + store.upsertWorkspaceSnapshotBlob({ sha256: digest.sha256, sizeBytes: digest.sizeBytes, contentType: "application/octet-stream", createdAt: new Date().toISOString(), refCount: store.getWorkspaceSnapshotBlob(digest.sha256)?.refCount ?? 0 }); + return digest.sha256; +} + +async function hashFile(path: string, stagingDir?: string): Promise<{ readonly sha256: string; readonly sizeBytes: number; readonly tempPath?: string }> { + const hash = createHash("sha256"); + const temporary = stagingDir ? join(stagingDir, `blob-${randomUUID()}`) : undefined; + if (temporary) { await mkdir(dirname(temporary), { recursive: true }); } + const input = createReadStream(path); const output = temporary ? createWriteStream(temporary, { flags: "wx" }) : undefined; + let sizeBytes = 0; + try { + for await (const chunk of input) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + hash.update(buffer); sizeBytes += buffer.byteLength; + if (output && !output.write(buffer)) await once(output, "drain"); + } + if (output) { output.end(); await once(output, "close"); } + } catch (error) { + output?.destroy(); + throw error; + } + return { sha256: hash.digest("hex"), sizeBytes, ...(temporary ? { tempPath: temporary } : {}) }; +} + +async function copyFileStreaming(source: string, destination: string): Promise { + await mkdir(dirname(destination), { recursive: true }); + await pipeline(createReadStream(source), createWriteStream(destination, { flags: "wx" })); +} + +async function hasActiveWorkspaceRun(store: RunStore, root: string): Promise { + for (const run of store.listRuns({ limit: 200 })) { + if (!["queued", "running", "waiting_for_approval"].includes(run.status)) continue; + try { + if (await realpath(resolve(run.cwd)) === root) return true; + } catch { + if (resolve(run.cwd) === root) return true; + } + } + return false; +} + +async function withWorkspaceSnapshotFence( + store: RunStore, + root: string, + workspaceId: string, + operationKind: "capture", + operation: () => Promise, +): Promise { + if (await hasActiveWorkspaceRun(store, root)) { + throw new WorkspaceSnapshotRestoreError(409, "workspace_busy", `${operationKind} cannot run while the workspace has an active Run or approval.`); + } + const ownerRequestId = `workspace_snapshot_${operationKind}_${randomUUID()}`; + let acquired = false; + try { + store.acquireWorkspaceSnapshotFence(workspaceId, ownerRequestId, new Date().toISOString()); + acquired = true; + if (await hasActiveWorkspaceRun(store, root)) { + throw new WorkspaceSnapshotRestoreError(409, "workspace_busy", `${operationKind} was fenced after a Run became active.`); + } + return await operation(); + } finally { + if (acquired) store.releaseWorkspaceSnapshotFence(workspaceId, ownerRequestId, new Date().toISOString()); + } +} + +async function pathDigest(root: string, relativePath: string): Promise { + const absolute = safePath(root, relativePath); + try { const info = await lstat(absolute); if (info.isSymbolicLink()) return symlinkDigest(await readlink(absolute)); if (info.isDirectory()) return directoryDigest(relativePath); if (!info.isFile()) return "unsupported"; return (await hashFile(absolute)).sha256; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } +} +function directoryDigest(relativePath: string): string { return createHash("sha256").update(`directory:${relativePath}`).digest("hex"); } +function symlinkDigest(target: string): string { return createHash("sha256").update(`symlink:${target}`).digest("hex"); } +function entryDigest(entry: { readonly relativePath: string; readonly kind: "file" | "directory" | "symlink"; readonly sha256?: string; readonly symlinkTarget?: string }): string { return entry.kind === "file" ? entry.sha256 ?? "" : entry.kind === "symlink" ? symlinkDigest(entry.symlinkTarget ?? "") : directoryDigest(entry.relativePath); } +function fingerprintEntries(entries: readonly { readonly relativePath: string; readonly kind: "file" | "directory" | "symlink"; readonly sha256?: string; readonly symlinkTarget?: string }[]): string { const hash = createHash("sha256"); for (const entry of [...entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) hash.update(`${entry.relativePath}\0${entry.kind}\0${entryDigest(entry)}\n`); return hash.digest("hex"); } +function exclusionPolicySha256(): string { return createHash("sha256").update(JSON.stringify({ version: 1, excludedRoots: [...excludedRoots].sort(), secretPartPattern: secretPartPattern.source })).digest("hex"); } +function hashManifest(input: { readonly root: string; readonly fingerprint: string; readonly provenanceWatermark: string; readonly entries: readonly ScannedEntry[] }): string { + const canonical = JSON.stringify({ schemaVersion: 1, canonicalRoot: input.root, workspaceFingerprintSha256: input.fingerprint, + provenanceWatermark: input.provenanceWatermark, exclusionPolicySha256: exclusionPolicySha256(), entries: [...input.entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath)).map((entry) => ({ + path: entry.relativePath, kind: entry.kind, managed: entry.managed, reason: entry.exclusionReason ?? null, + mode: entry.mode ?? null, size: entry.sizeBytes, digest: entryDigest(entry), symlinkTarget: entry.symlinkTarget ?? null, + })) }); + return createHash("sha256").update(canonical).digest("hex"); +} +function safePath(root: string, relativePath: string): string { const candidate = resolve(root, relativePath); const relation = relative(root, candidate); if (relation.startsWith("..") || relation.includes(`..${sep}`) || relation.startsWith(sep) || relation === ".") throw new WorkspaceSnapshotRestoreError(400, "invalid_snapshot_path", `Snapshot path escapes workspace: ${relativePath}`); return candidate; } +async function removeEntry(path: string): Promise { try { const info = await lstat(path); if (info.isDirectory()) await rmdir(path); else await unlink(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } +async function exists(path: string): Promise { try { await stat(path); return true; } catch { return false; } } +async function withRestoreLock(root: string, operation: () => Promise): Promise { const prior = activeRestoreLocks.get(root); if (prior) throw new WorkspaceSnapshotRestoreError(409, "workspace_restore_busy", "Another workspace restore is already running."); let release!: () => void; const lock = new Promise((resolveLock) => { release = resolveLock; }); activeRestoreLocks.set(root, lock); try { return await operation(); } finally { release(); activeRestoreLocks.delete(root); } } diff --git a/packages/daemon/src/features/runs/workspace-snapshot-routes.ts b/packages/daemon/src/features/runs/workspace-snapshot-routes.ts new file mode 100644 index 000000000..7378880fb --- /dev/null +++ b/packages/daemon/src/features/runs/workspace-snapshot-routes.ts @@ -0,0 +1,140 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { DEFAULT_WORKSPACE_ID, type RunStore } from "@ling-stack/core"; +import type { ResolvedDaemonConfig } from "../../config.js"; +import type { RunStoreRegistry } from "../../runtime/store-registry.js"; +import { readJsonBody } from "../../shared/request.js"; +import { writeJson } from "../../shared/response.js"; +import { captureWorkspaceSnapshot, createWorkspaceSnapshotRestoreAnchor, createWorkspaceSnapshotUserRestoreAnchor, garbageCollectWorkspaceSnapshots, isWorkspaceSnapshotRestoreActive, previewWorkspaceSnapshot, queueWorkspaceSnapshotRestore, reconcileWorkspaceSnapshotRestore, WorkspaceSnapshotRestoreError } from "./workspace-snapshot-restore.js"; + +export function parseWorkspaceSnapshotRoute(pathname: string): + | { readonly kind: "collection" } + | { readonly kind: "capture" } + | { readonly kind: "detail"; readonly snapshotId: string } + | { readonly kind: "anchor"; readonly snapshotId?: string } + | { readonly kind: "confirmation"; readonly snapshotId?: string; readonly confirmationId?: string } + | { readonly kind: "confirmation-resolve"; readonly confirmationId: string } + | { readonly kind: "restore"; readonly snapshotId?: string } + | { readonly kind: "restore-detail"; readonly restoreRequestId: string } + | undefined { + const parts = pathname.split("/").filter(Boolean).map(decodeURIComponent); + if (parts.length === 2 && parts[0] === "workspace" && parts[1] === "snapshots") return { kind: "collection" }; + if (parts.length === 3 && parts[0] === "workspace" && parts[1] === "snapshots" && parts[2] === "capture") return { kind: "capture" }; + if (parts.length === 3 && parts[0] === "workspace" && parts[1] === "snapshots" && parts[2] === "anchors") return { kind: "anchor" }; + if (parts.length === 4 && parts[0] === "workspace" && parts[1] === "snapshots" && parts[3] === "anchors") return { kind: "anchor", snapshotId: parts[2] }; + if (parts.length === 4 && parts[0] === "workspace" && parts[1] === "snapshots" && parts[3] === "confirmations") return { kind: "confirmation", snapshotId: parts[2] }; + if (parts.length === 3 && parts[0] === "workspace" && parts[1] === "snapshot-confirmations") return { kind: "confirmation", confirmationId: parts[2] }; + if (parts.length === 4 && parts[0] === "workspace" && parts[1] === "snapshot-confirmations" && parts[3] === "confirm") return { kind: "confirmation-resolve", confirmationId: parts[2] }; + if (parts.length === 3 && parts[0] === "workspace" && parts[1] === "snapshots" && parts[2] === "restore") return { kind: "restore" }; + if (parts.length === 4 && parts[0] === "workspace" && parts[1] === "snapshots" && parts[3] === "restore") return { kind: "restore", snapshotId: parts[2] }; + if (parts.length === 3 && parts[0] === "workspace" && parts[1] === "snapshot-restores") return { kind: "restore-detail", restoreRequestId: parts[2] }; + if (parts.length === 3 && parts[0] === "workspace" && parts[1] === "snapshots") return { kind: "detail", snapshotId: parts[2] }; + if (parts.length === 4 && parts[0] === "workspace" && parts[1] === "snapshots" && parts[2] === "restore") return { kind: "restore-detail", restoreRequestId: parts[3] }; + return undefined; +} + +export async function handleWorkspaceSnapshotRoute( + request: IncomingMessage, + response: ServerResponse, + url: URL, + storeRegistry: RunStoreRegistry, + route: NonNullable>, + config?: ResolvedDaemonConfig, +): Promise { + const cwd = url.searchParams.get("cwd")?.trim(); + if (!cwd) { writeJson(response, 400, { error: "cwd_required", message: "cwd query parameter is required." }); return; } + const store = storeRegistry.getStore(cwd); + try { + if (route.kind === "collection" && request.method === "GET") { + writeJson(response, 200, { cwd: store.paths.cwd, snapshots: store.listWorkspaceSnapshotManifests({ workspaceId: DEFAULT_WORKSPACE_ID }), restoreUiEnabled: config?.workspaceSnapshots.restoreUiEnabled === true && config.workspaceSnapshots.captureEnabled === true && config.workspaceSnapshots.restoreEnabled === true }); + return; + } + if (route.kind === "collection" && request.method === "POST") { + if (config && !config.workspaceSnapshots.captureEnabled) { writeJson(response, 404, { error: "snapshot_capture_disabled", message: "Workspace snapshot capture is disabled by feature flag." }); return; } + const body = await optionalBody(request); + if (config?.workspaceSnapshots.gcEnabled) await garbageCollectWorkspaceSnapshots(store, config.workspaceSnapshots); + const result = await captureWorkspaceSnapshot(store, cwd, { workspaceId: DEFAULT_WORKSPACE_ID, ttlMs: readPositiveInt(body?.ttlMs), maxEntries: config?.workspaceSnapshots.maxEntries, maxBytes: config?.workspaceSnapshots.maxBytes, maxFileBytes: config?.workspaceSnapshots.maxFileBytes, maxSnapshots: config?.workspaceSnapshots.maxSnapshots, captureKind: body?.captureKind === "compaction_boundary" ? "compaction_boundary" : "user_restore_point", sourceTurnId: typeof body?.sourceTurnId === "string" ? body.sourceTurnId : undefined, sourceRunId: typeof body?.sourceRunId === "string" ? body.sourceRunId : undefined, sourceCheckpointId: typeof body?.sourceCheckpointId === "string" ? body.sourceCheckpointId : undefined }); + writeJson(response, 201, result); + return; + } + if (route.kind === "detail" && request.method === "GET") { + const manifest = store.getWorkspaceSnapshotManifest(route.snapshotId); + if (!manifest) { writeJson(response, 404, { error: "snapshot_not_found" }); return; } + writeJson(response, 200, await previewWorkspaceSnapshot(store, cwd, route.snapshotId)); + return; + } + if (route.kind === "capture" && request.method === "POST") { + if (config && !config.workspaceSnapshots.captureEnabled) { writeJson(response, 404, { error: "snapshot_capture_disabled", message: "Workspace snapshot capture is disabled by feature flag." }); return; } + const body = await optionalBody(request); + if (config?.workspaceSnapshots.gcEnabled) await garbageCollectWorkspaceSnapshots(store, config.workspaceSnapshots); + const result = await captureWorkspaceSnapshot(store, cwd, { workspaceId: DEFAULT_WORKSPACE_ID, ttlMs: readPositiveInt(body?.ttlMs), maxEntries: config?.workspaceSnapshots.maxEntries, maxBytes: config?.workspaceSnapshots.maxBytes, maxFileBytes: config?.workspaceSnapshots.maxFileBytes, maxSnapshots: config?.workspaceSnapshots.maxSnapshots, captureKind: body?.captureKind === "compaction_boundary" ? "compaction_boundary" : "user_restore_point", sourceTurnId: typeof body?.sourceTurnId === "string" ? body.sourceTurnId : undefined, sourceRunId: typeof body?.sourceRunId === "string" ? body.sourceRunId : undefined, sourceCheckpointId: typeof body?.sourceCheckpointId === "string" ? body.sourceCheckpointId : undefined }); + writeJson(response, 201, result); + return; + } + if (route.kind === "anchor" && request.method === "POST") { + if (config && !config.workspaceSnapshots.restoreEnabled) { writeJson(response, 404, { error: "snapshot_restore_disabled", message: "Workspace snapshot restore is disabled by feature flag." }); return; } + if (route.snapshotId) { + const anchor = await createWorkspaceSnapshotUserRestoreAnchor(store, cwd, route.snapshotId); + writeJson(response, 201, anchor); + return; + } + const body = await readBody(request); + const anchor = await createWorkspaceSnapshotRestoreAnchor(store, body as unknown as Parameters[0]); + writeJson(response, 201, anchor); + return; + } + if (route.kind === "confirmation" && route.snapshotId && request.method === "POST") { + if (config && !config.workspaceSnapshots.restoreEnabled) { writeJson(response, 404, { error: "snapshot_restore_disabled", message: "Workspace snapshot restore is disabled by feature flag." }); return; } + const body = await readBody(request); + const confirmation = store.createSnapshotRestoreConfirmation({ ...(body as unknown as Parameters[0]), snapshotId: route.snapshotId, workspaceId: DEFAULT_WORKSPACE_ID, createdAt: new Date().toISOString() }); + writeJson(response, 201, confirmation); + return; + } + if (route.kind === "confirmation" && route.confirmationId && request.method === "GET") { + const confirmation = store.getSnapshotRestoreConfirmation(route.confirmationId); + if (!confirmation) { writeJson(response, 404, { error: "confirmation_not_found" }); return; } + writeJson(response, 200, confirmation); + return; + } + if (route.kind === "confirmation-resolve" && request.method === "POST") { + if (config && !config.workspaceSnapshots.restoreEnabled) { writeJson(response, 404, { error: "snapshot_restore_disabled", message: "Workspace snapshot restore is disabled by feature flag." }); return; } + const body = await readBody(request); + const state = body.state === "confirmed" || body.state === "denied" || body.state === "expired" ? body.state : undefined; + if (!state) { writeJson(response, 422, { error: "confirmation_state_invalid" }); return; } + const confirmation = store.resolveSnapshotRestoreConfirmation(route.confirmationId, state, new Date().toISOString()); + if (!confirmation) { writeJson(response, 404, { error: "confirmation_not_found" }); return; } + writeJson(response, 200, confirmation); + return; + } + if (route.kind === "restore" && request.method === "POST") { + if (config && !config.workspaceSnapshots.restoreEnabled) { writeJson(response, 404, { error: "snapshot_restore_disabled", message: "Workspace snapshot restore is disabled by feature flag." }); return; } + const body = await readBody(request); + if (typeof body.confirmationId !== "string" || !body.confirmationId.trim()) { writeJson(response, 409, { error: "confirmation_required", message: "A durable local-user restore confirmation is required." }); return; } + const restore = await queueWorkspaceSnapshotRestore(store, cwd, { ...(body as unknown as Parameters[2]), ...(route.snapshotId ? { snapshotId: route.snapshotId } : {}) }); + writeJson(response, restore.state === "committed" ? 200 : 202, restore); + return; + } + if (route.kind === "restore-detail" && request.method === "GET") { + const restore = store.getSnapshotRestoreRequest(route.restoreRequestId); + if (!restore) { writeJson(response, 404, { error: "restore_not_found" }); return; } + const reconciled = ["requested", "validating", "fenced", "staged", "committing", "verifying"].includes(restore.state) && !(await isWorkspaceSnapshotRestoreActive(cwd)) + ? await reconcileWorkspaceSnapshotRestore(store, cwd, restore.id) + : restore; + writeJson(response, 200, reconciled); + return; + } + writeJson(response, 405, { error: "method_not_allowed" }); + } catch (error) { + const status = error instanceof WorkspaceSnapshotRestoreError ? error.statusCode : 400; + writeJson(response, status, { error: error instanceof WorkspaceSnapshotRestoreError ? error.code : "snapshot_request_invalid", message: error instanceof Error ? error.message : String(error) }); + } +} + +async function readBody(request: IncomingMessage): Promise> { + const body = await readJsonBody(request, 256_000); + if (!body || typeof body !== "object" || Array.isArray(body)) throw new WorkspaceSnapshotRestoreError(400, "invalid_json_body", "Snapshot request body must be a JSON object."); + return body as Record; +} +async function optionalBody(request: IncomingMessage): Promise | undefined> { + try { return await readBody(request); } catch (error) { if (error instanceof Error && /required|valid JSON/iu.test(error.message)) return undefined; throw error; } +} +function readPositiveInt(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } diff --git a/packages/daemon/src/server/router.ts b/packages/daemon/src/server/router.ts index c024b9b13..233afdb1e 100644 --- a/packages/daemon/src/server/router.ts +++ b/packages/daemon/src/server/router.ts @@ -255,6 +255,7 @@ import { handleMutateRunWorkspaceChanges, handleMutateTurnWorkspaceChanges, } from "../features/runs/workspace-change-recovery.js"; +import { handleWorkspaceSnapshotRoute, parseWorkspaceSnapshotRoute } from "../features/runs/workspace-snapshot-routes.js"; import { handleEffectiveConfig, handleWorkspaceSettings } from "../features/settings/routes.js"; import { handleSkillRoute } from "../features/skills/routes.js"; import { handleTaskItem, handleTaskRun, handleTasksCollection } from "../features/tasks/routes.js"; @@ -1456,6 +1457,15 @@ export async function routeRequest( return; } + const workspaceSnapshotRoute = parseWorkspaceSnapshotRoute(url.pathname); + if (workspaceSnapshotRoute) { + if (!allowLocalWorkspaceRequest({ request, response, storeRegistry, cwd: url.searchParams.get("cwd") ?? undefined })) { + return; + } + await handleWorkspaceSnapshotRoute(request, response, url, storeRegistry, workspaceSnapshotRoute, config); + return; + } + if (request.method === "GET" && url.pathname === "/workspace-files/preview") { if (!allowLocalWorkspaceRequest({ request, response, storeRegistry, cwd: url.searchParams.get("cwd") ?? undefined })) { return; diff --git a/packages/daemon/test/workspace-snapshot-restore.test.mjs b/packages/daemon/test/workspace-snapshot-restore.test.mjs new file mode 100644 index 000000000..0d52e3027 --- /dev/null +++ b/packages/daemon/test/workspace-snapshot-restore.test.mjs @@ -0,0 +1,260 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; +import { createSqliteRunStore } from "../../core/dist/index.js"; +import { + captureWorkspaceSnapshot, + createWorkspaceSnapshotRestoreAnchor, + createWorkspaceSnapshotUserRestoreAnchor, + fingerprintWorkspace, + garbageCollectWorkspaceSnapshots, + reconcileWorkspaceSnapshotRestore, + restoreWorkspaceSnapshot, +} from "../dist/features/runs/workspace-snapshot-restore.js"; + +test("snapshot capture/restore is content addressed, fenced, CAS checked, and durable", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-snapshot-restore-")); + const now = "2026-08-26T01:00:00.000Z"; + const store = createSqliteRunStore(cwd); + let outside; + try { + await writeFile(resolve(cwd, "a.txt"), "alpha\n", "utf8"); + await writeFile(resolve(cwd, ".env"), "SECRET=keep\n", "utf8"); + await writeFile(resolve(cwd, "ignored-secret.txt"), "PRIVATE\n", "utf8"); + await writeFile(resolve(cwd, "nested.txt"), "nested\n", "utf8"); + await mkdir(resolve(cwd, "empty-dir")); + await symlink("a.txt", resolve(cwd, "internal-link.txt")); + outside = await mkdtemp(resolve(tmpdir(), "ling-snapshot-outside-")); + await writeFile(resolve(outside, "outside.txt"), "outside\n", "utf8"); + await symlink(resolve(outside, "outside.txt"), resolve(cwd, "external-link.txt")); + const first = await captureWorkspaceSnapshot(store, cwd, { workspaceId: "workspace_default", now }); + const second = await captureWorkspaceSnapshot(store, cwd, { workspaceId: "workspace_default", now: "2026-08-26T01:00:01.000Z" }); + assert.equal(first.manifest.state, "committed"); + assert.equal(first.entries.some((entry) => entry.relativePath === ".env" && entry.managed), false); + assert.equal(first.entries.some((entry) => entry.relativePath === "ignored-secret.txt" && entry.managed), false); + assert.equal(first.entries.find((entry) => entry.relativePath === "internal-link.txt")?.kind, "symlink"); + assert.equal(first.entries.find((entry) => entry.relativePath === "internal-link.txt")?.managed, true); + assert.equal(first.entries.find((entry) => entry.relativePath === "external-link.txt")?.exclusionReason, "external_symlink"); + const firstFile = first.entries.find((entry) => entry.relativePath === "a.txt"); + const secondFile = second.entries.find((entry) => entry.relativePath === "a.txt"); + assert.equal(firstFile?.blobSha256, secondFile?.blobSha256); + assert.equal(store.getWorkspaceSnapshotBlob(firstFile.blobSha256).refCount, 2); + await rm(resolve(cwd, ".env")); + await rm(resolve(cwd, "ignored-secret.txt")); + await rm(resolve(cwd, "external-link.txt")); + const baseline = await captureWorkspaceSnapshot(store, cwd, { workspaceId: "workspace_default", now: "2026-08-26T01:00:02.000Z" }); + await writeFile(resolve(cwd, ".env"), "SECRET=keep\n", "utf8"); + await writeFile(resolve(cwd, "ignored-secret.txt"), "PRIVATE\n", "utf8"); + await symlink(resolve(outside, "outside.txt"), resolve(cwd, "external-link.txt")); + await mkdir(resolve(cwd, "new-empty-dir")); + + const channel = store.createChannel({ name: "Snapshot", boundPath: cwd }, now); + const thread = store.resolveOrCreateConversationThread({ surfaceKind: "channel_root", channelId: channel.id, title: "Snapshot", objective: "Restore" }, now).thread; + const turn = store.appendConversationUserTurn({ + channelId: channel.id, + threadId: thread.id, + title: "Restore", + userInstruction: "Restore", + payload: { body: "Restore", format: "plain_text", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [] }, + idempotencyKey: "snapshot-turn", + }, now); + const checkpoint = store.createCompactionCheckpoint({ + threadId: thread.id, + sourceTurnId: turn.turnId, + coveredItemSeqFrom: turn.item.sequence, + coveredItemSeqTo: turn.item.sequence, + coveredTurnIds: [turn.turnId], + sourceRefs: [{ type: "turn", id: turn.turnId }], + workspaceProvenanceWatermark: "workspace_default:0", + compactionPolicyVersion: "continuity-v2-test", + createdAt: now, + }); + + await writeFile(resolve(cwd, "a.txt"), "changed\n", "utf8"); + await rm(resolve(cwd, "nested.txt")); + await writeFile(resolve(cwd, "new.txt"), "new\n", "utf8"); + const expectedFingerprint = await fingerprintWorkspace(cwd); + const anchor = await createWorkspaceSnapshotRestoreAnchor(store, { + threadId: thread.id, + targetTurnId: turn.turnId, + checkpointId: checkpoint.id, + baselineSnapshotId: baseline.manifest.id, + baselineFingerprint: baseline.manifest.fingerprint, + baselineProvenanceWatermark: baseline.manifest.provenanceWatermark, + expectedWorkspaceFingerprint: expectedFingerprint, + expectedProvenanceWatermark: "workspace_default:0", + createdAt: now, + }); + const confirmation = store.createSnapshotRestoreConfirmation({ anchorId: anchor.id, snapshotId: baseline.manifest.id, workspaceId: "workspace_default", createdAt: now }); + store.resolveSnapshotRestoreConfirmation(confirmation.id, "confirmed", now); + + const restored = await restoreWorkspaceSnapshot(store, cwd, { + idempotencyKey: "restore-1", + anchorId: anchor.id, + snapshotId: baseline.manifest.id, + confirmationId: confirmation.id, + now: "2026-08-26T01:00:02.000Z", + }); + assert.equal(restored.state, "committed"); + assert.equal(await readFile(resolve(cwd, "a.txt"), "utf8"), "alpha\n"); + assert.equal(await readFile(resolve(cwd, "nested.txt"), "utf8"), "nested\n"); + assert.equal((await stat(resolve(cwd, "empty-dir"))).isDirectory(), true); + await assert.rejects(() => stat(resolve(cwd, "new-empty-dir")), /ENOENT/); + await assert.rejects(() => readFile(resolve(cwd, "new.txt")), /ENOENT/); + assert.equal(await readFile(resolve(cwd, ".env"), "utf8"), "SECRET=keep\n"); + assert.equal(await readlink(resolve(cwd, "internal-link.txt")), "a.txt"); + assert.equal(await readlink(resolve(cwd, "external-link.txt")), resolve(outside, "outside.txt")); + const replay = await restoreWorkspaceSnapshot(store, cwd, { idempotencyKey: "restore-1", anchorId: anchor.id, snapshotId: baseline.manifest.id, confirmationId: confirmation.id }); + assert.equal(replay.id, restored.id); + const journal = store.listSnapshotRestoreJournalEntries({ restoreRequestId: restored.id }); + assert.equal(journal.length, restored.journalEntryCount); + assert.equal(journal.every((entry) => entry.state === "applied"), true); + + store.close(); + const reopened = createSqliteRunStore(cwd); + assert.equal(reopened.getWorkspaceSnapshotManifest(first.manifest.id)?.state, "committed"); + assert.equal(reopened.getSnapshotRestoreRequest(restored.id)?.state, "committed"); + reopened.close(); + } finally { + try { store.close(); } catch {} + await rm(cwd, { recursive: true, force: true }); + try { await rm(outside, { recursive: true, force: true }); } catch {} + } +}); + +test("snapshot restore rejects exact fingerprint drift and corrupt blobs", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-snapshot-cas-")); + const store = createSqliteRunStore(cwd); + try { + await writeFile(resolve(cwd, "file.txt"), "before\n", "utf8"); + const snapshot = await captureWorkspaceSnapshot(store, cwd, { workspaceId: "workspace_default", now: "2026-08-26T02:00:00.000Z" }); + const channel = store.createChannel({ name: "CAS", boundPath: cwd }, "2026-08-26T02:00:00.000Z"); + const thread = store.resolveOrCreateConversationThread({ surfaceKind: "channel_root", channelId: channel.id, title: "CAS", objective: "CAS" }, "2026-08-26T02:00:00.000Z").thread; + const turn = store.appendConversationUserTurn({ channelId: channel.id, threadId: thread.id, title: "CAS", userInstruction: "CAS", payload: { body: "CAS", format: "plain_text", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [] }, idempotencyKey: "cas-turn" }, "2026-08-26T02:00:00.000Z"); + const checkpoint = store.createCompactionCheckpoint({ threadId: thread.id, sourceTurnId: turn.turnId, coveredItemSeqFrom: turn.item.sequence, coveredItemSeqTo: turn.item.sequence, coveredTurnIds: [turn.turnId], sourceRefs: [{ type: "turn", id: turn.turnId }], workspaceProvenanceWatermark: "workspace_default:0", compactionPolicyVersion: "test", createdAt: "2026-08-26T02:00:00.000Z" }); + await writeFile(resolve(cwd, "file.txt"), "after\n", "utf8"); + const anchor = await createWorkspaceSnapshotRestoreAnchor(store, { threadId: thread.id, targetTurnId: turn.turnId, checkpointId: checkpoint.id, baselineSnapshotId: snapshot.manifest.id, baselineFingerprint: snapshot.manifest.fingerprint, baselineProvenanceWatermark: snapshot.manifest.provenanceWatermark, expectedWorkspaceFingerprint: await fingerprintWorkspace(cwd), expectedProvenanceWatermark: "workspace_default:0", createdAt: "2026-08-26T02:00:00.000Z" }); + const confirmation = store.createSnapshotRestoreConfirmation({ anchorId: anchor.id, snapshotId: snapshot.manifest.id, workspaceId: "workspace_default", createdAt: "2026-08-26T02:00:00.000Z" }); + store.resolveSnapshotRestoreConfirmation(confirmation.id, "confirmed", "2026-08-26T02:00:00.000Z"); + await writeFile(resolve(store.paths.snapshotBlobsDir, snapshot.entries.find((entry) => entry.relativePath === "file.txt").blobSha256), "tampered\n", "utf8"); + await assert.rejects(() => restoreWorkspaceSnapshot(store, cwd, { idempotencyKey: "cas-restore", anchorId: anchor.id, snapshotId: snapshot.manifest.id, confirmationId: confirmation.id }), /corrupt/u); + } finally { + try { store.close(); } catch {} + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("snapshot restore requires a matching durable confirmation", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-snapshot-confirmation-")); + const store = createSqliteRunStore(cwd); + const now = "2026-08-26T03:00:00.000Z"; + try { + await writeFile(resolve(cwd, "file.txt"), "before\n", "utf8"); + const snapshot = await captureWorkspaceSnapshot(store, cwd, { workspaceId: "workspace_default", now }); + const channel = store.createChannel({ name: "Confirmation", boundPath: cwd }, now); + const thread = store.resolveOrCreateConversationThread({ surfaceKind: "channel_root", channelId: channel.id, title: "Confirmation", objective: "Restore" }, now).thread; + const turn = store.appendConversationUserTurn({ channelId: channel.id, threadId: thread.id, title: "Restore", userInstruction: "Restore", payload: { body: "Restore", format: "plain_text", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [] }, idempotencyKey: "confirmation-turn" }, now); + const checkpoint = store.createCompactionCheckpoint({ threadId: thread.id, sourceTurnId: turn.turnId, coveredItemSeqFrom: turn.item.sequence, coveredItemSeqTo: turn.item.sequence, coveredTurnIds: [turn.turnId], sourceRefs: [{ type: "turn", id: turn.turnId }], workspaceProvenanceWatermark: "workspace_default:0", compactionPolicyVersion: "test", createdAt: now }); + await writeFile(resolve(cwd, "file.txt"), "after\n", "utf8"); + const anchor = await createWorkspaceSnapshotRestoreAnchor(store, { threadId: thread.id, targetTurnId: turn.turnId, checkpointId: checkpoint.id, baselineSnapshotId: snapshot.manifest.id, baselineFingerprint: snapshot.manifest.fingerprint, baselineProvenanceWatermark: snapshot.manifest.provenanceWatermark, expectedWorkspaceFingerprint: await fingerprintWorkspace(cwd), expectedProvenanceWatermark: "workspace_default:0", createdAt: now }); + await assert.rejects(() => restoreWorkspaceSnapshot(store, cwd, { idempotencyKey: "confirmation-required", anchorId: anchor.id, snapshotId: snapshot.manifest.id }), (error) => error?.code === "confirmation_required"); + const pending = store.createSnapshotRestoreConfirmation({ anchorId: anchor.id, snapshotId: snapshot.manifest.id, workspaceId: "workspace_default", createdAt: now }); + await assert.rejects(() => restoreWorkspaceSnapshot(store, cwd, { idempotencyKey: "confirmation-pending", anchorId: anchor.id, snapshotId: snapshot.manifest.id, confirmationId: pending.id }), (error) => error?.code === "confirmation_required"); + store.resolveSnapshotRestoreConfirmation(pending.id, "denied", now); + await assert.rejects(() => restoreWorkspaceSnapshot(store, cwd, { idempotencyKey: "confirmation-denied", anchorId: anchor.id, snapshotId: snapshot.manifest.id, confirmationId: pending.id }), (error) => error?.code === "confirmation_required"); + const confirmed = store.createSnapshotRestoreConfirmation({ anchorId: anchor.id, snapshotId: snapshot.manifest.id, workspaceId: "workspace_default", createdAt: now }); + store.resolveSnapshotRestoreConfirmation(confirmed.id, "confirmed", now); + const restored = await restoreWorkspaceSnapshot(store, cwd, { idempotencyKey: "confirmation-ok", anchorId: anchor.id, snapshotId: snapshot.manifest.id, confirmationId: confirmed.id }); + assert.equal(restored.state, "committed"); + } finally { + try { store.close(); } catch {} + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("snapshot quota rejects before exposing a partial manifest", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-snapshot-quota-")); + const store = createSqliteRunStore(cwd); + try { + await writeFile(resolve(cwd, "large.txt"), "1234567890", "utf8"); + await assert.rejects(() => captureWorkspaceSnapshot(store, cwd, { workspaceId: "workspace_default", maxBytes: 5, now: "2026-08-26T04:00:00.000Z" }), (error) => error?.code === "quota_exceeded"); + assert.equal(store.listWorkspaceSnapshotManifests({ workspaceId: "workspace_default", limit: 10 }).length, 0); + } finally { + try { store.close(); } catch {} + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("snapshot GC expires old manifests and preserves shared blob references", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-snapshot-gc-")); + const store = createSqliteRunStore(cwd); + try { + await writeFile(resolve(cwd, "shared.txt"), "shared\n", "utf8"); + const first = await captureWorkspaceSnapshot(store, cwd, { workspaceId: "workspace_default", now: "2026-08-20T00:00:00.000Z" }); + await writeFile(resolve(cwd, "only-first.txt"), "first\n", "utf8"); + const second = await captureWorkspaceSnapshot(store, cwd, { workspaceId: "workspace_default", now: "2026-08-21T00:00:00.000Z" }); + const shared = first.entries.find((entry) => entry.relativePath === "shared.txt"); + assert.equal(store.getWorkspaceSnapshotBlob(shared.blobSha256).refCount, 2); + const result = await garbageCollectWorkspaceSnapshots(store, { maxSnapshots: 1, retentionDays: 3650, now: "2026-08-23T00:00:00.000Z" }); + assert.equal(result.expiredSnapshots, 1); + assert.equal(store.getWorkspaceSnapshotManifest(first.manifest.id), undefined); + assert.equal(store.getWorkspaceSnapshotManifest(second.manifest.id)?.state, "committed"); + assert.equal(store.getWorkspaceSnapshotBlob(shared.blobSha256).refCount, 1); + } finally { + try { store.close(); } catch {} + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("snapshot restore reuses a durable journal after all-before reconciliation", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-snapshot-retry-journal-")); + const store = createSqliteRunStore(cwd); + const now = "2026-08-26T05:00:00.000Z"; + try { + await writeFile(resolve(cwd, "file.txt"), "before\n", "utf8"); + const snapshot = await captureWorkspaceSnapshot(store, cwd, { workspaceId: "workspace_default", now }); + await writeFile(resolve(cwd, "file.txt"), "after\n", "utf8"); + const anchor = await createWorkspaceSnapshotUserRestoreAnchor(store, cwd, snapshot.manifest.id, now); + const confirmation = store.createSnapshotRestoreConfirmation({ anchorId: anchor.id, snapshotId: snapshot.manifest.id, workspaceId: "workspace_default", createdAt: now }); + store.resolveSnapshotRestoreConfirmation(confirmation.id, "confirmed", now); + const beforeDigest = createHash("sha256").update("after\n").digest("hex"); + const targetDigest = snapshot.entries.find((entry) => entry.relativePath === "file.txt").sha256; + const request = store.createSnapshotRestoreRequest({ + idempotencyKey: "retry-journal", + workspaceId: "workspace_default", + anchorId: anchor.id, + snapshotId: snapshot.manifest.id, + expectedWorkspaceFingerprint: anchor.expectedWorkspaceFingerprint, + expectedProvenanceWatermark: anchor.expectedProvenanceWatermark, + confirmationId: confirmation.id, + state: "staged", + fencingEpoch: 1, + journalEntryCount: 1, + createdAt: now, + updatedAt: now, + }); + store.createSnapshotRestoreJournalEntries([{ + restoreRequestId: request.id, + sequence: 0, + relativePath: "file.txt", + expectedBeforeSha256: beforeDigest, + expectedAfterSha256: targetDigest, + targetBlobSha256: snapshot.entries.find((entry) => entry.relativePath === "file.txt").blobSha256, + targetKind: "file", + targetExists: true, + updatedAt: now, + }]); + const reconciled = await reconcileWorkspaceSnapshotRestore(store, cwd, request.id); + assert.equal(reconciled.state, "requested"); + const restored = await restoreWorkspaceSnapshot(store, cwd, { idempotencyKey: "retry-journal", anchorId: anchor.id, snapshotId: snapshot.manifest.id, confirmationId: confirmation.id }); + assert.equal(restored.state, "committed"); + assert.equal(await readFile(resolve(cwd, "file.txt"), "utf8"), "before\n"); + assert.equal(store.listSnapshotRestoreJournalEntries({ restoreRequestId: request.id }).length, 1); + } finally { + try { store.close(); } catch {} + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/daemon/test/workspace-snapshot-routes.test.mjs b/packages/daemon/test/workspace-snapshot-routes.test.mjs new file mode 100644 index 000000000..c7b8e03c6 --- /dev/null +++ b/packages/daemon/test/workspace-snapshot-routes.test.mjs @@ -0,0 +1,190 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; +import { createDaemonServer } from "../dist/index.js"; +import { parseWorkspaceSnapshotRoute } from "../dist/features/runs/workspace-snapshot-routes.js"; + +test("workspace snapshot routes keep capture, confirmation, restore, and status paths distinct", () => { + assert.deepEqual(parseWorkspaceSnapshotRoute("/workspace/snapshots"), { kind: "collection" }); + assert.deepEqual(parseWorkspaceSnapshotRoute("/workspace/snapshots/capture"), { kind: "capture" }); + assert.deepEqual(parseWorkspaceSnapshotRoute("/workspace/snapshots/snap_1"), { kind: "detail", snapshotId: "snap_1" }); + assert.deepEqual(parseWorkspaceSnapshotRoute("/workspace/snapshots/snap_1/confirmations"), { kind: "confirmation", snapshotId: "snap_1" }); + assert.deepEqual(parseWorkspaceSnapshotRoute("/workspace/snapshot-confirmations/confirmation_1"), { kind: "confirmation", confirmationId: "confirmation_1" }); + assert.deepEqual(parseWorkspaceSnapshotRoute("/workspace/snapshot-confirmations/confirmation_1/confirm"), { kind: "confirmation-resolve", confirmationId: "confirmation_1" }); + assert.deepEqual(parseWorkspaceSnapshotRoute("/workspace/snapshots/snap_1/restore"), { kind: "restore", snapshotId: "snap_1" }); + assert.deepEqual(parseWorkspaceSnapshotRoute("/workspace/snapshot-restores/restore_1"), { kind: "restore-detail", restoreRequestId: "restore_1" }); + assert.deepEqual(parseWorkspaceSnapshotRoute("/workspace/snapshots/restore/restore_1"), { kind: "restore-detail", restoreRequestId: "restore_1" }); + assert.equal(parseWorkspaceSnapshotRoute("/workspace/snapshots/snap_1/unknown"), undefined); +}); + +test("workspace snapshot HTTP routes expose gated capture, detail, and restore errors", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-snapshot-routes-")); + await writeFile(resolve(cwd, "route.txt"), "route\n", "utf8"); + const daemon = createDaemonServer({ + host: "127.0.0.1", + port: 0, + defaultWorkspaceCwd: cwd, + workspaceSnapshots: { + captureEnabled: true, + restoreEnabled: true, + restoreUiEnabled: true, + }, + }); + await daemon.start(); + const address = daemon.server.address(); + assert.ok(address && typeof address === "object"); + const baseUrl = `http://127.0.0.1:${address.port}`; + const query = `?cwd=${encodeURIComponent(cwd)}`; + try { + const capture = await fetch(`${baseUrl}/workspace/snapshots${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + const captureBody = await capture.text(); + assert.equal(capture.status, 201, captureBody); + const captured = JSON.parse(captureBody); + assert.equal(captured.manifest.state, "committed"); + + const collection = await fetch(`${baseUrl}/workspace/snapshots${query}`); + assert.equal(collection.status, 200); + const listed = await collection.json(); + assert.equal(listed.restoreUiEnabled, true); + assert.ok(listed.snapshots.some((snapshot) => snapshot.id === captured.manifest.id)); + + const detail = await fetch(`${baseUrl}/workspace/snapshots/${encodeURIComponent(captured.manifest.id)}${query}`); + assert.equal(detail.status, 200); + const detailed = await detail.json(); + assert.equal(detailed.manifest.id, captured.manifest.id); + assert.ok(detailed.entries.some((entry) => entry.relativePath === "route.txt")); + + await writeFile(resolve(cwd, "route.txt"), "changed\n", "utf8"); + const anchorResponse = await fetch(`${baseUrl}/workspace/snapshots/${encodeURIComponent(captured.manifest.id)}/anchors${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + assert.equal(anchorResponse.status, 201); + const anchor = await anchorResponse.json(); + assert.equal(anchor.threadId, undefined); + + const confirmationResponse = await fetch(`${baseUrl}/workspace/snapshots/${encodeURIComponent(captured.manifest.id)}/confirmations${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ anchorId: anchor.id }), + }); + assert.equal(confirmationResponse.status, 201); + const pendingConfirmation = await confirmationResponse.json(); + const confirmedResponse = await fetch(`${baseUrl}/workspace/snapshot-confirmations/${encodeURIComponent(pendingConfirmation.id)}/confirm${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ state: "confirmed" }), + }); + assert.equal(confirmedResponse.status, 200); + const confirmed = await confirmedResponse.json(); + + const queuedRestore = await fetch(`${baseUrl}/workspace/snapshots/${encodeURIComponent(captured.manifest.id)}/restore${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idempotencyKey: "route-restore-success", anchorId: anchor.id, confirmationId: confirmed.id }), + }); + assert.equal(queuedRestore.status, 202); + const queued = await queuedRestore.json(); + assert.ok(["requested", "validating", "fenced", "staged", "committing", "verifying", "committed"].includes(queued.state)); + let settled = queued; + for (let attempt = 0; attempt < 30 && settled.state !== "committed"; attempt += 1) { + await new Promise((resolveWait) => setTimeout(resolveWait, 20)); + const statusResponse = await fetch(`${baseUrl}/workspace/snapshot-restores/${encodeURIComponent(queued.id)}${query}`); + assert.equal(statusResponse.status, 200); + settled = await statusResponse.json(); + } + assert.equal(settled.state, "committed"); + assert.equal(await readFile(resolve(cwd, "route.txt"), "utf8"), "route\n"); + + const replay = await fetch(`${baseUrl}/workspace/snapshots/${encodeURIComponent(captured.manifest.id)}/restore${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idempotencyKey: "route-restore-success", anchorId: anchor.id, confirmationId: confirmed.id }), + }); + assert.equal(replay.status, 200); + assert.equal((await replay.json()).id, queued.id); + + const restore = await fetch(`${baseUrl}/workspace/snapshots/${encodeURIComponent(captured.manifest.id)}/restore${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idempotencyKey: "route-restore", anchorId: "missing-anchor" }), + }); + assert.equal(restore.status, 409); + assert.equal((await restore.json()).error, "confirmation_required"); + + const missing = await fetch(`${baseUrl}/workspace/snapshots/missing${query}`); + assert.equal(missing.status, 404); + + const expiringCapture = await fetch(`${baseUrl}/workspace/snapshots${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ttlMs: 1 }), + }); + assert.equal(expiringCapture.status, 201); + const expiring = await expiringCapture.json(); + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + const expiredAnchorResponse = await fetch(`${baseUrl}/workspace/snapshots/${encodeURIComponent(expiring.manifest.id)}/anchors${query}`, { method: "POST" }); + assert.equal(expiredAnchorResponse.status, 201); + const expiredAnchor = await expiredAnchorResponse.json(); + const expiredConfirmationResponse = await fetch(`${baseUrl}/workspace/snapshots/${encodeURIComponent(expiring.manifest.id)}/confirmations${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ anchorId: expiredAnchor.id }), + }); + const expiredConfirmation = await expiredConfirmationResponse.json(); + await fetch(`${baseUrl}/workspace/snapshot-confirmations/${encodeURIComponent(expiredConfirmation.id)}/confirm${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ state: "confirmed" }), + }); + const expiredRestore = await fetch(`${baseUrl}/workspace/snapshots/${encodeURIComponent(expiring.manifest.id)}/restore${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idempotencyKey: "expired-restore", anchorId: expiredAnchor.id, confirmationId: expiredConfirmation.id }), + }); + assert.equal(expiredRestore.status, 410); + + const malformedConfirmation = await fetch(`${baseUrl}/workspace/snapshot-confirmations/missing/confirm${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ state: "not-a-confirmation-state" }), + }); + assert.equal(malformedConfirmation.status, 422); + } finally { + await daemon.stop(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workspace snapshot HTTP capture returns 413 before committing an over-quota manifest", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-snapshot-routes-quota-")); + await writeFile(resolve(cwd, "too-large.txt"), "1234567890", "utf8"); + const daemon = createDaemonServer({ + host: "127.0.0.1", + port: 0, + defaultWorkspaceCwd: cwd, + workspaceSnapshots: { captureEnabled: true, restoreEnabled: true, restoreUiEnabled: true, maxBytes: 1 }, + }); + await daemon.start(); + const address = daemon.server.address(); + assert.ok(address && typeof address === "object"); + try { + const response = await fetch(`http://127.0.0.1:${address.port}/workspace/snapshots?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + assert.equal(response.status, 413); + assert.equal((await response.json()).error, "quota_exceeded"); + } finally { + await daemon.stop(); + await rm(cwd, { recursive: true, force: true }); + } +});