Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ _Avoid_: Deletable session
**Destroyed Status Check**:
A convenience policy predicate for the single `destroyed` **Session Status** value. It is not a separate lifecycle classification.

**Semantic Snapshot**:
A renderer-produced semantic description of a **Session** at a captured event-log sequence.

**Snapshot Result**:
A public snapshot payload returned to a caller after deriving structured or text output from a **Semantic Snapshot**.

**Snapshot Artifact**:
A persisted JSON artifact containing exactly the **Snapshot Result** returned to the caller.

**Snapshot Capture**:
The operation that derives a **Snapshot Result** from a **Semantic Snapshot** and records the matching **Snapshot Artifact**.
_Avoid_: Renderer capture

## Relationships

- A **Session** has exactly one **Session Status** at a time.
Expand All @@ -46,12 +59,17 @@ A convenience policy predicate for the single `destroyed` **Session Status** val

- A **Session** has one **Event Log**.
- An **Offline Replay Eligible Session** is reconstructed from its persisted **Event Log** and manifest.
- A **Snapshot Result** is derived from exactly one **Semantic Snapshot**.
- A **Snapshot Artifact** contains exactly the **Snapshot Result** emitted to the caller.

## Example dialogue

> **Dev:** "Can garbage collection remove a **destroying** **Session**?"
> **Domain expert:** "No. It is still an **Active Session**, even though renderer inspection should use **Offline Replay** instead of the live host."

> **Dev:** "Does **Snapshot Capture** ask the renderer for terminal state?"
> **Domain expert:** "No — the renderer first produces a **Semantic Snapshot**; **Snapshot Capture** derives and records the public result from that snapshot."

## Flagged ambiguities

- "Active" and "offline replay eligible" are independent classifications: `destroying` is both **Active** and **Offline Replay Eligible**.
122 changes: 12 additions & 110 deletions src/cli/commands/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type {
SnapshotParams,
SnapshotResult,
} from '../../protocol/messages.js';
import type { SnapshotResult } from '../../protocol/messages.js';
import type { SnapshotFormat } from '../../snapshot/capture.js';
import type { SemanticSnapshot } from '../../renderer/types.js';

import { CliError } from '../../cli/errors.js';
Expand All @@ -10,23 +8,14 @@ import type { CommandContext } from '../context.js';
import { emitSuccess } from '../output.js';
import { sendRpc } from '../../host/rpcClient.js';
import { SnapshotParamsSchema } from '../../protocol/messages.js';
import { SnapshotResultSchema } from '../../protocol/schemas.js';
import { ERROR_CODES, makeCliError } from '../../protocol/errors.js';
import { withOfflineReplayRenderer } from '../../replay/offlineReplay.js';
import {
appendArtifact,
createArtifactEntry,
} from '../../storage/artifactManifest.js';
captureSnapshotResult,
parseSnapshotResult,
} from '../../snapshot/capture.js';
import { invariant } from '../../util/assert.js';
import {
readManifestIfExists,
writeTextFileAtomic,
} from '../../storage/manifests.js';
import {
artifactPath,
ensureArtifactsDir,
snapshotFilename,
} from '../../storage/artifactPaths.js';
import { readManifestIfExists } from '../../storage/manifests.js';
import {
manifestPath,
sessionDir,
Expand All @@ -35,8 +24,6 @@ import {

const DEFAULT_SNAPSHOT_FORMAT = 'structured';

type SnapshotFormat = NonNullable<SnapshotParams['format']>;

interface CommandOptions {
context: CommandContext;
json: boolean;
Expand Down Expand Up @@ -94,89 +81,6 @@ function resolveIncludeCells(includeCells: boolean | undefined): boolean {
return effectiveIncludeCells;
}

function parseSnapshotResult(rawResult: unknown): SnapshotResult {
const parsedResult = SnapshotResultSchema.safeParse(rawResult);
if (!parsedResult.success) {
throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, {
message: 'Unexpected response from host',
details: { issues: parsedResult.error.issues },
});
}

return parsedResult.data;
}

function createSnapshotResult(
snapshot: SemanticSnapshot,
format: SnapshotFormat,
): SnapshotResult {
const textLines = [
...(snapshot.scrollbackLines ?? []).map((line) => line.text),
...snapshot.visibleLines.map((line) => line.text),
];

const snapshotResult: SnapshotResult =
format === 'structured'
? { format: 'structured' as const, ...snapshot }
: {
format: 'text' as const,
sessionId: snapshot.sessionId,
capturedAtSeq: snapshot.capturedAtSeq,
cols: snapshot.cols,
rows: snapshot.rows,
cursorRow: snapshot.cursorRow,
cursorCol: snapshot.cursorCol,
text: textLines.join('\n'),
};

return parseSnapshotResult(snapshotResult);
}

async function persistSnapshotArtifact(
sessionDirectory: string,
format: SnapshotFormat,
snapshot: SemanticSnapshot,
snapshotResult: SnapshotResult,
rendererBackend?: string,
): Promise<void> {
invariant(
rendererBackend === undefined || rendererBackend.length > 0,
'rendererBackend must be a non-empty string when provided',
);

await ensureArtifactsDir(sessionDirectory);
const filename = snapshotFilename(snapshot.capturedAtSeq, format);
const snapshotArtifactPath = artifactPath(sessionDirectory, filename);

await writeTextFileAtomic({
path: snapshotArtifactPath,
pathLabel: 'snapshot artifact path',
contents: `${JSON.stringify(snapshotResult, null, 2)}\n`,
writeErrorMessage: `Failed to write snapshot artifact at ${snapshotArtifactPath}.`,
});

await appendArtifact(
sessionDirectory,
createArtifactEntry({
kind: 'snapshot',
filename,
sessionId: snapshot.sessionId,
capturedAtSeq: snapshot.capturedAtSeq,
metadata: {
format,
cols: snapshot.cols,
rows: snapshot.rows,
cursorRow: snapshot.cursorRow,
cursorCol: snapshot.cursorCol,
...(rendererBackend === undefined ? {} : { rendererBackend }),
...(snapshot.scrollbackLines === undefined
? {}
: { scrollbackLineCount: snapshot.scrollbackLines.length }),
},
}),
);
}

async function runRpcSnapshot(
sessionDirectory: string,
rendererName: CommandContext['rendererDefault'],
Expand Down Expand Up @@ -207,20 +111,18 @@ async function runOfflineSnapshot(
): Promise<SnapshotResult> {
return withOfflineReplayRenderer(
{ sessionDir: sessionDirectory, rendererName },
async ({ backend }) => {
async ({ backend, manifest }) => {
const snapshot: SemanticSnapshot = await backend.snapshot({
includeScrollback,
includeCells,
});
const snapshotResult = createSnapshotResult(snapshot, format);
await persistSnapshotArtifact(
sessionDirectory,
return await captureSnapshotResult({
sessionDir: sessionDirectory,
format,
snapshot,
snapshotResult,
backend.rendererBackend,
);
return snapshotResult;
rendererBackend: backend.rendererBackend,
expectedSessionId: manifest.sessionId,
});
},
);
}
Expand Down
72 changes: 8 additions & 64 deletions src/host/hostMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from '../renderer/names.js';
import { resolveProfile } from '../renderer/profiles.js';
import { createRendererBackend } from '../renderer/registry.js';
import { captureSnapshotResult } from '../snapshot/capture.js';
import {
appendArtifact,
createArtifactEntry,
Expand All @@ -52,14 +53,9 @@ import {
artifactPath,
ensureArtifactsDir,
screenshotFilename,
snapshotFilename,
} from '../storage/artifactPaths.js';
import { resolveHome } from '../storage/home.js';
import {
readManifest,
writeManifest,
writeTextFileAtomic,
} from '../storage/manifests.js';
import { readManifest, writeManifest } from '../storage/manifests.js';
import {
eventLogPath,
manifestPath,
Expand Down Expand Up @@ -739,65 +735,13 @@ export async function runHost(sessionId: string): Promise<void> {
includeScrollback,
includeCells,
});
const snapshotText = [
...(snapshot.scrollbackLines ?? []),
...snapshot.visibleLines,
]
.map((line) => line.text)
.join('\n');

invariant(
snapshot.sessionId === sessionId,
'renderer snapshot sessionId must match host sessionId',
);

const snapshotResult =
format === 'structured'
? { format: 'structured' as const, ...snapshot }
: {
format: 'text' as const,
sessionId: snapshot.sessionId,
capturedAtSeq: snapshot.capturedAtSeq,
cols: snapshot.cols,
rows: snapshot.rows,
cursorRow: snapshot.cursorRow,
cursorCol: snapshot.cursorCol,
text: snapshotText,
};

await ensureArtifactsDir(sessDir);
const filename = snapshotFilename(snapshot.capturedAtSeq, format);
const snapshotArtifactPath = artifactPath(sessDir, filename);

await writeTextFileAtomic({
path: snapshotArtifactPath,
pathLabel: 'snapshot artifact path',
contents: `${JSON.stringify(snapshotResult, null, 2)}\n`,
writeErrorMessage: `Failed to write snapshot artifact at ${snapshotArtifactPath}.`,
return await captureSnapshotResult({
sessionDir: sessDir,
format,
snapshot,
rendererBackend: backend.rendererBackend,
expectedSessionId: sessionId,
});

await appendArtifact(
sessDir,
createArtifactEntry({
kind: 'snapshot',
filename,
sessionId: snapshot.sessionId,
capturedAtSeq: snapshot.capturedAtSeq,
metadata: {
format,
rendererBackend: backend.rendererBackend,
cols: snapshot.cols,
rows: snapshot.rows,
cursorRow: snapshot.cursorRow,
cursorCol: snapshot.cursorCol,
...(snapshot.scrollbackLines === undefined
? {}
: { scrollbackLineCount: snapshot.scrollbackLines.length }),
},
}),
);

return snapshotResult;
},
screenshot: async (params: unknown) => {
const {
Expand Down
Loading
Loading