diff --git a/apps/web/src/api/workspace-changes.ts b/apps/web/src/api/workspace-changes.ts index fc974e70d..cebb3f67c 100644 --- a/apps/web/src/api/workspace-changes.ts +++ b/apps/web/src/api/workspace-changes.ts @@ -1,11 +1,16 @@ import type { RunWorkspaceChangesResponse, + TurnWorkspaceChangesResponse, WorkflowWorkspaceChangesResponse, WorkspaceChangesResponse, WorkspaceFileOpenOptionsResponse, WorkspaceFileOpenRequest, WorkspaceFileOpenResponse, WorkspaceFileOpenTarget, + WorkspaceSnapshotManifest, + WorkspaceSnapshotPreview, + SnapshotRestoreConfirmation, + SnapshotRestoreRequest, } from "../types.js"; import { daemonClient } from "./client.js"; import { channelWorkCellDemoWorkflowRecovery } from "../lib/work-cell-demo-fixture.js"; @@ -14,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); } @@ -26,6 +63,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/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/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/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/channel-conversation-continuity-v2.md b/docs/design/channel-conversation-continuity-v2.md index 48f866d3e..746a5b20d 100644 --- a/docs/design/channel-conversation-continuity-v2.md +++ b/docs/design/channel-conversation-continuity-v2.md @@ -13,6 +13,94 @@ 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. + +### 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 `ea1f580f0e6cc57fe07dca4f8615be3b28ae35b8`. 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/docs/design/post-compaction-undo.md b/docs/design/post-compaction-undo.md new file mode 100644 index 000000000..9174bcbba --- /dev/null +++ b/docs/design/post-compaction-undo.md @@ -0,0 +1,549 @@ +# Post-Compaction Workspace Undo + +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. + +## 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 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 + +```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" + | "undone" + | "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 --+--> undone (undo committed) + | +--> conflict (CAS/policy/lock mismatch) + | +--> provenance_missing + | +--> snapshot_unverified + | +--> expired + | +--> failed (apply or verification failure) + | + +--> validating --+--> applied (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 status + +The following slices are implemented in the current daemon/core code: + +### S1: provenance and checkpoint persistence (P1) — implemented + +- 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) — implemented + +- 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. +- 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) + +- `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) + +- 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 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 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. + +## 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 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/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/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/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/index.ts b/packages/contracts/src/index.ts index 07a13b2e9..3d2ffe86c 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"; @@ -44,7 +45,8 @@ 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 * 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/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 4338c4c06..595cd9e44 100644 --- a/packages/contracts/src/runtime.ts +++ b/packages/contracts/src/runtime.ts @@ -440,17 +440,234 @@ 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 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: { + 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 { @@ -487,6 +704,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/contracts/src/workspace-changes.ts b/packages/contracts/src/workspace-changes.ts index bd8db209c..21456bbed 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,199 @@ 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 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/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/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/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/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 c8c848807..a1e4f9a4f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -85,6 +85,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 4af14fa89..99fd72ad3 100644 --- a/packages/core/src/run-types.ts +++ b/packages/core/src/run-types.ts @@ -1,8 +1,39 @@ import { randomUUID } from "node:crypto"; import type { ConversationItemV1, + CompactionCheckpoint, + CreateCompactionCheckpointInput, + CreatePostCompactionUndoRecordInput, + CreateWorkspaceMutationRecordInput, LingThreadRelationKindV2, LingThreadSurfaceKindV2, + ListCompactionCheckpointsOptions, + ListPostCompactionUndoRecordsOptions, + ListWorkspaceMutationRecordsOptions, + RunEventDataByType as ContractRunEventDataByType, + RunEventEnvelope as ContractRunEventEnvelope, + RunEventType as ContractRunEventType, + 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, @@ -602,39 +633,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]; @@ -1122,6 +1127,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; @@ -1130,14 +1138,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; @@ -1160,6 +1161,8 @@ export interface CreateRunInput { readonly parentRunId?: string; readonly interventionId?: string; readonly continuationReason?: RunContinuationReason; + readonly contextViewId?: string; + readonly contextViewCreatedAt?: string; readonly createdAt: string; } @@ -1501,7 +1504,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[]; @@ -1540,6 +1543,47 @@ 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[]; + createPostCompactionUndoRecord(input: CreatePostCompactionUndoRecordInput): PostCompactionUndoRecord; + 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 405317fa5..627fd9972 100644 --- a/packages/core/src/storage/sqlite-run-store.ts +++ b/packages/core/src/storage/sqlite-run-store.ts @@ -298,6 +298,7 @@ import { type ListRunsOptions, type RunArtifact, type RunEventEnvelope, + type RunEventDataByType, type RunEventType, type RunContinuationReason, type RunIntervention, @@ -312,6 +313,36 @@ import { type UpdateProposedPlanArtifactInput, type UpdateRunInterventionStatusInput, } from "../run-types.js"; +import type { + CompactionCheckpoint, + CreateCompactionCheckpointInput, + CreateWorkspaceMutationRecordInput, + ListCompactionCheckpointsOptions, + ListWorkspaceMutationRecordsOptions, + WorkspaceMutationRecord, + PostCompactionUndoRecord, + 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, type AppendRuntimeDiagnosticEventInput, @@ -610,6 +641,9 @@ 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"; +import { SqlitePostCompactionUndoStore } from "./sqlite/post-compaction-undo-store.js"; +import { SqliteWorkspaceSnapshotStore } from "./sqlite/workspace-snapshot-store.js"; export interface SqliteRunStoreOptions { readonly cwd: string; @@ -700,6 +734,9 @@ export class SqliteRunStore implements RunStore { private readonly externalWorkScheduler: SqliteExternalWorkSchedulerStore; private readonly externalWorkWorkflows: SqliteExternalWorkWorkflowCoordinator; 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); @@ -708,6 +745,9 @@ 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.postCompactionUndo = new SqlitePostCompactionUndoStore({ database: this.database }); + this.workspaceSnapshots = new SqliteWorkspaceSnapshotStore({ database: this.database }); this.conversationTurnIngest = new SqliteConversationTurnIngestStore( { database: this.database }, this.conversationItems, @@ -744,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( @@ -845,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( @@ -1627,7 +1669,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: RunEventDataByType[K], + ): Extract { return this.runEvents.appendEvent(runId, type, data); } @@ -1673,6 +1719,171 @@ 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); + } + + 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); + } + + 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 7dd92b45f..7b3330e4e 100644 --- a/packages/core/src/storage/sqlite/migrations.ts +++ b/packages/core/src/storage/sqlite/migrations.ts @@ -3,6 +3,12 @@ import { normalizeChannelRuntimePolicy } from "../../workspace-types.js"; import { stringifyJson } from "./helpers.js"; import { removeLegacySkillWorkflowPhaseMetadata } from "./skill-capability-migration.js"; import { CONTINUITY_V2_STORAGE_MIGRATION_VERSION } from "./continuity-v2-migration.js"; +import { + applyRunContextViewLineageMigration, +} 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; @@ -5641,4 +5647,29 @@ export function applySqliteRunStoreMigrations(context: SqliteRunStoreMigrationCo context.recordMigration(MEDIA_PROJECT_ASSETS_MIGRATION_VERSION, MEDIA_PROJECT_ASSETS_MIGRATION_NAME); } } + + applyRunContextViewLineageMigration({ + database, + hasMigration: context.hasMigration.bind(context), + 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), + }); + applyPostCompactionUndoMigration({ + database, + hasMigration: context.hasMigration.bind(context), + 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/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..091c9112f --- /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 ?? "available", + 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/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-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 36965eebb..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 { @@ -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"), @@ -234,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/run-session-store.ts b/packages/core/src/storage/sqlite/run-session-store.ts index 2b9cedb8f..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); @@ -75,13 +78,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 +113,8 @@ export class SqliteRunSessionStore { input.parentRunId ?? null, input.interventionId ?? null, input.continuationReason ?? null, + input.contextViewId ?? null, + input.contextViewCreatedAt ?? null, null, null, input.createdAt, @@ -241,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/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/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/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..17033c5f3 --- /dev/null +++ b/packages/core/src/storage/sqlite/workspace-provenance-store.ts @@ -0,0 +1,463 @@ +import { randomUUID } from "node:crypto"; +import { resolve as resolvePath } from "node:path"; +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) => 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, workspaceId)); + 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.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 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, 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}`); + } + } +} + +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/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/src/workspace-types.ts b/packages/core/src/workspace-types.ts index aa88a0a4f..3ec6f6272 100644 --- a/packages/core/src/workspace-types.ts +++ b/packages/core/src/workspace-types.ts @@ -2263,6 +2263,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-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/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/core/test/workspace-provenance.test.mjs b/packages/core/test/workspace-provenance.test.mjs new file mode 100644 index 000000000..61449e34f --- /dev/null +++ b/packages/core/test/workspace-provenance.test.mjs @@ -0,0 +1,180 @@ +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"; +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); + 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, "available"); + 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: "available", + state: "validating", + lastEventSeq: 8, + updatedAt: "2026-08-25T01:00:00.500Z", + })?.state, "validating"); + 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); + const integrity = new DatabaseSync(reopened.paths.databasePath); + try { + assert.deepEqual(integrity.prepare("PRAGMA foreign_key_check").all(), []); + } finally { + integrity.close(); + } + } finally { + reopened.close(); + } + } finally { + try { store.close(); } catch {} + await rm(cwd, { recursive: true, force: true }); + } +}); 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/channels/channel-reply-continuation-context.ts b/packages/daemon/src/features/channels/channel-reply-continuation-context.ts index baa9c7d5a..00e2ac323 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/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/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/conversation-summary-coordinator.ts b/packages/daemon/src/features/channels/context-processing/conversation-summary-coordinator.ts index 3f10bac96..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,9 +1,11 @@ +import { createHash } from "node:crypto"; import type { CommitConversationContextSummaryResult, ConversationContextSummarySourceFence, RunStore, } from "@ling-stack/core"; import type { + CompactionCheckpoint, ConversationContextSummary, ConversationDomainRef, ConversationItemV1, @@ -79,7 +81,8 @@ 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: "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: { readonly store: RunStore; @@ -227,13 +230,50 @@ 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] : []))]; + 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 }; } 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/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/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/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/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..9873b49f9 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)[]; @@ -264,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, @@ -472,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({ @@ -757,7 +770,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 +1049,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 6a92922c5..53976f9c0 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, @@ -593,6 +595,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); @@ -1049,7 +1053,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; } @@ -1061,7 +1068,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 @@ -1249,7 +1260,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/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/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/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 bba0ba27f..9d0d21ecf 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/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/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/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", { diff --git a/packages/daemon/src/features/runs/workspace-change-recovery.ts b/packages/daemon/src/features/runs/workspace-change-recovery.ts index 9dc310869..8779d4b5e 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,467 @@ 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 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" || 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"))) { + 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) { + 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 === "available" || durableRecord?.state === "validating") { + store.updatePostCompactionUndoRecord({ + id: durableRecord.id, + expectedState: durableRecord.state, + 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) { + // 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, + 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, + affectedPaths: observation.completedIntent.files.map((file) => file.path), + createdAt: observation.updatedAt ?? new Date().toISOString(), + }); + 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, + 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."); + } + let preflight: RecoveryPreflight; + try { + preflight = await preflightRecoveryPlanSequence(cwd, turnRecoveryPlanSteps(context.steps, action)); + } catch (error) { + if (durableRecord?.state === "available" || durableRecord?.state === "validating") { + store.updatePostCompactionUndoRecord({ + id: durableRecord.id, + expectedState: durableRecord.state, + state: "conflict", + lastEventSeq: durableRecord.lastEventSeq, + updatedAt: new Date().toISOString(), + }); + } + throw error; + } + const expectedWorkspaceFingerprint = workspaceFingerprint(preflight.before); + if (durableRecord && (durableRecord.state === "available" || durableRecord.state === "validating") && durableRecord.expectedWorkspaceFingerprint !== expectedWorkspaceFingerprint) { + store.updatePostCompactionUndoRecord({ + id: durableRecord.id, + expectedState: durableRecord.state, + 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, + affectedPaths: preflight.paths, + createdAt: new Date().toISOString(), + }); + const recoveryId = randomUUID(); + const startedEvent = 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), + }); + const validating = store.updatePostCompactionUndoRecord({ + id: recoveryRecord.id, + expectedState: recoveryRecord.state === "available" ? "available" : "validating", + state: "validating", + lastEventSeq: startedEvent.seq, + updatedAt: startedEvent.ts, + }); + 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, + 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)), + }); + store.updatePostCompactionUndoRecord({ + id: recoveryRecord.id, + expectedState: "validating", + state: action === "undo" ? "undone" : "applied", + lastEventSeq: terminalEvent.seq, + updatedAt: terminalEvent.ts, + }); + 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 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, + 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 ?? 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 || 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, 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) }; + } +} + +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; + } + // 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) ?? []; + 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 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) => ({ + 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 +716,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 +751,62 @@ 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))); + 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) => { + 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 +815,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 +888,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 +951,7 @@ function observeRecoveryState( return { state: intent.action === "undo" ? "undone" : "applied", updatedAt: pendingEvent.ts, + completedIntent: intent, }; } if (matchesBefore) { @@ -437,7 +999,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 { @@ -451,6 +1018,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)) { @@ -524,6 +1114,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/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/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..233afdb1e 100644 --- a/packages/daemon/src/server/router.ts +++ b/packages/daemon/src/server/router.ts @@ -251,8 +251,11 @@ import { import { handleGetWorkspaceFilePreview } from "../features/previews/workspace-file-preview.js"; import { handleGetRunWorkspaceChanges, + handleGetTurnWorkspaceChanges, 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"; @@ -331,6 +334,7 @@ import { parseProviderRoute, parseRunInterventionRoute, parseRunRoute, + parseTurnRoute, parseSkillRoute, parseTaskRoute, parseTeammateDirectInterventionRoute, @@ -1384,6 +1388,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") { @@ -1430,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/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/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: {} }), diff --git a/packages/daemon/test/conversation-summary-coordinator.test.mjs b/packages/daemon/test/conversation-summary-coordinator.test.mjs index 7e167eda9..5b939cc13 100644 --- a/packages/daemon/test/conversation-summary-coordinator.test.mjs +++ b/packages/daemon/test/conversation-summary-coordinator.test.mjs @@ -49,6 +49,12 @@ 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.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); const secondBatch = []; for (let index = 0; index < 10; index += 1) { @@ -70,6 +76,37 @@ 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 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) => { @@ -119,6 +156,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..4ad189c44 --- /dev/null +++ b/packages/daemon/test/post-compaction-undo.test.mjs @@ -0,0 +1,363 @@ +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"; +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-")); + 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"); + 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( + () => 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 }); + } +}); + +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; +} 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 }); + } +});