Skip to content
Merged
49 changes: 49 additions & 0 deletions apps/web/src/api/workspace-changes.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,6 +19,38 @@ export function getWorkspaceChanges(cwd: string): Promise<WorkspaceChangesRespon
return daemonClient.getWorkspaceChanges(cwd);
}

export function listWorkspaceSnapshots(cwd: string): Promise<{ readonly cwd: string; readonly snapshots: readonly WorkspaceSnapshotManifest[]; readonly restoreUiEnabled?: boolean }> {
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<WorkspaceSnapshotPreview> {
return daemonClient.getWorkspaceSnapshot(cwd, snapshotId);
}

export function createWorkspaceSnapshotAnchor(cwd: string, snapshotId: string): Promise<import("@ling-stack/contracts").WorkspaceSnapshotRestoreAnchor> {
return daemonClient.createWorkspaceSnapshotAnchor(cwd, snapshotId);
}

export function createWorkspaceSnapshotConfirmation(cwd: string, snapshotId: string, anchorId: string): Promise<SnapshotRestoreConfirmation> {
return daemonClient.createWorkspaceSnapshotConfirmation(cwd, snapshotId, { anchorId });
}

export function confirmWorkspaceSnapshotRestore(cwd: string, confirmationId: string): Promise<SnapshotRestoreConfirmation> {
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<SnapshotRestoreRequest> {
return daemonClient.restoreWorkspaceSnapshot(cwd, snapshotId, input);
}

export function getWorkspaceSnapshotRestore(cwd: string, requestId: string): Promise<SnapshotRestoreRequest> {
return daemonClient.getWorkspaceSnapshotRestore(cwd, requestId);
}

export function getRunWorkspaceChanges(cwd: string, runId: string): Promise<RunWorkspaceChangesResponse> {
return daemonClient.getRunWorkspaceChanges(runId, cwd);
}
Expand All @@ -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<TurnWorkspaceChangesResponse> {
return daemonClient.getTurnWorkspaceChanges(turnId, cwd);
}

export function undoTurnWorkspaceChanges(cwd: string, turnId: string, idempotencyKey: string): Promise<TurnWorkspaceChangesResponse> {
return daemonClient.undoTurnWorkspaceChanges(turnId, idempotencyKey, cwd);
}

export function reapplyTurnWorkspaceChanges(cwd: string, turnId: string, idempotencyKey: string): Promise<TurnWorkspaceChangesResponse> {
return daemonClient.reapplyTurnWorkspaceChanges(turnId, idempotencyKey, cwd);
}

export function getWorkflowWorkspaceChanges(cwd: string, workflowId: string): Promise<WorkflowWorkspaceChangesResponse> {
const demo = channelWorkCellDemoWorkflowRecovery({ cwd, workflowId });
if (demo) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -177,6 +179,7 @@ export function WorkspaceChangeReviewPage({
</aside>
) : null}
</div>
{cwd ? <WorkspaceSnapshotRestorePanel cwd={cwd} /> : null}
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<readonly WorkspaceSnapshotManifest[]>([]);
const [enabled, setEnabled] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string>();
const [selected, setSelected] = useState<WorkspaceSnapshotPreview>();
const [dialogOpen, setDialogOpen] = useState(false);
const [restore, setRestore] = useState<SnapshotRestoreRequest>();
const [expanded, setExpanded] = useState(false);
const returnFocusRef = useRef<HTMLButtonElement | null>(null);

async function refresh(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 (
<section className="workspace-snapshot-restore-panel" aria-label={t("Workspace restore points")}>
<header className="workspace-snapshot-restore-head">
<span><strong>{t("Restore points")}</strong><small>{t("Workspace files only; conversation history is unchanged")}</small></span>
<IconButton icon={Camera} label={t("Create restore point")} loading={busy} onClick={() => void createSnapshot()} size="sm" />
</header>
{message ? <InlineFeedback tone="neutral">{message}</InlineFeedback> : null}
{restore && restore.state !== "committed" ? <div className="workspace-snapshot-restore-progress" role="status"><strong>{t("Restore progress")}</strong><span>{restore.state}</span><small>{restore.journalEntryCount} {t("journal entries")}</small></div> : null}
{snapshots.length > 0 ? (
<ul className="workspace-snapshot-restore-list">
{snapshots.slice(0, 8).map((snapshot) => (
<li key={snapshot.id}>
<span className="workspace-snapshot-restore-copy"><strong>{new Date(snapshot.createdAt).toLocaleString()}</strong><small>{snapshot.managedEntryCount ?? snapshot.entryCount} {t("managed entries")} · {snapshot.excludedEntryCount ?? 0} {t("excluded")}{snapshot.expiresAt ? ` · ${t("expires")} ${new Date(snapshot.expiresAt).toLocaleDateString()}` : ""}</small></span>
<Button ref={returnFocusRef} variant="secondary" size="sm" loading={busy} onClick={(event) => void openPreview(snapshot, event.currentTarget)}>{t("Review")}</Button>
</li>
))}
</ul>
) : <small className="workspace-snapshot-restore-empty">{t("No restore points")}</small>}

{selected ? (
<Dialog open={dialogOpen} busy={busy} onOpenChange={(open) => { if (!busy) setDialogOpen(open); }}>
<DialogContent className="workspace-snapshot-restore-dialog" size="lg" aria-label={t("Review workspace restore") }>
<DialogHeader title={t("Review workspace restore")} closeLabel={t("Close")} description={t("This changes workspace files, not conversation history.")} />
<DialogBody>
<div className="workspace-snapshot-restore-meta"><span>{t("Captured")} {new Date(selected.manifest.createdAt).toLocaleString()}</span><span>{selected.manifest.captureKind}</span><span>{selected.manifest.managedEntryCount ?? selected.manifest.entryCount} {t("managed paths")}</span><span>{selected.manifest.excludedEntryCount ?? 0} {t("excluded")}</span></div>
<div className={`workspace-snapshot-fingerprint ${selected.fingerprintStatus}`}><strong>{t("Current fingerprint")}</strong><span>{selected.fingerprintStatus === "matching" ? t("matches snapshot") : t("has changed; restore will compare again before writing")}</span></div>
{(["create", "update", "delete", "unchanged"] as const).map((operation) => previewOperations[operation]?.length ? <PreviewGroup key={operation} operation={operation} entries={previewOperations[operation]} expanded={expanded} onToggle={() => setExpanded((value) => !value)} /> : null)}
{selected.entries.some((entry) => !entry.managed) ? <div className="workspace-snapshot-excluded"><strong>{t("Excluded paths are never changed")}</strong><small>{selected.entries.filter((entry) => !entry.managed).map((entry) => `${entry.relativePath} (${entry.exclusionReason ?? "unsupported"})`).join(" · ")}</small></div> : null}
</DialogBody>
<DialogFooter>
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setDialogOpen(false)}>{t("Cancel")}</Button>
<Button leadingIcon={<RotateCcw size={14} />} size="sm" variant="danger" loading={busy} loadingLabel={t("Restoring")} onClick={() => void confirmRestore()}>{t("Restore snapshot")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</section>
);
}

function groupOperations(entries: readonly WorkspaceSnapshotPreviewEntry[]): Record<string, readonly WorkspaceSnapshotPreviewEntry[]> {
return entries.reduce<Record<string, WorkspaceSnapshotPreviewEntry[]>>((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<typeof operation, string> = { create: t("Create"), update: t("Update"), delete: t("Delete"), unchanged: t("Unchanged") };
return <section className="workspace-snapshot-preview-group"><button type="button" onClick={onToggle}><span>{labels[operation]} · {entries.length}</span><ChevronDown className={expanded ? "expanded" : ""} size={14} /></button>{expanded ? <ul>{entries.slice(0, 80).map((entry) => <li key={entry.relativePath}>{entry.relativePath}</li>)}</ul> : null}</section>;
}
4 changes: 3 additions & 1 deletion apps/web/src/lib/conversation-work-cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -45,7 +47,13 @@ export function ConversationArtifactRecoveryDialog({
const cancelRef = useRef<HTMLButtonElement | null>(null);
const inFlightRef = useRef(false);
const lastActionRef = useRef<ConversationArtifactRecoveryAction>("undo");
const idempotencyKeyRef = useRef<string | undefined>(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 })
Expand All @@ -54,6 +62,7 @@ export function ConversationArtifactRecoveryDialog({
function close(): void {
if (inFlightRef.current) return;
setError("");
idempotencyKeyRef.current = undefined;
onClose();
}

Expand All @@ -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();
Expand Down Expand Up @@ -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<ConversationArtifactRecoveryResponse> {
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);
Expand Down
Loading
Loading