@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.
- package/README.md +17 -9
- package/dist/commands/apps.js +2 -2
- 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 +269 -58
- 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 +31 -25
- package/dist/runtime/connection.d.ts +3 -0
- package/dist/runtime/connection.js +251 -42
- 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/native-archive.js +3 -3
- package/dist/runtime/process-group.js +1 -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 +28 -5
- package/dist/runtime/session-store.js +272 -67
- 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
|
-
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
|
-
|
|
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 {
|
|
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
|
+
}
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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>;
|