@peakflo/agent-harness 0.0.0-stage → 0.2.1-rc.3
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/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- 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 {};
|