From d4c90ec6b7f6e4a39e626050367073b90ed2dfb2 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 6 Sep 2026 21:36:21 +0900 Subject: [PATCH 1/2] fix(gjc): keep persisted goal inspection read-only Use the pinned SDK's read-only inventory and unpromoted memory hydration instead of writable resume. Preserve external CLI writer identity and avoid orphan-backup recovery during Goal queries. Add real managed-writer regression coverage and document the contract. Full verify hits an existing frontend oneDark export failure, reproduced on unchanged upstream/main. Server suites, typecheck, Rust checks, lint, identity and build pass. Fixes #42 --- docs/GJC-WORKER-PROTOCOL.md | 2 +- server/gjc-bun-sdk-adapter.ts | 12 ++- server/gjc-sdk-contract.bun.test.ts | 120 +++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 4 deletions(-) diff --git a/docs/GJC-WORKER-PROTOCOL.md b/docs/GJC-WORKER-PROTOCOL.md index 8cab2ca2..b41754d3 100644 --- a/docs/GJC-WORKER-PROTOCOL.md +++ b/docs/GJC-WORKER-PROTOCOL.md @@ -115,7 +115,7 @@ scope is fixed by this specification, and a frame that gets it wrong is rejected | `turn.start` | scoped | Run a turn in an existing conversation. | | `turn.abort` | scoped | Stop the running turn. | | `turn.steer` | scoped | Inject input into the running turn without ending it. | -| `goal.inspect` | scoped | Read the exact persisted provider session's goal for an authenticated owner and resolved project cwd; refuses an active run. | +| `goal.inspect` | scoped | Read the exact persisted provider session's goal for an authenticated owner and resolved project cwd; refuses an app-owned active run. Inspection uses read-only inventory and strict, unpromoted memory hydration: it never recovers backups, rewrites the transcript, or persists replay sanitation, including when an external CLI is still writing. | | `goal.control` | scoped | Read or control a goal on the exact worker `runId`; requires matching app session, owner, cwd, and expected goal ID. The trusted server may pass literal `stopAfterMutation: false` for pause/drop so the enclosing native worktree job owns cancellation. | | `ask.reply` | scoped | Answer a question the worker raised with `ask.presented`. | diff --git a/server/gjc-bun-sdk-adapter.ts b/server/gjc-bun-sdk-adapter.ts index b43111b3..683d0baa 100644 --- a/server/gjc-bun-sdk-adapter.ts +++ b/server/gjc-bun-sdk-adapter.ts @@ -568,7 +568,17 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { async inspectGjcGoal(scope: GjcGoalScope, providerSessionId: string, sessionRoot: string): Promise { this.#assertHealthy(); - const manager = await resumeManager(providerSessionId, sessionRoot); + // list/open can recover backups and persist replay sanitation. A goal read must + // never acquire write ownership of a transcript an external CLI may be using. + const matches = (await SessionManager.listForResumePickerReadOnly('', sessionRoot)) + .filter((session) => session.id === providerSessionId); + if (matches.length !== 1) throw new Error(FAILURE); + const inspected = await SessionManager.inspectSessionTailReadOnly(matches[0].path); + if (inspected.kind === 'error' || inspected.identity.sessionId !== providerSessionId) throw new Error(FAILURE); + const hydrated = await SessionManager.openExistingForRecoveryHydrationStrict(inspected.identity, sessionRoot); + if (hydrated.kind !== 'hydrated') throw new Error(FAILURE); + // Keep hydration unpromoted: SDK branch semantics, with no persistence rights. + const manager = hydrated.manager; try { const { state, scope: owner } = readPersistedGjcGoal(manager); const actualCwd = await realpath(manager.getCwd()); diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index 6af7cccf..d0dc3a07 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { copyFile, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { isAbsolute, join, relative } from 'node:path'; import { test } from 'node:test'; @@ -9,7 +9,7 @@ import { ACP_BUILTIN_SLASH_COMMANDS } from '@gajae-code/coding-agent/slash-comma import { createAgentSession, discoverAuthStorage } from '@gajae-code/coding-agent/sdk/session'; import { ModelRegistry } from '@gajae-code/coding-agent/config/model-registry'; import { Settings } from '@gajae-code/coding-agent/config/settings'; -import { SessionManager } from '@gajae-code/coding-agent/session/session-manager'; +import { CURRENT_SESSION_VERSION, SessionManager } from '@gajae-code/coding-agent/session/session-manager'; import { AsyncJobManager } from '@gajae-code/coding-agent/async/job-manager'; import { registerCustomApi, unregisterCustomApis } from '@gajae-code/ai/api-registry'; import { AssistantMessageEventStream } from '@gajae-code/ai/utils/event-stream'; @@ -1297,6 +1297,122 @@ test('resume opens the sole exact session file and never re-emits session.create } finally { await f.close(); } }); +async function goalTranscriptSnapshot(path: string) { + const bytes = await readFile(path); + const { ino, size, mtimeNs, ctimeNs } = await stat(path, { bigint: true }); + return { bytes, identity: { ino, size, mtimeNs, ctimeNs } }; +} + +for (const status of [null, 'active', 'paused', 'complete'] as const) { + test(`goal inspection is read-only for a live managed transcript: ${status ?? 'no goal on current branch'}`, async () => { + const f = await fixture(); + let manager: SessionManager | undefined; + try { + await mkdir(join(f.root, 'project')); + const cwd = await realpath(join(f.root, 'project')); + const scope = { appSessionId: 'goal-inspection', owner: 'number:1', cwd }; + manager = SessionManager.create(cwd, SessionManager.managedDestination(cwd, join(f.root, 'agent'))); + assert.equal(manager.isManagedDestination(), true); + manager.appendMessage({ role: 'user', content: 'Keep the external CLI session active', timestamp: Date.now() }); + const answer: AssistantMessage = { + ...identityAnswer('Offline response'), api: 'openai-responses', provider: 'openai', + content: [{ type: 'thinking', thinking: 'Offline reasoning', thinkingSignature: 'live-provider-signature' }, + { type: 'text', text: 'Offline response' }], + providerPayload: { type: 'openaiResponsesHistory', provider: 'openai', + items: [{ type: 'reasoning', id: 'rs_live', encrypted_content: 'live-provider-payload', summary: [] }] }, + }; + const branchPoint = manager.appendMessage(answer); + const goal = { id: 'persisted-goal', objective: 'Preserve the live CLI transcript', status: status ?? 'active', + tokensUsed: 17, timeUsedSeconds: 3, createdAt: 100, updatedAt: 200 }; + manager.appendCustomEntry('gajae-goal-owner-v1', status ? scope : { ...scope, owner: 'abandoned-owner' }); + manager.appendModeChange(status === 'paused' ? 'goal_paused' : 'goal', { goal }); + if (status === null) { + // A goal on an abandoned branch must not leak into the current leaf's projection. + manager.branch(branchPoint); + manager.appendMessage({ role: 'user', content: 'Continue without the abandoned goal', timestamp: Date.now() }); + } + await manager.flush(); + const path = manager.getSessionFile()!; + const before = await goalTranscriptSnapshot(path); + assert.match(before.bytes.toString(), /live-provider-signature/); + assert.match(before.bytes.toString(), /live-provider-payload/); + const inspect = (owner = scope) => f.adapter.inspectGjcGoal(owner, manager!.getSessionId(), manager!.getSessionDir()); + const snapshot = await inspect(); + const after = await goalTranscriptSnapshot(path); + assert.deepEqual(after.bytes, before.bytes, 'goal inspection must not persist replay sanitation'); + assert.deepEqual(after.identity, before.identity, 'goal inspection must not touch the transcript'); + assert.deepEqual(snapshot, { + supported: true, goal: status ? goal : null, runId: null, canControl: true, resumeRequired: status === 'active', + }); + if (status) { + assert.deepEqual(await inspect({ ...scope, owner: 'number:2' }), { ...snapshot, canControl: false }); + } else { + await assert.rejects(inspect({ ...scope, cwd: f.root }), /working directory does not match/); + } + assert.deepEqual(await goalTranscriptSnapshot(path), before); + assert.deepEqual(f.sessions, [], 'goal inspection must not construct a provider session'); + const message = { role: 'user' as const, content: 'The original CLI owner can still append', timestamp: Date.now() }; + const id = manager.appendMessage(message); + await manager.flush(); + const continued = await readFile(path); + assert.deepEqual(continued.subarray(0, before.bytes.length), before.bytes); + const last = JSON.parse(continued.toString().trimEnd().split('\n').at(-1)!); + assert.equal(last.id, id); + assert.deepEqual(last.message, message, 'the original managed writer must persist its next append'); + } finally { + try { if (manager) assert.equal((await manager.closeStrict()).kind, 'closed'); } + finally { await f.close(); } + } + }); +} + +test('goal inspection inventory leaves orphan backups and missing targets untouched', async () => { + const f = await fixture(); + try { + const scope = { appSessionId: 'goal-inventory', owner: 'number:1', cwd: await realpath(f.root) }; + const header = { type: 'session', version: CURRENT_SESSION_VERSION, id: 'orphan-goal', cwd: scope.cwd, + timestamp: new Date().toISOString() }; + const target = join(f.root, 'orphan.jsonl'); + const backup = `${target}.123.bak`; + await writeFile(backup, `${JSON.stringify(header)}\n`); + const before = await goalTranscriptSnapshot(backup); + const inventory = await readdir(f.root); + await assert.rejects(f.adapter.inspectGjcGoal(scope, 'missing-goal', f.root)); + assert.deepEqual(await readdir(f.root), inventory, 'read-only inventory must not recover orphan backups'); + assert.deepEqual(await goalTranscriptSnapshot(backup), before); + await assert.rejects(f.adapter.inspectGjcGoal(scope, header.id, f.root)); + await assert.rejects(stat(target), { code: 'ENOENT' }); + const missingRoot = join(f.root, 'missing-sessions'); + await assert.rejects(f.adapter.inspectGjcGoal(scope, 'missing-goal', missingRoot)); + await assert.rejects(stat(missingRoot), { code: 'ENOENT' }); + assert.deepEqual(await goalTranscriptSnapshot(backup), before); + assert.deepEqual(f.sessions, []); + } finally { await f.close(); } +}); + +test('goal inspection rejects malformed and ambiguous transcripts without mutation', async () => { + const f = await fixture(); + try { + const scope = { appSessionId: 'goal-invalid', owner: 'number:1', cwd: await realpath(f.root) }; + const header = { type: 'session', version: CURRENT_SESSION_VERSION, id: 'malformed-goal', cwd: scope.cwd, + timestamp: new Date().toISOString() }; + const malformed = join(f.root, 'malformed.jsonl'); + await writeFile(malformed, `${JSON.stringify(header)}\n{"type":\n`); + const before = await goalTranscriptSnapshot(malformed); + await assert.rejects(f.adapter.inspectGjcGoal(scope, header.id, f.root)); + assert.deepEqual(await goalTranscriptSnapshot(malformed), before); + const first = join(f.root, 'first.jsonl'); + const duplicate = join(f.root, 'duplicate.jsonl'); + await writeFile(first, `${JSON.stringify({ ...header, id: 'duplicate-goal' })}\n`); + await copyFile(first, duplicate); + const originals = await Promise.all([first, duplicate].map(goalTranscriptSnapshot)); + await assert.rejects(f.adapter.inspectGjcGoal(scope, 'duplicate-goal', f.root)); + assert.deepEqual(await Promise.all([first, duplicate].map(goalTranscriptSnapshot)), originals); + assert.deepEqual(await goalTranscriptSnapshot(malformed), before); + assert.deepEqual(f.sessions, []); + } finally { await f.close(); } +}); + /** Real SDK construction; prompts are intercepted before any model transport can run. */ async function identityFixture() { const root = await mkdtemp(join(tmpdir(), 'gjc-sdk-identity-')); From 39b375f97a754bb518df26b8e7dd939347c984ec Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 6 Sep 2026 21:49:13 +0900 Subject: [PATCH 2/2] fix(gjc): project goal snapshots without hydration side effects The pinned SDK's recovery hydration still cleans sidecars and rewrites shared image blobs, even with a memory-backed transcript. Avoid manager construction entirely: capture and validate immutable bytes, apply the SDK parser, and project only the current leaf's parent chain. Keep original identity and digest checks, final source revalidation, and existing goal ownership semantics. Add compacted managed/explicit sidecar and image-store regressions; all nine focused cases pass. SDK and goal suites pass 100 tests with one existing skip; typecheck, lint, build and eight GJC e2e tests also pass. --- docs/GJC-WORKER-PROTOCOL.md | 2 +- server/gjc-bun-sdk-adapter.ts | 42 +++++++++++++---- server/gjc-goal-session.ts | 2 +- server/gjc-sdk-contract.bun.test.ts | 72 +++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 12 deletions(-) diff --git a/docs/GJC-WORKER-PROTOCOL.md b/docs/GJC-WORKER-PROTOCOL.md index b41754d3..b3f02245 100644 --- a/docs/GJC-WORKER-PROTOCOL.md +++ b/docs/GJC-WORKER-PROTOCOL.md @@ -115,7 +115,7 @@ scope is fixed by this specification, and a frame that gets it wrong is rejected | `turn.start` | scoped | Run a turn in an existing conversation. | | `turn.abort` | scoped | Stop the running turn. | | `turn.steer` | scoped | Inject input into the running turn without ending it. | -| `goal.inspect` | scoped | Read the exact persisted provider session's goal for an authenticated owner and resolved project cwd; refuses an app-owned active run. Inspection uses read-only inventory and strict, unpromoted memory hydration: it never recovers backups, rewrites the transcript, or persists replay sanitation, including when an external CLI is still writing. | +| `goal.inspect` | scoped | Read the exact persisted provider session's goal for an authenticated owner and resolved project cwd; refuses an app-owned active run. Inspection uses read-only inventory and an SDK-validated immutable transcript snapshot, projecting only the current branch without constructing a session manager: it never recovers backups, rewrites the transcript, persists replay sanitation, or changes source sidecars or image blobs, including when an external CLI is still writing. | | `goal.control` | scoped | Read or control a goal on the exact worker `runId`; requires matching app session, owner, cwd, and expected goal ID. The trusted server may pass literal `stopAfterMutation: false` for pause/drop so the enclosing native worktree job owns cancellation. | | `ask.reply` | scoped | Answer a question the worker raised with `ask.presented`. | diff --git a/server/gjc-bun-sdk-adapter.ts b/server/gjc-bun-sdk-adapter.ts index 683d0baa..61f25c50 100644 --- a/server/gjc-bun-sdk-adapter.ts +++ b/server/gjc-bun-sdk-adapter.ts @@ -7,7 +7,8 @@ import { activateModelProfile } from '@gajae-code/coding-agent/config/model-prof import { resolveModelRoleValue } from '@gajae-code/coding-agent/config/model-resolver'; import { Settings } from '@gajae-code/coding-agent/config/settings'; import { AuthStorage } from '@gajae-code/coding-agent/session/auth-storage'; -import { SessionManager } from '@gajae-code/coding-agent/session/session-manager'; +import { parseSessionEntries, SessionManager, type SessionEntry } from '@gajae-code/coding-agent/session/session-manager'; +import { MemorySessionStorage } from '@gajae-code/coding-agent/session/session-storage'; import { executeAcpBuiltinSlashCommand } from '@gajae-code/coding-agent/slash-commands/acp-builtins'; import { initTheme, theme } from '@gajae-code/coding-agent/modes/theme/theme'; import { generateSessionTitle } from '@gajae-code/coding-agent/utils/title-generator'; @@ -573,22 +574,43 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { const matches = (await SessionManager.listForResumePickerReadOnly('', sessionRoot)) .filter((session) => session.id === providerSessionId); if (matches.length !== 1) throw new Error(FAILURE); - const inspected = await SessionManager.inspectSessionTailReadOnly(matches[0].path); - if (inspected.kind === 'error' || inspected.identity.sessionId !== providerSessionId) throw new Error(FAILURE); - const hydrated = await SessionManager.openExistingForRecoveryHydrationStrict(inspected.identity, sessionRoot); - if (hydrated.kind !== 'hydrated') throw new Error(FAILURE); - // Keep hydration unpromoted: SDK branch semantics, with no persistence rights. - const manager = hydrated.manager; + const captured = SessionManager.captureTranscriptStrict(matches[0].path); + if (captured.kind !== 'captured') throw new Error(FAILURE); + const { snapshot } = captured; try { - const { state, scope: owner } = readPersistedGjcGoal(manager); - const actualCwd = await realpath(manager.getCwd()); + if (snapshot.identity.sessionId !== providerSessionId) throw new Error(FAILURE); + // Validate an immutable copy with the SDK. Never construct a manager: even + // recovery hydration can rebuild sidecars and write shared image blobs. + const bytes = Buffer.from(snapshot.materialize()); + const storage = new MemorySessionStorage(); + storage.writeBytesOwnedSync(snapshot.identity.canonicalPath, bytes); + const inspected = await SessionManager.inspectSessionTailReadOnly(snapshot.identity.canonicalPath, storage); + if (inspected.kind === 'error' || inspected.identity.sessionId !== providerSessionId + || inspected.identity.sha256 !== snapshot.identity.sha256) throw new Error(FAILURE); + const entries = parseSessionEntries(bytes.toString('utf8')); + const header = entries[0]; + if (header?.type !== 'session') throw new Error(FAILURE); + const sessionEntries = entries.filter((entry): entry is SessionEntry => entry.type !== 'session'); + const byId = new Map(sessionEntries.map((entry) => [entry.id, entry])); + const branch: SessionEntry[] = []; + const visited = new Set(); + // Match getBranch(): the last entry is the leaf, not every chronological mode change. + for (let entry = sessionEntries.at(-1); entry && !visited.has(entry.id); + entry = entry.parentId ? byId.get(entry.parentId) : undefined) { + visited.add(entry.id); + branch.push(entry); + } + branch.reverse(); + const { state, scope: owner } = readPersistedGjcGoal({ getBranch: () => branch }); + const actualCwd = await realpath(header.cwd); if (actualCwd !== (owner?.cwd ?? scope.cwd)) throw new Error('Goal working directory does not match the persisted session.'); + if (snapshot.revalidate().kind !== 'valid') throw new Error(FAILURE); return { supported: true, goal: state?.goal ?? null, runId: null, canControl: owner ? matchesGjcGoalOwner(owner, scope) : !state, resumeRequired: state?.goal.status === 'active', }; - } finally { await manager.close(); } + } finally { snapshot.close(); } } async controlGjcGoal(runId: string, scope: GjcGoalScope, command?: GjcGoalCommand, stopAfterMutation = true): Promise { diff --git a/server/gjc-goal-session.ts b/server/gjc-goal-session.ts index 6f5e857d..dcbb9ebc 100644 --- a/server/gjc-goal-session.ts +++ b/server/gjc-goal-session.ts @@ -23,7 +23,7 @@ type GoalManager = { const OWNER_ENTRY = 'gajae-goal-owner-v1'; export const GJC_GOAL_MODEL_OPERATIONS = ['create', 'get', 'pause', 'resume', 'complete'] as const; -export function readPersistedGjcGoal(manager: GoalManager): { state?: GjcGoalState; scope?: GjcGoalScope } { +export function readPersistedGjcGoal(manager: Pick): { state?: GjcGoalState; scope?: GjcGoalScope } { let state: GjcGoalState | undefined; let scope: GjcGoalScope | undefined; for (const entry of manager.getBranch()) { diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index d0dc3a07..70ad7271 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { isAbsolute, join, relative } from 'node:path'; import { test } from 'node:test'; +import { BlobStore } from '@gajae-code/coding-agent/session/blob-store'; import { ACP_BUILTIN_SLASH_COMMANDS } from '@gajae-code/coding-agent/slash-commands/acp-builtins'; import { createAgentSession, discoverAuthStorage } from '@gajae-code/coding-agent/sdk/session'; import { ModelRegistry } from '@gajae-code/coding-agent/config/model-registry'; @@ -1366,6 +1367,77 @@ for (const status of [null, 'active', 'paused', 'complete'] as const) { }); } +for (const destination of ['managed', 'explicit'] as const) { + test(`goal inspection preserves compacted ${destination} session sidecars on success and rejection`, async () => { + const f = await fixture(); + const cwd = await realpath(f.root); + const manager = SessionManager.create(cwd, destination === 'managed' + ? SessionManager.managedDestination(cwd, join(f.root, 'agent')) : f.root); + try { + const first = manager.appendMessage({ role: 'user', content: 'Before compaction', timestamp: Date.now() }); + manager.appendMessage(identityAnswer('A compacted answer')); + manager.appendCompaction('Retain the current goal', undefined, first, 100); + const goal = { id: 'compacted-goal', objective: 'Preserve sidecars', status: 'active', + tokensUsed: 1, timeUsedSeconds: 1, createdAt: 100, updatedAt: 200 }; + manager.appendModeChange('goal', { goal }); + await manager.flush(); + const path = manager.getSessionFile()!; + const sidecarDir = path.slice(0, -6); + await mkdir(sidecarDir, { recursive: true }); + const sidecars = [`${path}.spill.idx`, ...['idx', 'tail', 'commit', 'capture-probe.tmp'] + .map((suffix) => join(sidecarDir, `.session-memory.spill.${suffix}`))]; + for (const file of sidecars) await writeFile(file, `External writer owns ${file}`); + const originals = await Promise.all([path, ...sidecars].map(goalTranscriptSnapshot)); + const inventory = (await readdir(sidecarDir)).sort(); + const scope = { appSessionId: 'compacted-inspection', owner: 'number:1', cwd }; + const snapshot = await f.adapter.inspectGjcGoal(scope, manager.getSessionId(), manager.getSessionDir()); + assert.deepEqual(snapshot.goal, goal); + assert.equal(snapshot.resumeRequired, true); + await assert.rejects(f.adapter.inspectGjcGoal({ ...scope, cwd: join(cwd, 'other') }, + manager.getSessionId(), manager.getSessionDir()), /working directory does not match/); + assert.deepEqual(await Promise.all([path, ...sidecars].map(goalTranscriptSnapshot)), originals); + assert.deepEqual((await readdir(sidecarDir)).sort(), inventory); + const id = manager.appendMessage({ role: 'user', content: 'Continue after inspection', timestamp: Date.now() }); + await manager.flush(); + assert.equal(JSON.parse((await readFile(path, 'utf8')).trimEnd().split('\n').at(-1)!).id, id); + assert.deepEqual(f.sessions, []); + } finally { + try { assert.equal((await manager.closeStrict()).kind, 'closed'); } + finally { await f.close(); } + } + }); +} + +test('goal inspection never materializes image blobs or rewrites their shared store', async () => { + const f = await fixture(); + const originalPut = BlobStore.prototype.putSync; + let writes = 0; + BlobStore.prototype.putSync = () => { + writes++; + throw new Error('Goal inspection attempted a shared blob write'); + }; + try { + const cwd = await realpath(f.root); + const id = 'image-goal'; + const path = join(cwd, 'image.jsonl'); + const records = [ + { type: 'session', version: CURRENT_SESSION_VERSION, starredPatchVersion: 1, id, cwd, timestamp: new Date().toISOString() }, + { type: 'message', id: 'image-message', parentId: null, timestamp: new Date().toISOString(), + message: { role: 'user', timestamp: Date.now(), content: [ + { type: 'image', mimeType: 'image/png', data: Buffer.alloc(128 * 1024, 1).toString('base64') }, + ] } }, + ]; + await writeFile(path, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`); + const before = await goalTranscriptSnapshot(path); + const scope = { appSessionId: id, owner: 'number:1', cwd }; + const result = await f.adapter.inspectGjcGoal(scope, id, cwd); + assert.equal(result.goal, null); + await assert.rejects(f.adapter.inspectGjcGoal({ ...scope, cwd: join(cwd, 'wrong') }, id, cwd), /working directory does not match/); + assert.equal(writes, 0, 'goal reads must not hydrate images into the filesystem BlobStore'); + assert.deepEqual(await goalTranscriptSnapshot(path), before); + } finally { BlobStore.prototype.putSync = originalPut; await f.close(); } +}); + test('goal inspection inventory leaves orphan backups and missing targets untouched', async () => { const f = await fixture(); try {