@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.
- package/README.md +13 -1
- package/dist/commands/desktop.d.ts +9 -0
- package/dist/commands/desktop.js +13 -5
- package/dist/commands/run.d.ts +11 -0
- package/dist/commands/run.js +4 -4
- package/dist/commands/runtime.js +254 -43
- package/dist/commands/sandboxd-binary.d.ts +2 -0
- package/dist/commands/sandboxd-binary.js +37 -16
- package/dist/index.js +4 -2
- package/dist/runtime/archive-store.d.ts +2 -0
- package/dist/runtime/archive-store.js +7 -1
- package/dist/runtime/connection.d.ts +3 -0
- package/dist/runtime/connection.js +242 -36
- package/dist/runtime/diagnostics.d.ts +104 -0
- package/dist/runtime/diagnostics.js +382 -0
- package/dist/runtime/harness.d.ts +3 -2
- package/dist/runtime/harness.js +39 -8
- package/dist/runtime/json-rpc.d.ts +2 -0
- package/dist/runtime/json-rpc.js +12 -1
- package/dist/runtime/projection-store.d.ts +34 -0
- package/dist/runtime/projection-store.js +103 -0
- package/dist/runtime/session-store.d.ts +26 -4
- package/dist/runtime/session-store.js +238 -60
- package/dist/runtime/space-binding.d.ts +45 -0
- package/dist/runtime/space-binding.js +305 -0
- package/dist/runtime/turn-projection.d.ts +43 -0
- package/dist/runtime/turn-projection.js +127 -0
- package/dist/space.d.ts +11 -3
- package/dist/space.js +18 -6
- package/package.json +3 -2
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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>;
|