@neta-art/cohub-cli 7.0.0 → 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
- import { type RuntimeExecutionEvent, type HarnessArchive, type RuntimeTurnInput } from "@neta-art/cohub";
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,32 @@ 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;
52
+ pendingExecutionBatches(): AsyncGenerator<RuntimePendingExecution[]>;
30
53
  flushArchives(signal: AbortSignal): Promise<void>;
31
54
  private flushArchiveOutbox;
32
55
  pendingTurnIds(sessionId: string): Promise<string[]>;
@@ -41,6 +64,6 @@ export declare class RuntimeSessionStore {
41
64
  state: NativeSession;
42
65
  events: RuntimeExecutionEvent[];
43
66
  } | null>;
44
- archive(state: NativeSession, turnId: string): Promise<HarnessArchive | null>;
67
+ archive(state: NativeSession, turnId: string, diagnosticContext?: RuntimeDiagnosticContext): Promise<HarnessArchive | null>;
45
68
  acknowledge(state: NativeSession, turnId: string, revision: string): Promise<void>;
46
69
  }
@@ -1,31 +1,252 @@
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, 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
+ }
222
+ async *pendingExecutionBatches() {
223
+ let batch = [];
224
+ for (const harness of ["pi", "codex"]) {
225
+ const directory = join(this.root, harness);
226
+ const names = await readdir(directory).catch((error) => { if (missing(error))
227
+ return []; throw error; });
228
+ for (const name of names) {
229
+ if (!name.endsWith(".json"))
230
+ continue;
231
+ try {
232
+ const state = JSON.parse(await readFile(join(directory, name), "utf8"));
233
+ if (state.version !== 1 || state.harness !== harness || !state.sessionId || !state.pendingTurnId)
234
+ continue;
235
+ batch.push({ sessionId: state.sessionId, turnId: state.pendingTurnId, harness });
236
+ if (batch.length >= RUNTIME_RECOVERY_BATCH_SIZE) {
237
+ yield batch;
238
+ batch = [];
239
+ }
240
+ }
241
+ catch (error) {
242
+ this.diagnostics?.log("error", "runtime.session_state_unreadable", { path: join(directory, name), error: serializeDiagnosticError(error) });
243
+ console.error(`Runtime session state unreadable: ${join(directory, name)}`, error);
244
+ }
245
+ }
246
+ }
247
+ if (batch.length)
248
+ yield batch;
249
+ }
29
250
  async flushArchives(signal) {
30
251
  this.archiveFlush ??= this.flushArchiveOutbox(signal).finally(() => { this.archiveFlush = null; });
31
252
  return this.archiveFlush;
@@ -46,20 +267,20 @@ export class RuntimeSessionStore {
46
267
  state = JSON.parse(receipt);
47
268
  }
48
269
  catch {
49
- throw new CaptureUnavailableError("Invalid capture receipt / 归档捕获记录无效");
270
+ throw new CaptureUnavailableError("Invalid capture receipt");
50
271
  }
51
272
  const turnId = state?.archivePendingTurnId;
52
273
  if (typeof turnId !== "string" || typeof state?.path !== "string" || typeof state.resultChecksum !== "string") {
53
- throw new CaptureUnavailableError("Invalid capture receipt / 归档捕获记录无效");
274
+ throw new CaptureUnavailableError("Invalid capture receipt");
54
275
  }
55
276
  if (!await this.archives.hasCapture(turnId)) {
56
277
  const digest = await checksumNativeFile(state.path).catch((error) => {
57
278
  if (missing(error))
58
- throw new CaptureUnavailableError("Native session missing / 原生会话文件不存在");
279
+ throw new CaptureUnavailableError("Native session missing");
59
280
  throw error;
60
281
  });
61
282
  if (digest !== state.resultChecksum)
62
- throw new CaptureUnavailableError("Native session changed; original files retained / 原生会话已变化,原文件已保留");
283
+ throw new CaptureUnavailableError("Native session changed; original files retained");
63
284
  signal.throwIfAborted();
64
285
  await this.archives.stage(state, turnId);
65
286
  }
@@ -73,10 +294,13 @@ export class RuntimeSessionStore {
73
294
  receipt, reason: error.message, failedAt: new Date().toISOString(),
74
295
  });
75
296
  await rm(join(captures, name), { force: true });
76
- console.error("Archive capture unavailable; receipt retained / 归档捕获不可恢复,记录已保留:", error.message);
297
+ this.diagnostics?.log("error", "archive.capture_unavailable", { reason: error.message, receipt: true }, { component: "archive" });
298
+ console.error("Archive capture unavailable; receipt retained:", error.message);
299
+ }
300
+ else {
301
+ this.diagnostics?.log("warn", "archive.capture_pending", { error: serializeDiagnosticError(error) }, { component: "archive" });
302
+ console.error("Archive capture pending:", error);
77
303
  }
78
- else
79
- console.error("Archive capture pending / 归档捕获待重试:", error);
80
304
  }
81
305
  }
82
306
  await this.archives.flush(signal);
@@ -146,8 +370,11 @@ export class RuntimeSessionStore {
146
370
  }
147
371
  if (previous) {
148
372
  try {
149
- if (await checksumNativeFile(previous.path) !== previous.checksum)
150
- 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
+ }
151
378
  if (previous.archivePendingTurnId) {
152
379
  await this.archives.stage(previous, previous.archivePendingTurnId);
153
380
  previous.archivePendingTurnId = undefined;
@@ -155,24 +382,22 @@ export class RuntimeSessionStore {
155
382
  }
156
383
  if (input.harness === "codex" && !previous.codexTokenTotals)
157
384
  previous.codexTokenTotals = await readCodexArchiveTotals(previous.path);
158
- if (previous.cwd === cwd && previous.throughTurnId === input.context.throughTurnId && previous.revision === input.context.revision)
159
- return { state: previous, resume: "native" };
385
+ return await this.syncNativeProjection(input, cwd, previous?.cwd === cwd ? previous : null, signal);
160
386
  }
161
387
  catch (error) {
162
388
  if (!missing(error))
163
389
  throw error;
164
390
  }
165
391
  }
166
- if (input.context.complete === false && !input.context.archive)
167
- throw new ContextRequiredError("Full context is required to materialize this session");
168
392
  const id = randomUUID();
169
- const path = join(this.root, input.harness, `${id}.jsonl`);
393
+ const path = this.nativePath(input, id, cwd);
170
394
  const archive = input.context.archive;
171
395
  const restore = archive?.harness === input.harness && archive.sessionId === input.sessionId && archive.turnId === input.context.throughTurnId;
172
396
  const rawPath = join(this.root, "archives", "restored", `${id}.jsonl`);
173
397
  const state = {
174
398
  version: 1, harness: input.harness, sessionId: input.sessionId, nativeSessionId: id,
175
399
  path, cwd, throughTurnId: input.context.throughTurnId, revision: input.context.revision, checksum: "", pendingTurnId: null,
400
+ projectionVersion: 1, sourceSequence: null, sourceTurnId: null, sourceFingerprint: null,
176
401
  };
177
402
  if (restore) {
178
403
  try {
@@ -182,52 +407,19 @@ export class RuntimeSessionStore {
182
407
  }
183
408
  catch (error) {
184
409
  signal?.throwIfAborted();
185
- console.error("Native archive unavailable; rebuilding from durable history / 原生归档不可用,将从持久历史重建:", error);
410
+ this.diagnostics?.log("warn", "archive.restore_failed", { error: serializeDiagnosticError(error) }, { component: "archive" });
411
+ console.error("Native archive unavailable; rebuilding from durable history:", error);
186
412
  }
187
413
  }
188
- if (input.context.complete === false)
189
- throw new ContextRequiredError("Database history is required after archive recovery failed", true);
190
- let data = null;
191
- if (input.harness === "pi") {
192
- const entries = [{ type: "session", version: 3, id, cwd, timestamp: new Date().toISOString() }];
193
- let parentId = null;
194
- const history = selectRuntimeContextMessages(input.context.messages);
195
- const summary = history[0]?.role === "system" ? history[0].content.find((block) => block.type === "system_note" && block.note_type === "compacted") : undefined;
196
- let compaction;
197
- if (summary?.type === "system_note") {
198
- parentId = randomUUID().slice(0, 8);
199
- compaction = { type: "compaction", id: parentId, parentId: null, timestamp: new Date().toISOString(), summary: summary.text,
200
- firstKeptEntryId: "", tokensBefore: history[0]?.meta?.compaction?.tokensBefore ?? 0 };
201
- entries.push(compaction);
202
- }
203
- for (const message of contextToPiMessages(history)) {
204
- const entryId = randomUUID().slice(0, 8);
205
- if (compaction && !compaction.firstKeptEntryId)
206
- compaction.firstKeptEntryId = entryId;
207
- entries.push({ type: "message", id: entryId, parentId, timestamp: new Date().toISOString(), message });
208
- parentId = entryId;
209
- }
210
- data = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
211
- }
212
- if (data != null) {
213
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
214
- const file = await open(path, "wx", 0o600);
215
- try {
216
- await file.writeFile(data);
217
- await file.sync();
218
- }
219
- finally {
220
- await file.close();
221
- }
222
- state.checksum = checksum(data);
223
- }
224
- return { state, resume: input.context.messages.length ? "handoff" : "new" };
414
+ return await this.syncNativeProjection(input, cwd, state, signal);
225
415
  }
226
416
  async started(state, turnId) { state.pendingTurnId = turnId; state.resultChecksum = undefined; await atomicJson(this.statePath(state), state); }
227
417
  resultPath(state) { return join(this.root, "results", `${state.sessionId}.${state.harness}.json`); }
228
418
  async recordResult(state, requestId, events) {
229
419
  if (!state.pendingTurnId)
230
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);
231
423
  state.resultChecksum = await checksumNativeFile(state.path);
232
424
  if (state.archivePendingTurnId)
233
425
  await atomicJson(join(this.archives.root, "captures", `${state.archivePendingTurnId}.json`), state);
@@ -251,13 +443,19 @@ export class RuntimeSessionStore {
251
443
  throw error;
252
444
  }
253
445
  }
254
- async archive(state, turnId) {
446
+ async archive(state, turnId, diagnosticContext = {}) {
255
447
  if (!state.path)
256
448
  return null;
257
449
  state.archivePendingTurnId = turnId;
258
- const reference = await this.archives.stage(state, turnId);
259
- state.archivePendingTurnId = undefined;
260
- 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
+ }
261
459
  }
262
460
  async acknowledge(state, turnId, revision) {
263
461
  if (state.pendingTurnId !== turnId)
@@ -266,7 +464,14 @@ export class RuntimeSessionStore {
266
464
  return state.resultChecksum ?? state.checksum; throw error; });
267
465
  if (state.resultChecksum && currentChecksum !== state.resultChecksum)
268
466
  throw new Error("Native data changed before acknowledgement; files preserved");
269
- 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 };
270
475
  await atomicJson(this.statePath(state), acknowledged);
271
476
  Object.assign(state, acknowledged);
272
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>;