@neta-art/cohub-cli 7.0.1 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ import { fingerprintProjectionTurns, HttpError, isProjectionCompaction } from "@neta-art/cohub";
2
+ import { getSessionProjectionTurn, listSessionProjectionTurns, projectTurnBatch } from "./turn-projection.js";
3
+ export function rebindProjectionNativeSession(result, nativeSessionId) {
4
+ if (result.append)
5
+ return result;
6
+ const records = result.projection.records.map((entry) => {
7
+ if (entry.sourceTurnId !== null)
8
+ return entry;
9
+ if (entry.record.type === "session") {
10
+ return { ...entry, record: { ...entry.record, id: nativeSessionId, affinity: { ...entry.record.affinity, threadId: nativeSessionId } } };
11
+ }
12
+ if (entry.record.type === "session_meta") {
13
+ const payload = entry.record.payload;
14
+ return { ...entry, record: { ...entry.record, payload: { ...payload, id: nativeSessionId, session_id: nativeSessionId } } };
15
+ }
16
+ return entry;
17
+ });
18
+ return { ...result, projection: { ...result.projection, records } };
19
+ }
20
+ /** Reads durable Cohub turns and materializes one harness-specific native projection batch. */
21
+ export class ProjectionStore {
22
+ source;
23
+ constructor(source) {
24
+ this.source = source;
25
+ }
26
+ async project(input, signal) {
27
+ const throughSequence = input.throughTurnId ? await this.sourceSequence(input.sessionId, input.throughTurnId, signal) : 0;
28
+ let source = await this.readTurns(input, throughSequence, signal);
29
+ if (source.append && source.turns.some((turn) => turn.messages.some(isProjectionCompaction))) {
30
+ source = { turns: await listSessionProjectionTurns(this.source, input.sessionId, { throughSequence, excludeTurnId: input.turnId, signal }), append: false };
31
+ }
32
+ const projection = projectTurnBatch({
33
+ spaceId: input.spaceId,
34
+ sessionId: input.sessionId,
35
+ nativeSessionId: input.nativeSessionId,
36
+ cwd: input.cwd,
37
+ provider: input.provider,
38
+ turns: source.turns,
39
+ }, input.target, !source.append);
40
+ const last = source.turns.at(-1);
41
+ const previous = source.append ? input.cursor : null;
42
+ return {
43
+ projection,
44
+ turns: source.turns,
45
+ append: source.append,
46
+ cursor: {
47
+ throughSequence: last?.sequence ?? previous?.throughSequence ?? null,
48
+ throughTurnId: last?.sourceTurnId ?? previous?.throughTurnId ?? null,
49
+ sourceFingerprint: last ? fingerprintProjectionTurns([last]) : previous?.sourceFingerprint ?? null,
50
+ },
51
+ };
52
+ }
53
+ async sourceSequence(sessionId, turnId, signal) {
54
+ const client = this.source.session(sessionId);
55
+ const response = await client.turns.get(turnId, { signal });
56
+ return response.turn.sequence;
57
+ }
58
+ async cursorForTurn(sessionId, turnId, signal) {
59
+ const turn = await getSessionProjectionTurn(this.source, sessionId, turnId, signal);
60
+ return {
61
+ throughSequence: turn.sequence,
62
+ throughTurnId: turn.sourceTurnId,
63
+ sourceFingerprint: fingerprintProjectionTurns([turn]),
64
+ };
65
+ }
66
+ async readTurns(input, throughSequence, signal) {
67
+ const cursor = input.cursor;
68
+ if (cursor?.throughSequence != null && cursor.throughSequence > throughSequence) {
69
+ return { turns: await listSessionProjectionTurns(this.source, input.sessionId, { throughSequence, excludeTurnId: input.turnId, signal }), append: false };
70
+ }
71
+ if (cursor?.throughTurnId && cursor.throughSequence != null) {
72
+ let anchor;
73
+ try {
74
+ anchor = await getSessionProjectionTurn(this.source, input.sessionId, cursor.throughTurnId, signal);
75
+ }
76
+ catch (error) {
77
+ if (!(error instanceof HttpError) || error.status !== 404)
78
+ throw error;
79
+ anchor = null;
80
+ }
81
+ const anchorFingerprint = anchor ? fingerprintProjectionTurns([anchor]) : null;
82
+ if (anchorFingerprint !== cursor.sourceFingerprint) {
83
+ return {
84
+ turns: await listSessionProjectionTurns(this.source, input.sessionId, { throughSequence, excludeTurnId: input.turnId, signal }),
85
+ append: false,
86
+ };
87
+ }
88
+ return {
89
+ turns: await listSessionProjectionTurns(this.source, input.sessionId, {
90
+ afterSequence: Math.max(1, cursor.throughSequence),
91
+ throughSequence,
92
+ excludeTurnId: input.turnId,
93
+ signal,
94
+ }),
95
+ append: true,
96
+ };
97
+ }
98
+ return {
99
+ turns: await listSessionProjectionTurns(this.source, input.sessionId, { throughSequence, excludeTurnId: input.turnId, signal }),
100
+ append: false,
101
+ };
102
+ }
103
+ }
@@ -1,10 +1,15 @@
1
1
  import { type RuntimeExecutionEvent, type HarnessArchive, type RuntimePendingExecution, type RuntimeTurnInput } from "@neta-art/cohub";
2
2
  import { RuntimeArchiveStore, type ArchiveTransport } from "./archive-store.js";
3
3
  import type { CodexTokenTotals } from "./codex-usage.js";
4
+ import { type RuntimeDiagnosticContext, type RuntimeDiagnostics } from "./diagnostics.js";
5
+ import type { SessionTurnProjectionClient } from "./turn-projection.js";
4
6
  export declare class ContextRequiredError extends Error {
5
- readonly historyOnly: boolean;
6
- constructor(message: string, historyOnly?: boolean);
7
7
  }
8
+ export type RuntimeSessionStoreOptions = {
9
+ stateRoot?: string;
10
+ archiveTransport?: ArchiveTransport;
11
+ projectionSource: SessionTurnProjectionClient;
12
+ };
8
13
  export type NativeSession = {
9
14
  version: 1;
10
15
  sessionId: string;
@@ -19,14 +24,31 @@ export type NativeSession = {
19
24
  resultChecksum?: string;
20
25
  archivePendingTurnId?: string;
21
26
  codexTokenTotals?: CodexTokenTotals;
27
+ projectionVersion?: 1;
28
+ sourceSequence?: number | null;
29
+ sourceTurnId?: string | null;
30
+ sourceFingerprint?: string | null;
31
+ nativeLeafId?: string | null;
22
32
  };
23
33
  /** Local references are hints validated against actual native files, never cloud existence claims. */
24
34
  export declare class RuntimeSessionStore {
25
35
  readonly root: string;
26
36
  readonly archives: RuntimeArchiveStore;
27
37
  private archiveFlush;
28
- constructor(spaceId: string, stateRoot?: string, transport?: ArchiveTransport);
38
+ private diagnostics;
39
+ private readonly nativeWrites;
40
+ private readonly projectionStore;
41
+ constructor(spaceId: string, options: RuntimeSessionStoreOptions);
42
+ setDiagnostics(diagnostics: RuntimeDiagnostics): void;
29
43
  private statePath;
44
+ private nativePath;
45
+ private withNativeWrite;
46
+ private syncParentDirectory;
47
+ private writeNativeFile;
48
+ private appendNativeFile;
49
+ private lastNativeRecordId;
50
+ private reportProjectionWarnings;
51
+ private syncNativeProjection;
30
52
  pendingExecutionBatches(): AsyncGenerator<RuntimePendingExecution[]>;
31
53
  flushArchives(signal: AbortSignal): Promise<void>;
32
54
  private flushArchiveOutbox;
@@ -42,6 +64,6 @@ export declare class RuntimeSessionStore {
42
64
  state: NativeSession;
43
65
  events: RuntimeExecutionEvent[];
44
66
  } | null>;
45
- archive(state: NativeSession, turnId: string): Promise<HarnessArchive | null>;
67
+ archive(state: NativeSession, turnId: string, diagnosticContext?: RuntimeDiagnosticContext): Promise<HarnessArchive | null>;
46
68
  acknowledge(state: NativeSession, turnId: string, revision: string): Promise<void>;
47
69
  }
@@ -1,31 +1,224 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, open, readFile, readdir, rm } from "node:fs/promises";
2
+ import { mkdir, open, readFile, readdir, rename, rm } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
- import { contextToPiMessages, RUNTIME_RECOVERY_BATCH_SIZE, selectRuntimeContextMessages, runtimeEventSchema } from "@neta-art/cohub";
5
+ import { RUNTIME_RECOVERY_BATCH_SIZE, runtimeEventSchema, serializeProjectionRecords } from "@neta-art/cohub";
6
6
  import { RuntimeArchiveStore, checksumNativeFile, atomicRuntimeJson as atomicJson } from "./archive-store.js";
7
7
  import { importNativeArchive, readCodexArchiveTotals } from "./native-archive.js";
8
+ import { serializeDiagnosticError } from "./diagnostics.js";
9
+ import { ProjectionStore, rebindProjectionNativeSession } from "./projection-store.js";
8
10
  export class ContextRequiredError extends Error {
9
- historyOnly;
10
- constructor(message, historyOnly = false) {
11
- super(message);
12
- this.historyOnly = historyOnly;
13
- }
14
11
  }
15
12
  const checksum = (data) => createHash("sha256").update(data).digest("hex");
16
13
  const missing = (error) => error?.code === "ENOENT";
17
14
  class CaptureUnavailableError extends Error {
18
15
  }
16
+ function piSessionDirectory(cwd) {
17
+ const custom = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
18
+ if (custom)
19
+ return custom;
20
+ const agentDir = process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
21
+ const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
22
+ return join(agentDir, "sessions", safePath);
23
+ }
24
+ function codexSessionPath(id) {
25
+ const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "").replaceAll(":", "-");
26
+ const root = process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
27
+ return join(root, "sessions", timestamp.slice(0, 4), timestamp.slice(5, 7), timestamp.slice(8, 10), `rollout-${timestamp}-${id}.jsonl`);
28
+ }
19
29
  /** Local references are hints validated against actual native files, never cloud existence claims. */
20
30
  export class RuntimeSessionStore {
21
31
  root;
22
32
  archives;
23
33
  archiveFlush = null;
24
- constructor(spaceId, stateRoot = join(homedir(), ".local", "state", "cohub", "runtime"), transport) {
25
- this.root = join(stateRoot, spaceId);
26
- this.archives = new RuntimeArchiveStore(join(this.root, "archives"), transport);
34
+ diagnostics = null;
35
+ nativeWrites = new Map();
36
+ projectionStore;
37
+ constructor(spaceId, options) {
38
+ this.root = join(options.stateRoot ?? join(homedir(), ".local", "state", "cohub", "runtime"), spaceId);
39
+ this.archives = new RuntimeArchiveStore(join(this.root, "archives"), options.archiveTransport);
40
+ this.projectionStore = new ProjectionStore(options.projectionSource);
41
+ }
42
+ setDiagnostics(diagnostics) {
43
+ this.diagnostics = diagnostics;
44
+ this.archives.setErrorReporter((error, index) => diagnostics.log("warn", "archive.upload_pending", { error: serializeDiagnosticError(error) }, {
45
+ component: "archive",
46
+ sessionId: index?.sessionId,
47
+ turnId: index?.turnId,
48
+ harness: index?.harness,
49
+ }));
27
50
  }
28
51
  statePath(input) { return join(this.root, input.harness, `${input.sessionId}.json`); }
52
+ nativePath(input, nativeSessionId, cwd) {
53
+ if (input.harness === "pi")
54
+ return join(piSessionDirectory(cwd), `${nativeSessionId}.jsonl`);
55
+ return codexSessionPath(nativeSessionId);
56
+ }
57
+ async withNativeWrite(path, write) {
58
+ const previous = this.nativeWrites.get(path) ?? Promise.resolve();
59
+ const current = previous.catch(() => undefined).then(write);
60
+ this.nativeWrites.set(path, current);
61
+ try {
62
+ await current;
63
+ }
64
+ finally {
65
+ if (this.nativeWrites.get(path) === current)
66
+ this.nativeWrites.delete(path);
67
+ }
68
+ }
69
+ async syncParentDirectory(path) {
70
+ if (process.platform === "win32")
71
+ return;
72
+ const directory = await open(dirname(path), "r");
73
+ try {
74
+ await directory.sync();
75
+ }
76
+ finally {
77
+ await directory.close();
78
+ }
79
+ }
80
+ async writeNativeFile(path, data) {
81
+ await this.withNativeWrite(path, async () => {
82
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
83
+ const temporary = `${path}.${randomUUID()}.projection`;
84
+ try {
85
+ const file = await open(temporary, "wx", 0o600);
86
+ try {
87
+ await file.writeFile(data, "utf8");
88
+ await file.sync();
89
+ }
90
+ finally {
91
+ await file.close();
92
+ }
93
+ await rename(temporary, path);
94
+ await this.syncParentDirectory(path);
95
+ }
96
+ finally {
97
+ await rm(temporary, { force: true });
98
+ }
99
+ });
100
+ }
101
+ async appendNativeFile(path, data, expectedChecksum) {
102
+ if (!data)
103
+ return;
104
+ await this.withNativeWrite(path, async () => {
105
+ const file = await open(path, "r+", 0o600);
106
+ try {
107
+ const bytes = await file.readFile();
108
+ if (expectedChecksum && checksum(bytes) !== expectedChecksum)
109
+ throw new Error("Native session changed outside Cohub; original data was preserved");
110
+ const prefix = bytes.length > 0 && bytes.at(-1) !== 10 ? "\n" : "";
111
+ await file.write(`${prefix}${data}`, bytes.length, "utf8");
112
+ await file.sync();
113
+ }
114
+ finally {
115
+ await file.close();
116
+ }
117
+ await this.syncParentDirectory(path);
118
+ });
119
+ }
120
+ async lastNativeRecordId(path) {
121
+ const file = await open(path, "r");
122
+ try {
123
+ let position = (await file.stat()).size;
124
+ let tail = "";
125
+ while (position > 0) {
126
+ const size = Math.min(position, 64 * 1024);
127
+ position -= size;
128
+ const chunk = Buffer.alloc(size);
129
+ await file.read(chunk, 0, size, position);
130
+ tail = chunk.toString("utf8") + tail;
131
+ const candidate = tail.trimEnd();
132
+ const newline = candidate.lastIndexOf("\n");
133
+ if (newline >= 0) {
134
+ const record = JSON.parse(candidate.slice(newline + 1));
135
+ return typeof record.id === "string" ? record.id : null;
136
+ }
137
+ }
138
+ const record = JSON.parse(tail.trim());
139
+ return typeof record.id === "string" ? record.id : null;
140
+ }
141
+ finally {
142
+ await file.close();
143
+ }
144
+ }
145
+ reportProjectionWarnings(input, warnings) {
146
+ if (warnings.length === 0)
147
+ return;
148
+ this.diagnostics?.log("warn", "runtime.projection_loss", { warningCount: warnings.length, warnings: warnings.slice(0, 100), truncated: warnings.length > 100 }, { component: "projection", sessionId: input.sessionId, turnId: input.turnId, harness: input.harness });
149
+ }
150
+ async syncNativeProjection(input, cwd, previous, signal) {
151
+ const existing = previous && (previous.checksum.length > 0 || previous.sourceSequence != null || previous.pendingTurnId != null) ? previous : null;
152
+ const projection = await this.projectionStore.project({
153
+ spaceId: input.spaceId,
154
+ sessionId: input.sessionId,
155
+ turnId: input.turnId,
156
+ nativeSessionId: existing?.nativeSessionId ?? randomUUID(),
157
+ cwd,
158
+ provider: input.provider,
159
+ target: input.harness,
160
+ throughTurnId: input.context.throughTurnId,
161
+ cursor: existing ? {
162
+ throughSequence: existing.sourceSequence ?? null,
163
+ throughTurnId: existing.sourceTurnId ?? null,
164
+ sourceFingerprint: existing.sourceFingerprint ?? null,
165
+ } : null,
166
+ }, signal);
167
+ if (existing && projection.append && projection.turns.length === 0)
168
+ return { state: existing, resume: "native" };
169
+ const replacing = existing !== null && !projection.append;
170
+ const id = replacing || !existing ? randomUUID() : existing.nativeSessionId;
171
+ const path = replacing || !existing ? this.nativePath(input, id, cwd) : existing.path;
172
+ const materialized = replacing ? rebindProjectionNativeSession(projection, id) : projection;
173
+ this.reportProjectionWarnings(input, materialized.projection.warnings);
174
+ const turns = materialized.turns;
175
+ const next = {
176
+ ...(replacing || !existing ? { version: 1, sessionId: input.sessionId, harness: input.harness, nativeSessionId: id, path, cwd, throughTurnId: null, revision: input.context.revision, checksum: "", pendingTurnId: null } : existing),
177
+ projectionVersion: 1,
178
+ nativeSessionId: id,
179
+ path,
180
+ cwd,
181
+ sourceSequence: materialized.cursor.throughSequence,
182
+ sourceTurnId: materialized.cursor.throughTurnId,
183
+ sourceFingerprint: materialized.cursor.sourceFingerprint,
184
+ throughTurnId: input.context.throughTurnId,
185
+ revision: input.context.revision,
186
+ };
187
+ const serialized = serializeProjectionRecords(materialized.projection.records);
188
+ if (input.harness === "codex" && turns.length === 0 && !existing)
189
+ return { state: next, resume: "new" };
190
+ if (materialized.append && existing) {
191
+ let suffix = serialized;
192
+ let leafId = existing.nativeLeafId ?? null;
193
+ if (input.harness === "pi") {
194
+ leafId = await this.lastNativeRecordId(existing.path);
195
+ if (leafId) {
196
+ let rebound = false;
197
+ const records = materialized.projection.records.map((entry) => {
198
+ if (rebound || !("parentId" in entry.record))
199
+ return entry;
200
+ rebound = true;
201
+ return { ...entry, record: { ...entry.record, parentId: leafId } };
202
+ });
203
+ suffix = serializeProjectionRecords(records);
204
+ }
205
+ }
206
+ await this.appendNativeFile(existing.path, suffix, existing.checksum);
207
+ const projectedLeaf = materialized.projection.records.at(-1)?.record.id;
208
+ if (typeof projectedLeaf === "string")
209
+ leafId = projectedLeaf;
210
+ next.nativeLeafId = leafId;
211
+ }
212
+ else {
213
+ await this.writeNativeFile(path, serialized);
214
+ const projectedLeaf = materialized.projection.records.at(-1)?.record.id;
215
+ next.nativeLeafId = typeof projectedLeaf === "string" ? projectedLeaf : null;
216
+ }
217
+ next.checksum = await checksumNativeFile(path);
218
+ if (existing)
219
+ await atomicJson(this.statePath(next), next);
220
+ return { state: next, resume: existing ? "handoff" : (turns.length ? "handoff" : "new") };
221
+ }
29
222
  async *pendingExecutionBatches() {
30
223
  let batch = [];
31
224
  for (const harness of ["pi", "codex"]) {
@@ -46,6 +239,7 @@ export class RuntimeSessionStore {
46
239
  }
47
240
  }
48
241
  catch (error) {
242
+ this.diagnostics?.log("error", "runtime.session_state_unreadable", { path: join(directory, name), error: serializeDiagnosticError(error) });
49
243
  console.error(`Runtime session state unreadable: ${join(directory, name)}`, error);
50
244
  }
51
245
  }
@@ -100,10 +294,13 @@ export class RuntimeSessionStore {
100
294
  receipt, reason: error.message, failedAt: new Date().toISOString(),
101
295
  });
102
296
  await rm(join(captures, name), { force: true });
297
+ this.diagnostics?.log("error", "archive.capture_unavailable", { reason: error.message, receipt: true }, { component: "archive" });
103
298
  console.error("Archive capture unavailable; receipt retained:", error.message);
104
299
  }
105
- else
300
+ else {
301
+ this.diagnostics?.log("warn", "archive.capture_pending", { error: serializeDiagnosticError(error) }, { component: "archive" });
106
302
  console.error("Archive capture pending:", error);
303
+ }
107
304
  }
108
305
  }
109
306
  await this.archives.flush(signal);
@@ -173,8 +370,11 @@ export class RuntimeSessionStore {
173
370
  }
174
371
  if (previous) {
175
372
  try {
176
- if (await checksumNativeFile(previous.path) !== previous.checksum)
177
- throw new Error("Native session changed outside Cohub; original data was preserved");
373
+ const nativeChecksum = await checksumNativeFile(previous.path);
374
+ if (nativeChecksum !== previous.checksum) {
375
+ this.diagnostics?.log("warn", "runtime.projection_rebuilt", { reason: "native file changed outside Cohub" }, { component: "projection", sessionId: input.sessionId, turnId: input.turnId, harness: input.harness });
376
+ return await this.syncNativeProjection(input, cwd, null, signal);
377
+ }
178
378
  if (previous.archivePendingTurnId) {
179
379
  await this.archives.stage(previous, previous.archivePendingTurnId);
180
380
  previous.archivePendingTurnId = undefined;
@@ -182,24 +382,22 @@ export class RuntimeSessionStore {
182
382
  }
183
383
  if (input.harness === "codex" && !previous.codexTokenTotals)
184
384
  previous.codexTokenTotals = await readCodexArchiveTotals(previous.path);
185
- if (previous.cwd === cwd && previous.throughTurnId === input.context.throughTurnId && previous.revision === input.context.revision)
186
- return { state: previous, resume: "native" };
385
+ return await this.syncNativeProjection(input, cwd, previous?.cwd === cwd ? previous : null, signal);
187
386
  }
188
387
  catch (error) {
189
388
  if (!missing(error))
190
389
  throw error;
191
390
  }
192
391
  }
193
- if (input.context.complete === false && !input.context.archive)
194
- throw new ContextRequiredError("Full context is required to materialize this session");
195
392
  const id = randomUUID();
196
- const path = join(this.root, input.harness, `${id}.jsonl`);
393
+ const path = this.nativePath(input, id, cwd);
197
394
  const archive = input.context.archive;
198
395
  const restore = archive?.harness === input.harness && archive.sessionId === input.sessionId && archive.turnId === input.context.throughTurnId;
199
396
  const rawPath = join(this.root, "archives", "restored", `${id}.jsonl`);
200
397
  const state = {
201
398
  version: 1, harness: input.harness, sessionId: input.sessionId, nativeSessionId: id,
202
399
  path, cwd, throughTurnId: input.context.throughTurnId, revision: input.context.revision, checksum: "", pendingTurnId: null,
400
+ projectionVersion: 1, sourceSequence: null, sourceTurnId: null, sourceFingerprint: null,
203
401
  };
204
402
  if (restore) {
205
403
  try {
@@ -209,52 +407,19 @@ export class RuntimeSessionStore {
209
407
  }
210
408
  catch (error) {
211
409
  signal?.throwIfAborted();
410
+ this.diagnostics?.log("warn", "archive.restore_failed", { error: serializeDiagnosticError(error) }, { component: "archive" });
212
411
  console.error("Native archive unavailable; rebuilding from durable history:", error);
213
412
  }
214
413
  }
215
- if (input.context.complete === false)
216
- throw new ContextRequiredError("Database history is required after archive recovery failed", true);
217
- let data = null;
218
- if (input.harness === "pi") {
219
- const entries = [{ type: "session", version: 3, id, cwd, timestamp: new Date().toISOString() }];
220
- let parentId = null;
221
- const history = selectRuntimeContextMessages(input.context.messages);
222
- const summary = history[0]?.role === "system" ? history[0].content.find((block) => block.type === "system_note" && block.note_type === "compacted") : undefined;
223
- let compaction;
224
- if (summary?.type === "system_note") {
225
- parentId = randomUUID().slice(0, 8);
226
- compaction = { type: "compaction", id: parentId, parentId: null, timestamp: new Date().toISOString(), summary: summary.text,
227
- firstKeptEntryId: "", tokensBefore: history[0]?.meta?.compaction?.tokensBefore ?? 0 };
228
- entries.push(compaction);
229
- }
230
- for (const message of contextToPiMessages(history)) {
231
- const entryId = randomUUID().slice(0, 8);
232
- if (compaction && !compaction.firstKeptEntryId)
233
- compaction.firstKeptEntryId = entryId;
234
- entries.push({ type: "message", id: entryId, parentId, timestamp: new Date().toISOString(), message });
235
- parentId = entryId;
236
- }
237
- data = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
238
- }
239
- if (data != null) {
240
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
241
- const file = await open(path, "wx", 0o600);
242
- try {
243
- await file.writeFile(data);
244
- await file.sync();
245
- }
246
- finally {
247
- await file.close();
248
- }
249
- state.checksum = checksum(data);
250
- }
251
- return { state, resume: input.context.messages.length ? "handoff" : "new" };
414
+ return await this.syncNativeProjection(input, cwd, state, signal);
252
415
  }
253
416
  async started(state, turnId) { state.pendingTurnId = turnId; state.resultChecksum = undefined; await atomicJson(this.statePath(state), state); }
254
417
  resultPath(state) { return join(this.root, "results", `${state.sessionId}.${state.harness}.json`); }
255
418
  async recordResult(state, requestId, events) {
256
419
  if (!state.pendingTurnId)
257
420
  throw new Error("Cannot record a result for an idle native session");
421
+ if (state.harness === "pi")
422
+ state.nativeLeafId = await this.lastNativeRecordId(state.path);
258
423
  state.resultChecksum = await checksumNativeFile(state.path);
259
424
  if (state.archivePendingTurnId)
260
425
  await atomicJson(join(this.archives.root, "captures", `${state.archivePendingTurnId}.json`), state);
@@ -278,13 +443,19 @@ export class RuntimeSessionStore {
278
443
  throw error;
279
444
  }
280
445
  }
281
- async archive(state, turnId) {
446
+ async archive(state, turnId, diagnosticContext = {}) {
282
447
  if (!state.path)
283
448
  return null;
284
449
  state.archivePendingTurnId = turnId;
285
- const reference = await this.archives.stage(state, turnId);
286
- state.archivePendingTurnId = undefined;
287
- return reference;
450
+ try {
451
+ const reference = await this.archives.stage(state, turnId);
452
+ state.archivePendingTurnId = undefined;
453
+ return reference;
454
+ }
455
+ catch (error) {
456
+ this.diagnostics?.log("warn", "archive.capture_failed", { error: serializeDiagnosticError(error) }, { ...diagnosticContext, component: "archive", turnId, harness: state.harness });
457
+ throw error;
458
+ }
288
459
  }
289
460
  async acknowledge(state, turnId, revision) {
290
461
  if (state.pendingTurnId !== turnId)
@@ -293,7 +464,14 @@ export class RuntimeSessionStore {
293
464
  return state.resultChecksum ?? state.checksum; throw error; });
294
465
  if (state.resultChecksum && currentChecksum !== state.resultChecksum)
295
466
  throw new Error("Native data changed before acknowledgement; files preserved");
296
- const acknowledged = { ...state, checksum: currentChecksum, pendingTurnId: null, resultChecksum: undefined, throughTurnId: turnId, revision };
467
+ let sourceSequence = state.sourceSequence ?? null;
468
+ let sourceTurnId = state.sourceTurnId ?? null;
469
+ let sourceFingerprint = state.sourceFingerprint ?? null;
470
+ const projectionCursor = await this.projectionStore.cursorForTurn(state.sessionId, turnId);
471
+ sourceSequence = projectionCursor.throughSequence;
472
+ sourceTurnId = projectionCursor.throughTurnId;
473
+ sourceFingerprint = projectionCursor.sourceFingerprint;
474
+ const acknowledged = { ...state, checksum: currentChecksum, pendingTurnId: null, resultChecksum: undefined, throughTurnId: turnId, revision, sourceSequence, sourceTurnId, sourceFingerprint };
297
475
  await atomicJson(this.statePath(state), acknowledged);
298
476
  Object.assign(state, acknowledged);
299
477
  }
@@ -0,0 +1,45 @@
1
+ export type RuntimeSpaceBinding = {
2
+ /** Canonical local workspace root. */
3
+ root: string;
4
+ /** Environment + authenticated actor identity. */
5
+ key: string;
6
+ spaceId: string;
7
+ };
8
+ export type RuntimeSpaceBindingsFile = {
9
+ version: 1;
10
+ bindings: RuntimeSpaceBinding[];
11
+ };
12
+ export type RuntimeSpaceBindingResolution = {
13
+ spaceId: string;
14
+ source: "binding" | "created" | "explicit";
15
+ };
16
+ export declare class RuntimeSpaceBindingsError extends Error {
17
+ name: string;
18
+ }
19
+ export declare function runtimeSpaceBindingsPath(): string;
20
+ export declare function normalizeRuntimeRoot(root: string): string;
21
+ export declare function canonicalRuntimeRoot(root: string): Promise<string>;
22
+ export declare function parseRuntimeSpaceBindings(raw: string, path?: string): RuntimeSpaceBindingsFile;
23
+ export declare function readRuntimeSpaceBindings(path?: string): Promise<RuntimeSpaceBindingsFile>;
24
+ export declare function findRuntimeSpaceBinding(bindings: RuntimeSpaceBinding[] | RuntimeSpaceBindingsFile, input: {
25
+ root: string;
26
+ key: string;
27
+ }): RuntimeSpaceBinding | null;
28
+ export declare function withRuntimeSpaceBindingsLock<T>(fn: () => Promise<T>, options?: {
29
+ path?: string;
30
+ lockPath?: string;
31
+ }): Promise<T>;
32
+ export declare function getRuntimeSpaceBinding(root: string, key: string | null | undefined, path?: string): Promise<RuntimeSpaceBinding | null>;
33
+ /**
34
+ * Resolve a local Runtime Space without silently creating duplicates.
35
+ * The per-binding lock covers creation; the shared file is locked only for
36
+ * short read-modify-write commits.
37
+ */
38
+ export declare function resolveRuntimeSpace(input: {
39
+ root: string;
40
+ identityKey: string | null | undefined;
41
+ explicitSpaceId?: string | null;
42
+ createSpace: () => Promise<string>;
43
+ validateSpace?: (spaceId: string) => Promise<void>;
44
+ path?: string;
45
+ }): Promise<RuntimeSpaceBindingResolution>;