@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1

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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. package/package.json +53 -4
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Extracted verbatim from 20x `src/shared/reasoning-effort.ts`.
3
+ * Nothing changed — the file has no imports. Source formatting (no
4
+ * semicolons) is preserved deliberately so the diff stays reviewable.
5
+ */
6
+ export declare const CODEX_REASONING_EFFORT_VALUES: readonly ["minimal", "low", "medium", "high", "xhigh"];
7
+ export declare const CLAUDE_REASONING_EFFORT_VALUES: readonly ["low", "medium", "high", "xhigh", "max"];
8
+ export declare const REASONING_EFFORT_VALUES: readonly ["minimal", "low", "medium", "high", "xhigh", "max"];
9
+ export type ReasoningEffort = typeof REASONING_EFFORT_VALUES[number];
10
+ export declare function isReasoningEffort(value: unknown): value is ReasoningEffort;
11
+ export declare function normalizeReasoningEffort(value: unknown): ReasoningEffort | undefined;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ /**
3
+ * Extracted verbatim from 20x `src/shared/reasoning-effort.ts`.
4
+ * Nothing changed — the file has no imports. Source formatting (no
5
+ * semicolons) is preserved deliberately so the diff stays reviewable.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.REASONING_EFFORT_VALUES = exports.CLAUDE_REASONING_EFFORT_VALUES = exports.CODEX_REASONING_EFFORT_VALUES = void 0;
9
+ exports.isReasoningEffort = isReasoningEffort;
10
+ exports.normalizeReasoningEffort = normalizeReasoningEffort;
11
+ exports.CODEX_REASONING_EFFORT_VALUES = ['minimal', 'low', 'medium', 'high', 'xhigh'];
12
+ exports.CLAUDE_REASONING_EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
13
+ exports.REASONING_EFFORT_VALUES = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
14
+ const REASONING_EFFORT_SET = new Set(exports.REASONING_EFFORT_VALUES);
15
+ function isReasoningEffort(value) {
16
+ return typeof value === 'string' && REASONING_EFFORT_SET.has(value);
17
+ }
18
+ function normalizeReasoningEffort(value) {
19
+ return isReasoningEffort(value) ? value : undefined;
20
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * WHERE THE HARNESS REMEMBERS A CONVERSATION BETWEEN TURNS.
3
+ *
4
+ * Two things live here, and they are two things rather than one on purpose:
5
+ *
6
+ * BACKEND SESSION ID — an OPAQUE CHECKPOINT. It means "the backend still
7
+ * holds this conversation's context; ask it, do not rebuild it". The harness
8
+ * never interprets it; only the adapter that minted it knows what it is.
9
+ *
10
+ * MODEL MESSAGES — the FALLBACK. What the model was actually given and
11
+ * actually said, in order, so a fresh session on a fresh machine can be told
12
+ * what happened. See `runtime/conversation.ts` for why this is not the
13
+ * transcript projection.
14
+ *
15
+ * WHY BOTH. The checkpoint is better whenever it works, and it stops working
16
+ * for entirely ordinary reasons: the sandbox was swept for being idle, the
17
+ * runner was redeployed, the turn was leased to a different machine. The log is
18
+ * what turns those from "the user lost the thread" into "the user repeats
19
+ * nothing".
20
+ *
21
+ * THIS STORE IS A CACHE, NOT THE RECORD. The control plane owns the durable
22
+ * copy, because a runner is disposable and a new one starts with an empty disk.
23
+ * This exists so the common case — same runner, next turn — costs no network
24
+ * call at all.
25
+ */
26
+ import type { ConversationStore, ModelMessageRecord, SessionCheckpoint } from '../ports';
27
+ import type { ModelMessage } from '../runtime/conversation';
28
+ interface SqliteStatement {
29
+ run(...params: unknown[]): unknown;
30
+ all(...params: unknown[]): unknown[];
31
+ get(...params: unknown[]): unknown;
32
+ }
33
+ interface SqliteDatabase {
34
+ exec(sql: string): void;
35
+ prepare(sql: string): SqliteStatement;
36
+ }
37
+ export declare class SqliteConversationStore implements ConversationStore {
38
+ private readonly db;
39
+ constructor(db: SqliteDatabase);
40
+ getCheckpoint(sessionId: string): SessionCheckpoint | null;
41
+ setCheckpoint(sessionId: string, backendSessionId: string | null, backend: string, extra?: {
42
+ backendOwnerId?: string | null;
43
+ permissionDecision?: string;
44
+ }): void;
45
+ getModelMessages(sessionId: string): ModelMessageRecord[];
46
+ /**
47
+ * Append, with the sequence allocated HERE rather than by the caller.
48
+ *
49
+ * `INSERT OR IGNORE` plus a computed `MAX(seq)+1` makes a re-append after a
50
+ * crash idempotent in the only way that matters: a turn that ingested its
51
+ * messages and then died before recording that it had cannot produce a
52
+ * duplicate at a NEW seq, because the seq is derived from what is already
53
+ * stored, not from a counter the caller holds.
54
+ */
55
+ appendModelMessages(sessionId: string, messages: ModelMessage[]): void;
56
+ /**
57
+ * Replace the whole log with what the control plane holds.
58
+ *
59
+ * Used when the control plane knows about messages this runner has never
60
+ * seen — a cold runner. REPLACE rather than merge, because the control plane
61
+ * is the record and a local cache that disagrees with it is worse than no
62
+ * cache at all.
63
+ */
64
+ replaceModelMessages(sessionId: string, messages: ModelMessageRecord[]): void;
65
+ forget(sessionId: string): void;
66
+ }
67
+ /**
68
+ * The same behaviour without durability.
69
+ *
70
+ * A REAL implementation, not a stub — if the two disagreed, every test using
71
+ * this would be testing something the product does not do.
72
+ */
73
+ export declare class MemoryConversationStore implements ConversationStore {
74
+ private readonly checkpoints;
75
+ private readonly messages;
76
+ getCheckpoint(sessionId: string): SessionCheckpoint | null;
77
+ setCheckpoint(sessionId: string, backendSessionId: string | null, backend: string, extra?: {
78
+ backendOwnerId?: string | null;
79
+ permissionDecision?: string;
80
+ }): void;
81
+ getModelMessages(sessionId: string): ModelMessageRecord[];
82
+ appendModelMessages(sessionId: string, messages: ModelMessage[]): void;
83
+ replaceModelMessages(sessionId: string, messages: ModelMessageRecord[]): void;
84
+ forget(sessionId: string): void;
85
+ }
86
+ export interface OpenConversationStoreResult {
87
+ store: ConversationStore;
88
+ durable: boolean;
89
+ reason?: string;
90
+ }
91
+ /**
92
+ * Open the durable conversation store, or say plainly why it could not be.
93
+ *
94
+ * NEVER THROWS, for the same reason `openTranscriptStore` does not: the control
95
+ * plane is the record, so a harness that cannot open a local database is
96
+ * degraded rather than broken. It will fetch history over the network every
97
+ * turn instead of caching it, which is slower and still correct.
98
+ */
99
+ export declare function openConversationStore(path: string): OpenConversationStoreResult;
100
+ export {};
@@ -0,0 +1,305 @@
1
+ "use strict";
2
+ /**
3
+ * WHERE THE HARNESS REMEMBERS A CONVERSATION BETWEEN TURNS.
4
+ *
5
+ * Two things live here, and they are two things rather than one on purpose:
6
+ *
7
+ * BACKEND SESSION ID — an OPAQUE CHECKPOINT. It means "the backend still
8
+ * holds this conversation's context; ask it, do not rebuild it". The harness
9
+ * never interprets it; only the adapter that minted it knows what it is.
10
+ *
11
+ * MODEL MESSAGES — the FALLBACK. What the model was actually given and
12
+ * actually said, in order, so a fresh session on a fresh machine can be told
13
+ * what happened. See `runtime/conversation.ts` for why this is not the
14
+ * transcript projection.
15
+ *
16
+ * WHY BOTH. The checkpoint is better whenever it works, and it stops working
17
+ * for entirely ordinary reasons: the sandbox was swept for being idle, the
18
+ * runner was redeployed, the turn was leased to a different machine. The log is
19
+ * what turns those from "the user lost the thread" into "the user repeats
20
+ * nothing".
21
+ *
22
+ * THIS STORE IS A CACHE, NOT THE RECORD. The control plane owns the durable
23
+ * copy, because a runner is disposable and a new one starts with an empty disk.
24
+ * This exists so the common case — same runner, next turn — costs no network
25
+ * call at all.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.MemoryConversationStore = exports.SqliteConversationStore = void 0;
29
+ exports.openConversationStore = openConversationStore;
30
+ const SCHEMA = `
31
+ CREATE TABLE IF NOT EXISTS model_messages (
32
+ session_id TEXT NOT NULL,
33
+ seq INTEGER NOT NULL,
34
+ role TEXT NOT NULL,
35
+ content TEXT NOT NULL,
36
+ created_at INTEGER NOT NULL,
37
+ PRIMARY KEY (session_id, seq)
38
+ );
39
+ CREATE TABLE IF NOT EXISTS session_checkpoints (
40
+ session_id TEXT PRIMARY KEY,
41
+ backend_session_id TEXT,
42
+ backend TEXT,
43
+ -- WHICH INSTANCE minted the handle. Without it a checkpoint is handed to
44
+ -- whichever runner takes the next turn and resolves nowhere.
45
+ backend_owner TEXT,
46
+ -- The permission decision the backend session was CREATED under. See
47
+ -- ConversationStore#getCheckpoint for why this is the only place it can
48
+ -- live. Nullable: rows written before this column existed have no value,
49
+ -- and an unknown decision must NOT force a rebuild.
50
+ permission_decision TEXT,
51
+ updated_at INTEGER NOT NULL
52
+ );
53
+ `;
54
+ function errorMessage(error) {
55
+ return error instanceof Error ? error.message : String(error);
56
+ }
57
+ /* -------------------------------------------------------------------------- */
58
+ /* SQLite */
59
+ /* -------------------------------------------------------------------------- */
60
+ class SqliteConversationStore {
61
+ db;
62
+ constructor(db) {
63
+ this.db = db;
64
+ this.db.exec(SCHEMA);
65
+ /*
66
+ * ADD THE COLUMNS TO A DATABASE THAT PREDATES THEM.
67
+ *
68
+ * `CREATE TABLE IF NOT EXISTS` is a no-op on an existing table, so a store
69
+ * written before these columns existed keeps its old shape and every read
70
+ * would throw. Any runner that has run a turn before this change has
71
+ * exactly that file on disk.
72
+ *
73
+ * MERGE NOTE (#1591 × #1602): both changes added a column here. #1602 used
74
+ * a bare `catch {}`; this keeps #1591's stricter guard and applies it to
75
+ * BOTH columns, because the failure it protects against is the same for
76
+ * each — see the comment inside.
77
+ */
78
+ for (const column of ['backend_owner', 'permission_decision']) {
79
+ try {
80
+ this.db.exec(`ALTER TABLE session_checkpoints ADD COLUMN ${column} TEXT`);
81
+ }
82
+ catch (error) {
83
+ /*
84
+ * SQLite has no ADD COLUMN IF NOT EXISTS, so the ordinary outcome on an
85
+ * already-migrated store is a duplicate-column error. That one is
86
+ * expected and silent.
87
+ *
88
+ * ANYTHING ELSE IS NOT. A bare `catch {}` here would swallow a locked
89
+ * database, a corrupt file or a read-only disk, and the store would
90
+ * then run on without the column — every checkpoint would read as
91
+ * "owner unknown", every turn would replay, and the symptom would be
92
+ * indistinguishable from the defect the column was added to fix.
93
+ */
94
+ const message = errorMessage(error).toLowerCase();
95
+ if (!message.includes('duplicate column')) {
96
+ console.warn(`[ConversationStore] could not add session_checkpoints.${column}: ` +
97
+ errorMessage(error));
98
+ }
99
+ }
100
+ }
101
+ }
102
+ getCheckpoint(sessionId) {
103
+ const row = this.db
104
+ .prepare('SELECT backend_session_id, backend, backend_owner, permission_decision FROM session_checkpoints WHERE session_id = ?')
105
+ .get(sessionId);
106
+ if (!row?.backend_session_id)
107
+ return null;
108
+ return {
109
+ backendSessionId: row.backend_session_id,
110
+ backend: row.backend ?? '',
111
+ backendOwnerId: row.backend_owner ?? null,
112
+ ...(row.permission_decision
113
+ ? { permissionDecision: row.permission_decision }
114
+ : {})
115
+ };
116
+ }
117
+ setCheckpoint(sessionId, backendSessionId, backend, extra) {
118
+ const backendOwnerId = extra?.backendOwnerId ?? null;
119
+ const permissionDecision = extra?.permissionDecision;
120
+ this.db
121
+ .prepare(`INSERT INTO session_checkpoints (session_id, backend_session_id, backend, backend_owner, permission_decision, updated_at)
122
+ VALUES (?, ?, ?, ?, ?, ?)
123
+ ON CONFLICT(session_id) DO UPDATE SET
124
+ backend_session_id = excluded.backend_session_id,
125
+ backend = excluded.backend,
126
+ backend_owner = excluded.backend_owner,
127
+ permission_decision = excluded.permission_decision,
128
+ updated_at = excluded.updated_at`)
129
+ .run(sessionId, backendSessionId, backend, backendOwnerId, permissionDecision ?? null, Date.now());
130
+ }
131
+ getModelMessages(sessionId) {
132
+ const rows = this.db
133
+ .prepare('SELECT seq, role, content FROM model_messages WHERE session_id = ? ORDER BY seq ASC')
134
+ .all(sessionId);
135
+ return rows.map((row) => ({
136
+ seq: row.seq,
137
+ role: row.role === 'user' ? 'user' : 'assistant',
138
+ content: row.content
139
+ }));
140
+ }
141
+ /**
142
+ * Append, with the sequence allocated HERE rather than by the caller.
143
+ *
144
+ * `INSERT OR IGNORE` plus a computed `MAX(seq)+1` makes a re-append after a
145
+ * crash idempotent in the only way that matters: a turn that ingested its
146
+ * messages and then died before recording that it had cannot produce a
147
+ * duplicate at a NEW seq, because the seq is derived from what is already
148
+ * stored, not from a counter the caller holds.
149
+ */
150
+ appendModelMessages(sessionId, messages) {
151
+ if (messages.length === 0)
152
+ return;
153
+ const row = this.db
154
+ .prepare('SELECT COALESCE(MAX(seq), 0) AS max_seq FROM model_messages WHERE session_id = ?')
155
+ .get(sessionId);
156
+ let seq = Number(row?.max_seq ?? 0);
157
+ const insert = this.db.prepare(`INSERT INTO model_messages (session_id, seq, role, content, created_at)
158
+ VALUES (?, ?, ?, ?, ?)
159
+ ON CONFLICT(session_id, seq) DO NOTHING`);
160
+ this.db.exec('BEGIN');
161
+ try {
162
+ for (const message of messages) {
163
+ seq += 1;
164
+ insert.run(sessionId, seq, message.role, message.content, Date.now());
165
+ }
166
+ this.db.exec('COMMIT');
167
+ }
168
+ catch (error) {
169
+ try {
170
+ this.db.exec('ROLLBACK');
171
+ }
172
+ catch {
173
+ /* the original error is the one worth reporting */
174
+ }
175
+ throw error;
176
+ }
177
+ }
178
+ /**
179
+ * Replace the whole log with what the control plane holds.
180
+ *
181
+ * Used when the control plane knows about messages this runner has never
182
+ * seen — a cold runner. REPLACE rather than merge, because the control plane
183
+ * is the record and a local cache that disagrees with it is worse than no
184
+ * cache at all.
185
+ */
186
+ replaceModelMessages(sessionId, messages) {
187
+ this.db.exec('BEGIN');
188
+ try {
189
+ this.db
190
+ .prepare('DELETE FROM model_messages WHERE session_id = ?')
191
+ .run(sessionId);
192
+ const insert = this.db.prepare(`INSERT INTO model_messages (session_id, seq, role, content, created_at)
193
+ VALUES (?, ?, ?, ?, ?)`);
194
+ for (const message of messages) {
195
+ insert.run(sessionId, message.seq, message.role, message.content, Date.now());
196
+ }
197
+ this.db.exec('COMMIT');
198
+ }
199
+ catch (error) {
200
+ try {
201
+ this.db.exec('ROLLBACK');
202
+ }
203
+ catch {
204
+ /* same */
205
+ }
206
+ throw error;
207
+ }
208
+ }
209
+ forget(sessionId) {
210
+ this.db
211
+ .prepare('DELETE FROM model_messages WHERE session_id = ?')
212
+ .run(sessionId);
213
+ this.db
214
+ .prepare('DELETE FROM session_checkpoints WHERE session_id = ?')
215
+ .run(sessionId);
216
+ }
217
+ }
218
+ exports.SqliteConversationStore = SqliteConversationStore;
219
+ /* -------------------------------------------------------------------------- */
220
+ /* In-memory */
221
+ /* -------------------------------------------------------------------------- */
222
+ /**
223
+ * The same behaviour without durability.
224
+ *
225
+ * A REAL implementation, not a stub — if the two disagreed, every test using
226
+ * this would be testing something the product does not do.
227
+ */
228
+ class MemoryConversationStore {
229
+ checkpoints = new Map();
230
+ messages = new Map();
231
+ getCheckpoint(sessionId) {
232
+ return this.checkpoints.get(sessionId) ?? null;
233
+ }
234
+ setCheckpoint(sessionId, backendSessionId, backend, extra) {
235
+ const backendOwnerId = extra?.backendOwnerId ?? null;
236
+ const permissionDecision = extra?.permissionDecision;
237
+ if (!backendSessionId) {
238
+ this.checkpoints.delete(sessionId);
239
+ return;
240
+ }
241
+ this.checkpoints.set(sessionId, {
242
+ backendSessionId,
243
+ backend,
244
+ backendOwnerId,
245
+ ...(permissionDecision ? { permissionDecision } : {})
246
+ });
247
+ }
248
+ getModelMessages(sessionId) {
249
+ return (this.messages.get(sessionId) ?? []).map((message) => ({
250
+ ...message
251
+ }));
252
+ }
253
+ appendModelMessages(sessionId, messages) {
254
+ if (messages.length === 0)
255
+ return;
256
+ const existing = this.messages.get(sessionId) ?? [];
257
+ let seq = existing.length ? existing[existing.length - 1].seq : 0;
258
+ for (const message of messages) {
259
+ seq += 1;
260
+ existing.push({ seq, role: message.role, content: message.content });
261
+ }
262
+ this.messages.set(sessionId, existing);
263
+ }
264
+ replaceModelMessages(sessionId, messages) {
265
+ this.messages.set(sessionId, messages.map((message) => ({ ...message })));
266
+ }
267
+ forget(sessionId) {
268
+ this.messages.delete(sessionId);
269
+ this.checkpoints.delete(sessionId);
270
+ }
271
+ }
272
+ exports.MemoryConversationStore = MemoryConversationStore;
273
+ /**
274
+ * Open the durable conversation store, or say plainly why it could not be.
275
+ *
276
+ * NEVER THROWS, for the same reason `openTranscriptStore` does not: the control
277
+ * plane is the record, so a harness that cannot open a local database is
278
+ * degraded rather than broken. It will fetch history over the network every
279
+ * turn instead of caching it, which is slower and still correct.
280
+ */
281
+ function openConversationStore(path) {
282
+ let sqlite;
283
+ try {
284
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
285
+ sqlite = require('node:sqlite');
286
+ }
287
+ catch (error) {
288
+ return {
289
+ store: new MemoryConversationStore(),
290
+ durable: false,
291
+ reason: `node:sqlite unavailable (needs Node >= 22.13): ${errorMessage(error)}`
292
+ };
293
+ }
294
+ try {
295
+ const db = new sqlite.DatabaseSync(path);
296
+ return { store: new SqliteConversationStore(db), durable: true };
297
+ }
298
+ catch (error) {
299
+ return {
300
+ store: new MemoryConversationStore(),
301
+ durable: false,
302
+ reason: `could not open ${path}: ${errorMessage(error)}`
303
+ };
304
+ }
305
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * THE HARNESS TRANSCRIPT STORE.
3
+ *
4
+ * Decision: KEEP THE SQLITE STORE. A runner that holds its working state only
5
+ * in memory loses a turn to its own crash, and the crash is exactly when the
6
+ * transcript matters. The control plane stays the source of truth via
7
+ * `POST /ingest`; this table is WORKING STATE — what has been produced, and
8
+ * what has been acknowledged — so a restarted harness can resume a lease
9
+ * instead of replaying a turn the user already paid for.
10
+ *
11
+ * WHY `node:sqlite` AND NOT `better-sqlite3`, which 20x uses:
12
+ *
13
+ * `pf-bootstrap` installs the harness with `--ignore-scripts` (a Phase 0
14
+ * hardening decision: an npm install script runs arbitrary code as the harness
15
+ * user before any of our code is reached). `better-sqlite3` is a NATIVE module
16
+ * whose binary is fetched or built by `prebuild-install` — an install script.
17
+ * Depending on it would therefore break the one boot path Phase 0 actually
18
+ * proved, and it would break it silently, at runtime, on a customer VM.
19
+ *
20
+ * `node:sqlite` ships INSIDE Node 22 (unflagged from 22.13). It costs no
21
+ * dependency, no native build, no install script and no prebuild matrix, and
22
+ * the sandbox image is already `node:22-bookworm-slim`. On an older Node the
23
+ * module is simply absent and `openTranscriptStore` falls back to memory, which
24
+ * costs crash-resumption and nothing else.
25
+ *
26
+ * THE SEVEN INVARIANTS are ported from 20x `src/main/database.ts` (schema at
27
+ * :1225, upsert at :2118, delta at :2179, snapshot at :2209) and each is
28
+ * asserted individually in the tests. They are NOT approximated: the control
29
+ * plane implements the same seven, and a transcript written by a runner must be
30
+ * byte-for-byte the transcript the desktop would have written.
31
+ */
32
+ import { StoredPart, StoredPartInput, TranscriptStore } from '../ports';
33
+ /**
34
+ * Structurally typed rather than imported.
35
+ *
36
+ * `node:sqlite` has no bundled type declarations on every supported Node, and a
37
+ * hard `import { DatabaseSync } from 'node:sqlite'` would make the package fail
38
+ * to COMPILE wherever those types are missing — turning a graceful runtime
39
+ * fallback into a build error. The surface used is five methods wide, so
40
+ * describing it here is cheaper and safer than a dependency.
41
+ */
42
+ interface SqliteStatement {
43
+ run(...params: unknown[]): unknown;
44
+ get(...params: unknown[]): unknown;
45
+ all(...params: unknown[]): unknown[];
46
+ }
47
+ interface SqliteDatabase {
48
+ exec(sql: string): void;
49
+ prepare(sql: string): SqliteStatement;
50
+ close(): void;
51
+ }
52
+ export declare class SqliteTranscriptStore implements TranscriptStore {
53
+ private readonly db;
54
+ constructor(db: SqliteDatabase);
55
+ /**
56
+ * INVARIANTS 1-5, in one transaction.
57
+ *
58
+ * The transaction is explicit BEGIN/COMMIT because `node:sqlite` has no
59
+ * `db.transaction()` wrapper of the kind better-sqlite3 provides. The
60
+ * rollback on failure is what makes invariant 1 true: a batch that fails
61
+ * half-way must leave no parts behind, or a retry would see a transcript that
62
+ * never existed.
63
+ */
64
+ upsertParts(sessionId: string, parts: StoredPartInput[]): {
65
+ maxRev: number;
66
+ changedPartIds: string[];
67
+ };
68
+ /** INVARIANTS 6 and 7. */
69
+ getDelta(sessionId: string, sinceRev: number): {
70
+ parts: StoredPart[];
71
+ maxRev: number;
72
+ };
73
+ getParts(sessionId: string): StoredPart[];
74
+ getMaxRev(sessionId: string): number;
75
+ deleteParts(sessionId: string): void;
76
+ close(): void;
77
+ }
78
+ /**
79
+ * The same seven invariants, without durability.
80
+ *
81
+ * Used when `node:sqlite` is absent, and in tests. It is a REAL implementation
82
+ * rather than a stub: if the two disagreed, every test that used this would be
83
+ * testing something the product does not do. The tests run the same invariant
84
+ * suite against both.
85
+ */
86
+ export declare class MemoryTranscriptStore implements TranscriptStore {
87
+ private readonly bySession;
88
+ private globalRev;
89
+ upsertParts(sessionId: string, parts: StoredPartInput[]): {
90
+ maxRev: number;
91
+ changedPartIds: string[];
92
+ };
93
+ getDelta(sessionId: string, sinceRev: number): {
94
+ parts: StoredPart[];
95
+ maxRev: number;
96
+ };
97
+ getParts(sessionId: string): StoredPart[];
98
+ getMaxRev(sessionId: string): number;
99
+ deleteParts(sessionId: string): void;
100
+ private ordered;
101
+ }
102
+ export interface OpenStoreResult {
103
+ store: TranscriptStore;
104
+ /** True when the store survives a restart. False means the fallback is in use. */
105
+ durable: boolean;
106
+ /** Why durability was unavailable. Reported so a degraded fleet is visible. */
107
+ reason?: string;
108
+ }
109
+ /**
110
+ * Open the durable store, or say plainly why it could not be opened.
111
+ *
112
+ * NEVER THROWS. A harness that cannot open a database is still able to run a
113
+ * turn and push it to the control plane, which is the source of truth; refusing
114
+ * to boot would trade a recoverable degradation for an outage. The caller
115
+ * reports `durable: false` in its health snapshot so the degradation is VISIBLE
116
+ * rather than silent — a fallback nobody can see is how an outage becomes a
117
+ * mystery.
118
+ */
119
+ export declare function openTranscriptStore(path: string): OpenStoreResult;
120
+ export {};