ai-foreman 1.7.6 → 1.7.8
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 +26 -1
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +31 -6
- package/dist/adapters/claude.d.ts +61 -6
- package/dist/adapters/claude.js +544 -36
- package/dist/adapters/codex.d.ts +28 -1
- package/dist/adapters/codex.js +398 -27
- package/dist/adapters/recovering.d.ts +13 -2
- package/dist/adapters/recovering.js +32 -4
- package/dist/adapters/sessionFailure.d.ts +18 -0
- package/dist/adapters/sessionFailure.js +51 -0
- package/dist/adapters/types.d.ts +117 -9
- package/dist/agentRun.d.ts +4 -1
- package/dist/agentRun.js +96 -9
- package/dist/branch/currentGuard.d.ts +38 -0
- package/dist/branch/currentGuard.js +71 -0
- package/dist/branch/planner.d.ts +1 -1
- package/dist/branch/planner.js +2 -7
- package/dist/branch/prefix.d.ts +8 -0
- package/dist/branch/prefix.js +26 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +8 -0
- package/dist/branch/runner.d.ts +14 -2
- package/dist/branch/runner.js +77 -17
- package/dist/buildRuns.d.ts +13 -4
- package/dist/buildRuns.js +85 -19
- package/dist/cli/handoffs.d.ts +2 -0
- package/dist/cli/handoffs.js +74 -0
- package/dist/cli/start.js +1079 -43
- package/dist/cli/tickets.js +199 -3
- package/dist/continuity.d.ts +92 -0
- package/dist/continuity.js +415 -0
- package/dist/foreman.d.ts +5 -1
- package/dist/foreman.js +41 -6
- package/dist/handoffs.d.ts +69 -0
- package/dist/handoffs.js +546 -0
- package/dist/log.d.ts +1 -1
- package/dist/permissions/policy.d.ts +5 -1
- package/dist/permissions/policy.js +48 -1
- package/dist/qaReview.d.ts +6 -6
- package/dist/qaReview.js +24 -14
- package/dist/sessionAvailability.d.ts +15 -0
- package/dist/sessionAvailability.js +57 -0
- package/dist/sessionIdentity.d.ts +33 -0
- package/dist/sessionIdentity.js +132 -0
- package/dist/sessionLifecycle.d.ts +198 -5
- package/dist/sessionLifecycle.js +1373 -20
- package/dist/statusReporter.d.ts +84 -8
- package/dist/statusReporter.js +270 -14
- package/dist/ticketPlanning.js +8 -0
- package/dist/ticketPopulation.js +88 -12
- package/dist/tickets/commands.js +20 -12
- package/dist/tickets/groupReset.d.ts +25 -0
- package/dist/tickets/groupReset.js +279 -0
- package/dist/tickets/groups.d.ts +36 -0
- package/dist/tickets/groups.js +68 -0
- package/dist/tickets/importer.js +27 -3
- package/dist/tickets/recommendations.js +31 -7
- package/dist/tickets/reset.d.ts +1 -0
- package/dist/tickets/reset.js +1 -0
- package/dist/tickets/setupConfig.d.ts +1 -0
- package/dist/tickets/setupConfig.js +10 -2
- package/dist/tickets/stateDb.d.ts +34 -0
- package/dist/tickets/stateDb.js +207 -0
- package/dist/tickets/ticketLoader.js +5 -2
- package/dist/tickets/validate.js +4 -0
- package/dist/workflowDb.d.ts +145 -3
- package/dist/workflowDb.js +571 -10
- package/package.json +32 -4
package/dist/workflowDb.js
CHANGED
|
@@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statS
|
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import Database from "better-sqlite3";
|
|
6
|
+
import { providerSessionKey } from "./sessionIdentity.js";
|
|
6
7
|
export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
|
|
7
8
|
/** Read the current project lease without creating or migrating recovery state. */
|
|
8
9
|
export function readCurrentWorkflowLease(projectDir) {
|
|
@@ -42,6 +43,20 @@ export class WorkflowDb {
|
|
|
42
43
|
this.importLegacyOnce();
|
|
43
44
|
}
|
|
44
45
|
close() { this.db.close(); }
|
|
46
|
+
/** Ensure non-workflow build records can use the same durable event store. */
|
|
47
|
+
ensureRun(runId, kind = "build", now = new Date()) {
|
|
48
|
+
const existing = this.getRun(runId);
|
|
49
|
+
if (existing)
|
|
50
|
+
return existing;
|
|
51
|
+
const at = now.toISOString();
|
|
52
|
+
this.db.transaction(() => {
|
|
53
|
+
const inserted = this.db.prepare(`INSERT OR IGNORE INTO workflow_runs(run_id,kind,status,checkpoint,original_work_json,remaining_work_json,state_json,legacy,created_at,updated_at)
|
|
54
|
+
VALUES(?,?,'running','durable-baseline','{}','{}','{}',0,?,?)`).run(runId, kind, at, at);
|
|
55
|
+
if (inserted.changes)
|
|
56
|
+
this.insertEvent(runId, "durable_baseline", "durable-baseline", { source: "host" }, at);
|
|
57
|
+
})();
|
|
58
|
+
return this.getRun(runId);
|
|
59
|
+
}
|
|
45
60
|
createRun(input, now = new Date()) {
|
|
46
61
|
const at = now.toISOString();
|
|
47
62
|
const run = {
|
|
@@ -87,21 +102,187 @@ export class WorkflowDb {
|
|
|
87
102
|
return rows.map((row) => ({ sequence: row.sequence, type: row.event_type, checkpoint: row.checkpoint, payload: parseJson(row.payload_json), at: row.created_at }));
|
|
88
103
|
}
|
|
89
104
|
recordSettings(runId, role, boundary, settings, now = new Date()) {
|
|
105
|
+
this.ensureRun(runId, "build", now);
|
|
90
106
|
this.db.transaction(() => {
|
|
91
107
|
this.db.prepare("INSERT OR REPLACE INTO role_settings(run_id,role,boundary,revision,settings_json,created_at) VALUES(?,?,?,?,?,?)")
|
|
92
108
|
.run(runId, role, boundary, settings.settings_revision, json(settings), now.toISOString());
|
|
93
109
|
this.insertEvent(runId, "settings_boundary", `role:${role}:${boundary}`, { revision: settings.settings_revision }, now.toISOString());
|
|
94
110
|
})();
|
|
95
111
|
}
|
|
96
|
-
recordProjectSettingsRevision(revision, defaults, now = new Date()) {
|
|
97
|
-
|
|
112
|
+
recordProjectSettingsRevision(revision, defaults, targetsOrNow = {}, now = new Date()) {
|
|
113
|
+
const targets = targetsOrNow instanceof Date ? {} : targetsOrNow;
|
|
114
|
+
const recordedAt = targetsOrNow instanceof Date ? targetsOrNow : now;
|
|
115
|
+
this.db.transaction(() => {
|
|
116
|
+
this.db.prepare(`INSERT INTO project_settings_revisions(revision,defaults_json,created_at) VALUES(?,?,?)
|
|
117
|
+
ON CONFLICT(revision) DO UPDATE SET defaults_json=excluded.defaults_json,created_at=excluded.created_at`).run(revision, json(defaults), recordedAt.toISOString());
|
|
118
|
+
const entries = Object.entries(targets);
|
|
119
|
+
if (entries.length) {
|
|
120
|
+
this.db.prepare("DELETE FROM project_settings_revision_targets WHERE revision=?").run(revision);
|
|
121
|
+
const insert = this.db.prepare("INSERT INTO project_settings_revision_targets(revision,role,fields_json) VALUES(?,?,?)");
|
|
122
|
+
for (const [role, fields] of entries)
|
|
123
|
+
insert.run(revision, role, json([...new Set(fields)].sort()));
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
}
|
|
127
|
+
projectSettingsRevisionTargets(revision) {
|
|
128
|
+
const rows = this.db.prepare("SELECT role,fields_json FROM project_settings_revision_targets WHERE revision=? ORDER BY role").all(revision);
|
|
129
|
+
return Object.fromEntries(rows.map((row) => [row.role, parseJson(row.fields_json)]));
|
|
98
130
|
}
|
|
99
131
|
recordTelemetry(runId, snapshot, now = new Date()) {
|
|
132
|
+
this.ensureRun(runId, "build", now);
|
|
100
133
|
this.db.prepare("INSERT INTO workflow_telemetry(run_id,snapshot_json,created_at) VALUES(?,?,?)").run(runId, json(snapshot), now.toISOString());
|
|
101
134
|
}
|
|
102
|
-
|
|
103
|
-
this.
|
|
104
|
-
|
|
135
|
+
recordContextSample(sample) {
|
|
136
|
+
this.ensureRun(sample.runId);
|
|
137
|
+
const session = sessionParts(sample.sessionRef, sample.providerSessionId, sample.sessionKey);
|
|
138
|
+
if (session.ref)
|
|
139
|
+
this.recordProviderSessionBinding(session.ref, new Date(sample.observedAt));
|
|
140
|
+
const normalized = { ...sample, ...(session.ref ? { sessionRef: session.ref } : {}), ...(session.key ? { sessionKey: session.key } : {}) };
|
|
141
|
+
this.db.prepare(`INSERT INTO context_samples(run_id,role,provider_session_id,session_key,session_ref_json,sample_json,observed_at)
|
|
142
|
+
VALUES(?,?,?,?,?,?,?)`).run(sample.runId, sample.role, session.id, session.key, session.refJson, json(normalized), sample.observedAt);
|
|
143
|
+
}
|
|
144
|
+
contextSamples(runId, role) {
|
|
145
|
+
const rows = role
|
|
146
|
+
? this.db.prepare("SELECT sample_json FROM context_samples WHERE run_id=? AND role=? ORDER BY sample_id").all(runId, role)
|
|
147
|
+
: this.db.prepare("SELECT sample_json FROM context_samples WHERE run_id=? ORDER BY sample_id").all(runId);
|
|
148
|
+
return rows.map((row) => parseJson(row.sample_json));
|
|
149
|
+
}
|
|
150
|
+
recordSessionUsage(sample) {
|
|
151
|
+
this.ensureRun(sample.runId);
|
|
152
|
+
const session = sessionParts(sample.sessionRef, sample.providerSessionId, sample.sessionKey);
|
|
153
|
+
if (session.ref)
|
|
154
|
+
this.recordProviderSessionBinding(session.ref, new Date(sample.observedAt));
|
|
155
|
+
const normalized = { ...sample, ...(session.ref ? { sessionRef: session.ref } : {}), ...(session.key ? { sessionKey: session.key } : {}) };
|
|
156
|
+
this.db.prepare(`INSERT INTO session_usage_samples(run_id,role,provider_session_id,session_key,session_ref_json,sample_json,observed_at)
|
|
157
|
+
VALUES(?,?,?,?,?,?,?)`).run(sample.runId, sample.role, session.id, session.key, session.refJson, json(normalized), sample.observedAt);
|
|
158
|
+
}
|
|
159
|
+
sessionUsageSamples(runId, role) {
|
|
160
|
+
const rows = role
|
|
161
|
+
? this.db.prepare("SELECT sample_json FROM session_usage_samples WHERE run_id=? AND role=? ORDER BY sample_id").all(runId, role)
|
|
162
|
+
: this.db.prepare("SELECT sample_json FROM session_usage_samples WHERE run_id=? ORDER BY sample_id").all(runId);
|
|
163
|
+
return rows.map((row) => parseJson(row.sample_json));
|
|
164
|
+
}
|
|
165
|
+
acknowledgeSettings(ack) {
|
|
166
|
+
this.ensureRun(ack.runId);
|
|
167
|
+
const session = sessionParts(ack.sessionRef, ack.providerSessionId, ack.sessionKey);
|
|
168
|
+
this.db.transaction(() => {
|
|
169
|
+
this.db.prepare(`INSERT INTO live_settings_acknowledgments(run_id,role,provider_session_id,session_key,session_ref_json,revision,acknowledged_at)
|
|
170
|
+
VALUES(?,?,?,?,?,?,?) ON CONFLICT(run_id,role,revision) DO UPDATE SET provider_session_id=excluded.provider_session_id,session_key=excluded.session_key,session_ref_json=excluded.session_ref_json,acknowledged_at=excluded.acknowledged_at`)
|
|
171
|
+
.run(ack.runId, ack.role, session.id, session.key, session.refJson, ack.revision, ack.acknowledgedAt);
|
|
172
|
+
if (session.key) {
|
|
173
|
+
this.db.prepare(`INSERT INTO live_settings_session_acknowledgments(run_id,role,provider_session_id,session_key,session_ref_json,revision,acknowledged_at)
|
|
174
|
+
VALUES(?,?,?,?,?,?,?) ON CONFLICT(run_id,role,session_key,revision) DO UPDATE SET provider_session_id=excluded.provider_session_id,session_ref_json=excluded.session_ref_json,acknowledged_at=excluded.acknowledged_at`)
|
|
175
|
+
.run(ack.runId, ack.role, session.id, session.key, session.refJson, ack.revision, ack.acknowledgedAt);
|
|
176
|
+
}
|
|
177
|
+
this.insertEvent(ack.runId, "live_settings_acknowledged", `settings:${ack.revision}`, { role: ack.role, providerSessionId: session.id, sessionKey: session.key }, ack.acknowledgedAt);
|
|
178
|
+
})();
|
|
179
|
+
}
|
|
180
|
+
settingsAcknowledgments(revision) {
|
|
181
|
+
const scoped = this.db.prepare("SELECT * FROM live_settings_session_acknowledgments WHERE revision=? ORDER BY acknowledged_at").all(revision);
|
|
182
|
+
const covered = new Set(scoped.map((row) => `${row.run_id}:${row.role}`));
|
|
183
|
+
const legacy = this.db.prepare("SELECT * FROM live_settings_acknowledgments WHERE revision=? ORDER BY acknowledged_at").all(revision)
|
|
184
|
+
.filter((row) => !covered.has(`${row.run_id}:${row.role}`));
|
|
185
|
+
return [...scoped, ...legacy]
|
|
186
|
+
.sort((left, right) => left.acknowledged_at.localeCompare(right.acknowledged_at))
|
|
187
|
+
.map((row) => ({ runId: row.run_id, role: row.role, ...(row.provider_session_id ? { providerSessionId: row.provider_session_id } : {}), ...(row.session_key ? { sessionKey: row.session_key } : {}), ...(row.session_ref_json ? { sessionRef: parseJson(row.session_ref_json) } : {}), revision: row.revision, acknowledgedAt: row.acknowledged_at }));
|
|
188
|
+
}
|
|
189
|
+
appendContinuityEvent(input, now = new Date()) {
|
|
190
|
+
this.ensureRun(input.runId, "build", now);
|
|
191
|
+
const at = now.toISOString();
|
|
192
|
+
const safePayload = sanitizeContinuityValue(input.payload);
|
|
193
|
+
const session = sessionParts(input.sessionRef, undefined, input.sessionKey);
|
|
194
|
+
const digest = digestJson({ runId: input.runId, role: input.role, kind: input.kind, payload: safePayload, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key, at });
|
|
195
|
+
const result = this.db.prepare(`INSERT INTO continuity_events(run_id,role,kind,payload_json,digest,authoritative_state_revision,session_key,session_ref_json,created_at)
|
|
196
|
+
VALUES(?,?,?,?,?,?,?,?,?)`).run(input.runId, input.role, input.kind, json(safePayload), digest, input.authoritativeStateRevision, session.key, session.refJson, at);
|
|
197
|
+
return { sequence: Number(result.lastInsertRowid), runId: input.runId, role: input.role, kind: input.kind, payload: safePayload, digest, authoritativeStateRevision: input.authoritativeStateRevision, createdAt: at, ...(session.key ? { sessionKey: session.key } : {}), ...(session.ref ? { sessionRef: session.ref } : {}) };
|
|
198
|
+
}
|
|
199
|
+
continuityEvents(runId, afterSequence = 0) {
|
|
200
|
+
const rows = this.db.prepare("SELECT * FROM continuity_events WHERE run_id=? AND sequence>? ORDER BY sequence").all(runId, afterSequence);
|
|
201
|
+
return rows.map(continuityEventFromRow);
|
|
202
|
+
}
|
|
203
|
+
publishContinuityCheckpoint(input, now = new Date()) {
|
|
204
|
+
this.ensureRun(input.runId, "build", now);
|
|
205
|
+
return this.db.transaction(() => {
|
|
206
|
+
const at = now.toISOString();
|
|
207
|
+
const previous = this.continuityHead(input.runId, input.role);
|
|
208
|
+
const latestEvent = this.db.prepare("SELECT COALESCE(MAX(sequence),0) AS sequence FROM continuity_events WHERE run_id=?").get(input.runId);
|
|
209
|
+
const safeDelta = sanitizeContinuityValue(input.delta);
|
|
210
|
+
const session = sessionParts(input.sessionRef, undefined, input.sessionKey);
|
|
211
|
+
const digest = digestJson({ runId: input.runId, role: input.role, sequence: latestEvent.sequence, predecessorDigest: previous?.digest, delta: safeDelta, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key });
|
|
212
|
+
const result = this.db.prepare(`INSERT INTO continuity_checkpoints(run_id,role,event_sequence,state,delta_json,digest,predecessor_digest,authoritative_state_revision,session_key,session_ref_json,created_at)
|
|
213
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(input.runId, input.role, latestEvent.sequence, input.state ?? "current", json(safeDelta), digest, previous?.digest ?? null, input.authoritativeStateRevision, session.key, session.refJson, at);
|
|
214
|
+
const checkpoint = {
|
|
215
|
+
checkpointId: Number(result.lastInsertRowid), runId: input.runId, role: input.role,
|
|
216
|
+
sequence: latestEvent.sequence, state: input.state ?? "current", delta: safeDelta, digest,
|
|
217
|
+
...(previous ? { predecessorDigest: previous.digest } : {}),
|
|
218
|
+
authoritativeStateRevision: input.authoritativeStateRevision, createdAt: at,
|
|
219
|
+
...(session.key ? { sessionKey: session.key } : {}), ...(session.ref ? { sessionRef: session.ref } : {}),
|
|
220
|
+
};
|
|
221
|
+
this.upsertContinuityHead({ runId: input.runId, role: input.role, state: checkpoint.state, sequence: checkpoint.sequence, digest, authoritativeStateRevision: input.authoritativeStateRevision, updatedAt: at });
|
|
222
|
+
this.refreshRunContinuityHead(input.runId, input.authoritativeStateRevision, at);
|
|
223
|
+
this.insertEvent(input.runId, "continuity_checkpoint", `continuity:${input.role}`, { checkpointId: checkpoint.checkpointId, digest, state: checkpoint.state }, at);
|
|
224
|
+
return checkpoint;
|
|
225
|
+
})();
|
|
226
|
+
}
|
|
227
|
+
continuityCheckpoints(runId, role) {
|
|
228
|
+
const rows = role
|
|
229
|
+
? this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id").all(runId, role)
|
|
230
|
+
: this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? ORDER BY checkpoint_id").all(runId);
|
|
231
|
+
return rows.map(continuityCheckpointFromRow);
|
|
232
|
+
}
|
|
233
|
+
latestContinuityCheckpoint(runId, role) {
|
|
234
|
+
const row = this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id DESC LIMIT 1").get(runId, role);
|
|
235
|
+
return row ? continuityCheckpointFromRow(row) : undefined;
|
|
236
|
+
}
|
|
237
|
+
continuityHead(runId, role) {
|
|
238
|
+
const row = this.db.prepare("SELECT * FROM continuity_heads WHERE run_id=? AND role=?").get(runId, role);
|
|
239
|
+
return row ? continuityHeadFromRow(row) : undefined;
|
|
240
|
+
}
|
|
241
|
+
setContinuityHeadState(runId, role, state, now = new Date()) {
|
|
242
|
+
const head = this.continuityHead(runId, role);
|
|
243
|
+
if (!head)
|
|
244
|
+
return undefined;
|
|
245
|
+
const next = { ...head, state, updatedAt: now.toISOString() };
|
|
246
|
+
this.upsertContinuityHead(next);
|
|
247
|
+
this.refreshRunContinuityHead(runId, head.authoritativeStateRevision, next.updatedAt);
|
|
248
|
+
return next;
|
|
249
|
+
}
|
|
250
|
+
recordSession(runId, role, stream, session, transition, settings, now = new Date()) {
|
|
251
|
+
this.ensureRun(runId, "build", now);
|
|
252
|
+
const scoped = sessionParts(typeof session === "object" ? session : undefined, typeof session === "string" ? session : undefined);
|
|
253
|
+
if (scoped.ref)
|
|
254
|
+
this.recordProviderSessionBinding(scoped.ref, now);
|
|
255
|
+
this.db.prepare(`INSERT INTO provider_sessions(run_id,role,stream,provider,model,session_id,session_key,session_ref_json,transition,settings_revision,created_at)
|
|
256
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(runId, role, stream, settings.make, settings.model, scoped.id, scoped.key, scoped.refJson, transition, settings.settings_revision, now.toISOString());
|
|
257
|
+
}
|
|
258
|
+
providerSessionRefs(runId, role) {
|
|
259
|
+
const rows = role
|
|
260
|
+
? this.db.prepare("SELECT session_ref_json FROM provider_sessions WHERE run_id=? AND role=? AND session_ref_json IS NOT NULL ORDER BY id").all(runId, role)
|
|
261
|
+
: this.db.prepare("SELECT session_ref_json FROM provider_sessions WHERE run_id=? AND session_ref_json IS NOT NULL ORDER BY id").all(runId);
|
|
262
|
+
const refs = rows.map((row) => parseJson(row.session_ref_json));
|
|
263
|
+
return [...new Map(refs.map((ref) => [providerSessionKey(ref), ref])).values()];
|
|
264
|
+
}
|
|
265
|
+
/** Project-local lookup used to resolve compatibility raw IDs into scoped references. */
|
|
266
|
+
recordProviderSessionBinding(ref, now = new Date()) {
|
|
267
|
+
const key = providerSessionKey(ref);
|
|
268
|
+
this.db.prepare(`INSERT INTO provider_session_bindings(session_key,provider_session_id,role,session_ref_json,observed_at)
|
|
269
|
+
VALUES(?,?,?,?,?) ON CONFLICT(session_key) DO UPDATE SET session_ref_json=excluded.session_ref_json,observed_at=excluded.observed_at`)
|
|
270
|
+
.run(key, ref.sessionId, ref.role, json(ref), now.toISOString());
|
|
271
|
+
}
|
|
272
|
+
providerSessionBindings(sessionId, role) {
|
|
273
|
+
const clauses = [];
|
|
274
|
+
const values = [];
|
|
275
|
+
if (sessionId) {
|
|
276
|
+
clauses.push("provider_session_id=?");
|
|
277
|
+
values.push(sessionId);
|
|
278
|
+
}
|
|
279
|
+
if (role) {
|
|
280
|
+
clauses.push("role=?");
|
|
281
|
+
values.push(role);
|
|
282
|
+
}
|
|
283
|
+
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
284
|
+
const rows = this.db.prepare(`SELECT session_ref_json FROM provider_session_bindings${where} ORDER BY observed_at`).all(...values);
|
|
285
|
+
return rows.map((row) => parseJson(row.session_ref_json));
|
|
105
286
|
}
|
|
106
287
|
recordIssue(runId, issue) {
|
|
107
288
|
const result = this.db.prepare("INSERT INTO workflow_issues(run_id,code,issue_json,created_at) VALUES(?,?,?,?)").run(runId, issue.code, json(issue), issue.occurred_at);
|
|
@@ -118,10 +299,15 @@ export class WorkflowDb {
|
|
|
118
299
|
return this.operation(input.idempotencyKey);
|
|
119
300
|
}
|
|
120
301
|
updateOperation(idempotencyKey, status, details = {}, now = new Date()) {
|
|
121
|
-
|
|
302
|
+
const prior = this.operation(idempotencyKey);
|
|
303
|
+
if (!prior)
|
|
122
304
|
throw new Error(`operation not found: ${idempotencyKey}`);
|
|
123
|
-
|
|
124
|
-
|
|
305
|
+
const at = now.toISOString();
|
|
306
|
+
this.db.transaction(() => {
|
|
307
|
+
this.db.prepare("UPDATE operation_journal SET status=?,result_json=?,external_id=?,error=?,updated_at=? WHERE idempotency_key=?")
|
|
308
|
+
.run(status, details.result === undefined ? null : json(details.result), details.externalId ?? null, details.error ?? null, at, idempotencyKey);
|
|
309
|
+
this.appendContinuityEvent({ runId: prior.runId, role: "host", kind: "operation_receipt", payload: { idempotencyKey, kind: prior.kind, status, externalId: details.externalId, error: details.error }, authoritativeStateRevision: this.continuityHead(prior.runId, "run")?.authoritativeStateRevision ?? 0 }, now);
|
|
310
|
+
})();
|
|
125
311
|
return this.operation(idempotencyKey);
|
|
126
312
|
}
|
|
127
313
|
operation(idempotencyKey) {
|
|
@@ -141,6 +327,271 @@ export class WorkflowDb {
|
|
|
141
327
|
const row = this.db.prepare("SELECT content FROM content_refs WHERE digest=?").get(digest);
|
|
142
328
|
return row?.content;
|
|
143
329
|
}
|
|
330
|
+
stageHandoff(manifest, markdown) {
|
|
331
|
+
this.ensureRun(manifest.runId);
|
|
332
|
+
const manifestText = stableJson(sanitizeContinuityValue(manifest));
|
|
333
|
+
const manifestDigest = this.putEvidence("handoff", manifestText);
|
|
334
|
+
const markdownDigest = this.putEvidence("handoff", markdown);
|
|
335
|
+
const lineage = {
|
|
336
|
+
runId: manifest.runId,
|
|
337
|
+
generation: manifest.generation,
|
|
338
|
+
manifestDigest,
|
|
339
|
+
markdownDigest,
|
|
340
|
+
...(manifest.predecessorSessionId ? { predecessorSessionId: manifest.predecessorSessionId } : {}),
|
|
341
|
+
...(manifest.predecessorSessionRef ? { predecessorSessionRef: manifest.predecessorSessionRef } : {}),
|
|
342
|
+
state: "staged",
|
|
343
|
+
createdAt: manifest.createdAt,
|
|
344
|
+
};
|
|
345
|
+
this.db.transaction(() => {
|
|
346
|
+
const existing = this.db.prepare("SELECT manifest_digest,markdown_digest FROM handoffs WHERE run_id=? AND generation=?").get(manifest.runId, manifest.generation);
|
|
347
|
+
if (existing && (existing.manifest_digest !== manifestDigest || existing.markdown_digest !== markdownDigest)) {
|
|
348
|
+
throw new Error(`handoff generation ${manifest.generation} is immutable`);
|
|
349
|
+
}
|
|
350
|
+
const predecessor = sessionParts(manifest.predecessorSessionRef, manifest.predecessorSessionId);
|
|
351
|
+
this.db.prepare(`INSERT OR IGNORE INTO handoffs(run_id,generation,role,manifest_digest,markdown_digest,predecessor_session_id,predecessor_session_key,predecessor_session_ref_json,state,created_at)
|
|
352
|
+
VALUES(?,?,?,?,?,?,?,?,?,?)`).run(manifest.runId, manifest.generation, manifest.role, manifestDigest, markdownDigest, predecessor.id, predecessor.key, predecessor.refJson, "staged", manifest.createdAt);
|
|
353
|
+
this.insertEvent(manifest.runId, "handoff_staged", `handoff:${manifest.generation}`, { generation: manifest.generation, manifestDigest, markdownDigest, resources: manifest.resources }, manifest.createdAt);
|
|
354
|
+
})();
|
|
355
|
+
return this.handoff(manifest.runId, manifest.generation) ?? lineage;
|
|
356
|
+
}
|
|
357
|
+
acceptHandoff(runId, generation, successor, now = new Date()) {
|
|
358
|
+
const session = sessionParts(typeof successor === "object" ? successor : undefined, typeof successor === "string" ? successor : undefined);
|
|
359
|
+
if (!session.id?.trim())
|
|
360
|
+
throw new Error("a validated successor session ID is required before handoff acceptance");
|
|
361
|
+
if (session.ref)
|
|
362
|
+
this.recordProviderSessionBinding(session.ref, now);
|
|
363
|
+
return this.db.transaction(() => {
|
|
364
|
+
const prior = this.handoff(runId, generation);
|
|
365
|
+
if (!prior)
|
|
366
|
+
throw new Error(`handoff generation ${generation} not found for run ${runId}`);
|
|
367
|
+
if (prior.state === "failed")
|
|
368
|
+
throw new Error("a failed handoff cannot be accepted");
|
|
369
|
+
const at = now.toISOString();
|
|
370
|
+
this.db.prepare(`UPDATE handoffs SET state='accepted',successor_session_id=?,successor_session_key=?,successor_session_ref_json=?,accepted_at=? WHERE run_id=? AND generation=?`)
|
|
371
|
+
.run(session.id, session.key, session.refJson, at, runId, generation);
|
|
372
|
+
this.db.prepare(`INSERT INTO role_mutation_leases(run_id,role,generation,provider_session_id,provider_session_key,provider_session_ref_json,moved_at)
|
|
373
|
+
SELECT run_id,role,generation,?,?,?,? FROM handoffs WHERE run_id=? AND generation=?
|
|
374
|
+
ON CONFLICT(run_id,role) DO UPDATE SET generation=excluded.generation,provider_session_id=excluded.provider_session_id,provider_session_key=excluded.provider_session_key,provider_session_ref_json=excluded.provider_session_ref_json,moved_at=excluded.moved_at`)
|
|
375
|
+
.run(session.id, session.key, session.refJson, at, runId, generation);
|
|
376
|
+
this.insertEvent(runId, "handoff_accepted", `handoff:${generation}`, { generation, successorSessionId: session.id, sessionKey: session.key, leaseMoved: true }, at);
|
|
377
|
+
return this.handoff(runId, generation);
|
|
378
|
+
})();
|
|
379
|
+
}
|
|
380
|
+
/** Fence the initial role owner without overwriting a lease already moved by a handoff. */
|
|
381
|
+
claimInitialRoleLease(runId, role, providerSession, now = new Date()) {
|
|
382
|
+
const session = sessionParts(typeof providerSession === "object" ? providerSession : undefined, typeof providerSession === "string" ? providerSession : undefined);
|
|
383
|
+
if (!session.id?.trim())
|
|
384
|
+
throw new Error("a provider session ID is required to claim the role lease");
|
|
385
|
+
this.ensureRun(runId);
|
|
386
|
+
this.db.prepare(`INSERT OR IGNORE INTO role_mutation_leases(run_id,role,generation,provider_session_id,provider_session_key,provider_session_ref_json,moved_at) VALUES(?,?,?,?,?,?,?)`)
|
|
387
|
+
.run(runId, role, 0, session.id, session.key, session.refJson, now.toISOString());
|
|
388
|
+
return this.roleMutationLease(runId, role);
|
|
389
|
+
}
|
|
390
|
+
roleMutationLease(runId, role) {
|
|
391
|
+
const row = this.db.prepare(`SELECT run_id,role,generation,provider_session_id,provider_session_key,provider_session_ref_json,moved_at FROM role_mutation_leases WHERE run_id=? AND role=?`)
|
|
392
|
+
.get(runId, role);
|
|
393
|
+
return row ? { runId: row.run_id, role: row.role, generation: row.generation, providerSessionId: row.provider_session_id, ...(row.provider_session_key ? { sessionKey: row.provider_session_key } : {}), ...(row.provider_session_ref_json ? { sessionRef: parseJson(row.provider_session_ref_json) } : {}), movedAt: row.moved_at } : undefined;
|
|
394
|
+
}
|
|
395
|
+
/** Move a dead predecessor's lease only after a fresh recovery produced a schema-valid checkpoint. */
|
|
396
|
+
moveRoleLeaseAfterValidatedRecovery(runId, role, providerSession, reason, now = new Date()) {
|
|
397
|
+
const session = sessionParts(typeof providerSession === "object" ? providerSession : undefined, typeof providerSession === "string" ? providerSession : undefined);
|
|
398
|
+
if (!session.id?.trim())
|
|
399
|
+
throw new Error("a validated recovery session ID is required to move the role lease");
|
|
400
|
+
this.ensureRun(runId);
|
|
401
|
+
const prior = this.roleMutationLease(runId, role);
|
|
402
|
+
if ((session.key && prior?.sessionKey === session.key) || (!session.key && prior?.providerSessionId === session.id))
|
|
403
|
+
return prior;
|
|
404
|
+
const at = now.toISOString();
|
|
405
|
+
const generation = (prior?.generation ?? -1) + 1;
|
|
406
|
+
return this.db.transaction(() => {
|
|
407
|
+
this.db.prepare(`INSERT INTO role_mutation_leases(run_id,role,generation,provider_session_id,provider_session_key,provider_session_ref_json,moved_at) VALUES(?,?,?,?,?,?,?)
|
|
408
|
+
ON CONFLICT(run_id,role) DO UPDATE SET generation=excluded.generation,provider_session_id=excluded.provider_session_id,provider_session_key=excluded.provider_session_key,provider_session_ref_json=excluded.provider_session_ref_json,moved_at=excluded.moved_at`)
|
|
409
|
+
.run(runId, role, generation, session.id, session.key, session.refJson, at);
|
|
410
|
+
this.insertEvent(runId, "validated_recovery_lease_moved", `recovery:${role}:${generation}`, {
|
|
411
|
+
role, generation, predecessorSessionId: prior?.providerSessionId, successorSessionId: session.id, successorSessionKey: session.key,
|
|
412
|
+
reason: sanitizeText(reason).slice(0, 500),
|
|
413
|
+
}, at);
|
|
414
|
+
return this.roleMutationLease(runId, role);
|
|
415
|
+
})();
|
|
416
|
+
}
|
|
417
|
+
failHandoff(runId, generation, reason, now = new Date()) {
|
|
418
|
+
const prior = this.handoff(runId, generation);
|
|
419
|
+
if (!prior)
|
|
420
|
+
throw new Error(`handoff generation ${generation} not found for run ${runId}`);
|
|
421
|
+
if (prior.state === "accepted")
|
|
422
|
+
throw new Error("an accepted handoff cannot be failed");
|
|
423
|
+
const at = now.toISOString();
|
|
424
|
+
this.db.prepare("UPDATE handoffs SET state='failed',failure=? WHERE run_id=? AND generation=?").run(sanitizeText(reason).slice(0, 2000), runId, generation);
|
|
425
|
+
this.insertEvent(runId, "handoff_failed", `handoff:${generation}`, { generation, reason: sanitizeText(reason).slice(0, 500) }, at);
|
|
426
|
+
return this.handoff(runId, generation);
|
|
427
|
+
}
|
|
428
|
+
handoff(runId, generation) {
|
|
429
|
+
const row = this.db.prepare("SELECT * FROM handoffs WHERE run_id=? AND generation=?").get(runId, generation);
|
|
430
|
+
return row ? handoffFromRow(row) : undefined;
|
|
431
|
+
}
|
|
432
|
+
handoffs(runId) {
|
|
433
|
+
return this.db.prepare("SELECT * FROM handoffs WHERE run_id=? ORDER BY generation").all(runId).map(handoffFromRow);
|
|
434
|
+
}
|
|
435
|
+
handoffContent(runId, generation) {
|
|
436
|
+
const lineage = this.handoff(runId, generation);
|
|
437
|
+
if (!lineage)
|
|
438
|
+
return undefined;
|
|
439
|
+
const manifest = this.getEvidence(lineage.manifestDigest);
|
|
440
|
+
const markdown = this.getEvidence(lineage.markdownDigest);
|
|
441
|
+
if (!manifest || !markdown)
|
|
442
|
+
throw new Error(`handoff ${runId}/${generation} references missing durable content`);
|
|
443
|
+
return { lineage, manifest: JSON.parse(manifest.toString("utf8")), markdown: markdown.toString("utf8") };
|
|
444
|
+
}
|
|
445
|
+
deleteHandoffHistory(runId) {
|
|
446
|
+
const run = this.getRun(runId);
|
|
447
|
+
if (!run)
|
|
448
|
+
throw new Error(`run not found: ${runId}`);
|
|
449
|
+
if (!["completed", "cancelled", "superseded"].includes(run.status))
|
|
450
|
+
throw new Error(`run ${runId} is active or recoverable; durable handoff history cannot be deleted`);
|
|
451
|
+
const rows = this.db.prepare("SELECT manifest_digest,markdown_digest FROM handoffs WHERE run_id=?").all(runId);
|
|
452
|
+
this.db.transaction(() => {
|
|
453
|
+
this.db.prepare("DELETE FROM role_mutation_leases WHERE run_id=?").run(runId);
|
|
454
|
+
this.db.prepare("DELETE FROM handoffs WHERE run_id=?").run(runId);
|
|
455
|
+
for (const row of rows) {
|
|
456
|
+
for (const digest of [row.manifest_digest, row.markdown_digest]) {
|
|
457
|
+
const referenced = this.db.prepare("SELECT 1 FROM handoffs WHERE manifest_digest=? OR markdown_digest=? LIMIT 1").get(digest, digest);
|
|
458
|
+
if (!referenced)
|
|
459
|
+
this.db.prepare("DELETE FROM content_refs WHERE digest=? AND kind='handoff'").run(digest);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
})();
|
|
463
|
+
return rows.length;
|
|
464
|
+
}
|
|
465
|
+
startCompactionAttempt(input, now = new Date()) {
|
|
466
|
+
this.ensureRun(input.runId);
|
|
467
|
+
const at = now.toISOString();
|
|
468
|
+
const session = sessionParts(input.sessionRef, input.providerSessionId, input.sessionKey);
|
|
469
|
+
this.db.prepare(`INSERT OR IGNORE INTO compaction_attempts(idempotency_key,run_id,role,provider_session_id,session_key,session_ref_json,crossing_key,status,before_sample_json,origin,provider_event_id,threshold_generation_id,created_at,updated_at)
|
|
470
|
+
VALUES(?,?,?,?,?,?,?,'started',?,?,?,?,?,?)`).run(input.idempotencyKey, input.runId, input.role, session.id, session.key, session.refJson, input.crossingKey, input.beforeSample ? json(input.beforeSample) : null, input.origin ?? "rafi-manual", input.providerEventId ?? null, input.thresholdGenerationId ?? null, at, at);
|
|
471
|
+
const direct = this.compactionAttempt(input.idempotencyKey);
|
|
472
|
+
if (direct)
|
|
473
|
+
return direct;
|
|
474
|
+
if (session.key && input.providerEventId) {
|
|
475
|
+
const duplicate = this.db.prepare("SELECT * FROM compaction_attempts WHERE run_id=? AND role=? AND session_key=? AND provider_event_id=?").get(input.runId, input.role, session.key, input.providerEventId);
|
|
476
|
+
if (duplicate)
|
|
477
|
+
return compactionAttemptFromRow(duplicate);
|
|
478
|
+
}
|
|
479
|
+
throw new Error(`failed to persist compaction attempt ${input.idempotencyKey}`);
|
|
480
|
+
}
|
|
481
|
+
finishCompactionAttempt(idempotencyKey, input, now = new Date()) {
|
|
482
|
+
const prior = this.compactionAttempt(idempotencyKey);
|
|
483
|
+
if (!prior)
|
|
484
|
+
throw new Error(`compaction attempt not found: ${idempotencyKey}`);
|
|
485
|
+
if (prior.status !== "started")
|
|
486
|
+
return prior;
|
|
487
|
+
this.db.prepare("UPDATE compaction_attempts SET status=?,after_sample_json=?,error=?,updated_at=? WHERE idempotency_key=?")
|
|
488
|
+
.run(input.status ?? (input.ok ? "succeeded" : "failed"), input.afterSample ? json(input.afterSample) : null, input.error ? sanitizeText(input.error).slice(0, 2000) : null, now.toISOString(), idempotencyKey);
|
|
489
|
+
const status = input.status ?? (input.ok ? "succeeded" : "failed");
|
|
490
|
+
this.insertEvent(prior.runId, `compaction_${status}`, `context:${prior.role}`, { idempotencyKey, providerSessionId: prior.providerSessionId, sessionKey: prior.sessionKey, crossingKey: prior.crossingKey, origin: prior.origin, providerEventId: prior.providerEventId }, now.toISOString());
|
|
491
|
+
return this.compactionAttempt(idempotencyKey);
|
|
492
|
+
}
|
|
493
|
+
compactionAttempt(idempotencyKey) {
|
|
494
|
+
const row = this.db.prepare("SELECT * FROM compaction_attempts WHERE idempotency_key=?").get(idempotencyKey);
|
|
495
|
+
return row ? compactionAttemptFromRow(row) : undefined;
|
|
496
|
+
}
|
|
497
|
+
linkCompactionProviderEvent(idempotencyKey, providerEventId) {
|
|
498
|
+
const prior = this.compactionAttempt(idempotencyKey);
|
|
499
|
+
if (!prior)
|
|
500
|
+
throw new Error(`compaction attempt not found: ${idempotencyKey}`);
|
|
501
|
+
if (prior.providerEventId === providerEventId)
|
|
502
|
+
return prior;
|
|
503
|
+
this.db.prepare("UPDATE compaction_attempts SET provider_event_id=?,updated_at=? WHERE idempotency_key=?")
|
|
504
|
+
.run(providerEventId, new Date().toISOString(), idempotencyKey);
|
|
505
|
+
return this.compactionAttempt(idempotencyKey);
|
|
506
|
+
}
|
|
507
|
+
compactionAttempts(runId, role, providerSession) {
|
|
508
|
+
if (!providerSession)
|
|
509
|
+
return [];
|
|
510
|
+
const session = sessionParts(typeof providerSession === "object" ? providerSession : undefined, typeof providerSession === "string" ? providerSession : undefined);
|
|
511
|
+
const rows = session.key
|
|
512
|
+
? this.db.prepare("SELECT * FROM compaction_attempts WHERE run_id=? AND role=? AND session_key=? ORDER BY created_at,idempotency_key").all(runId, role, session.key)
|
|
513
|
+
: this.db.prepare("SELECT * FROM compaction_attempts WHERE run_id=? AND role=? AND provider_session_id=? AND session_key IS NULL ORDER BY created_at,idempotency_key").all(runId, role, session.id);
|
|
514
|
+
return rows.map(compactionAttemptFromRow);
|
|
515
|
+
}
|
|
516
|
+
successfulCompactionCount(runId, role, providerSession) {
|
|
517
|
+
if (!providerSession)
|
|
518
|
+
return 0;
|
|
519
|
+
const session = sessionParts(typeof providerSession === "object" ? providerSession : undefined, typeof providerSession === "string" ? providerSession : undefined);
|
|
520
|
+
const row = session.key
|
|
521
|
+
? this.db.prepare(`SELECT COUNT(*) AS count FROM compaction_attempts WHERE run_id=? AND role=? AND session_key=? AND status IN ('succeeded','unverified')`).get(runId, role, session.key)
|
|
522
|
+
: this.db.prepare(`SELECT COUNT(*) AS count FROM compaction_attempts WHERE run_id=? AND role=? AND provider_session_id=? AND session_key IS NULL AND status IN ('succeeded','unverified')`).get(runId, role, session.id);
|
|
523
|
+
return row.count;
|
|
524
|
+
}
|
|
525
|
+
upsertThresholdGeneration(input, now = new Date()) {
|
|
526
|
+
this.ensureRun(input.runId);
|
|
527
|
+
const at = now.toISOString();
|
|
528
|
+
const session = sessionParts(input.sessionRef, input.providerSessionId, input.sessionKey);
|
|
529
|
+
this.db.prepare(`INSERT INTO threshold_generations(
|
|
530
|
+
generation_id,run_id,role,provider_session_id,session_key,session_ref_json,generation,state,configured_ceiling_percent,
|
|
531
|
+
installed_native_token_limit,installed_native_percent,settings_revision,model,latest_sample_json,frozen_action_digest,provider_event_id,created_at,updated_at
|
|
532
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
533
|
+
ON CONFLICT(generation_id) DO UPDATE SET
|
|
534
|
+
provider_session_id=excluded.provider_session_id,session_key=excluded.session_key,session_ref_json=excluded.session_ref_json,
|
|
535
|
+
state=excluded.state,configured_ceiling_percent=excluded.configured_ceiling_percent,
|
|
536
|
+
installed_native_token_limit=excluded.installed_native_token_limit,installed_native_percent=excluded.installed_native_percent,
|
|
537
|
+
settings_revision=excluded.settings_revision,model=excluded.model,latest_sample_json=excluded.latest_sample_json,
|
|
538
|
+
frozen_action_digest=COALESCE(excluded.frozen_action_digest,threshold_generations.frozen_action_digest),
|
|
539
|
+
provider_event_id=COALESCE(excluded.provider_event_id,threshold_generations.provider_event_id),updated_at=excluded.updated_at`).run(input.generationId, input.runId, input.role, session.id, session.key, session.refJson, input.generation, input.state, input.configuredCeilingPercent, input.installedNativeTokenLimit ?? null, input.installedNativePercent ?? null, input.settingsRevision, input.model, input.latestSample ? json(input.latestSample) : null, input.frozenActionDigest ?? null, input.providerEventId ?? null, at, at);
|
|
540
|
+
return this.thresholdGeneration(input.generationId);
|
|
541
|
+
}
|
|
542
|
+
thresholdGeneration(generationId) {
|
|
543
|
+
const row = this.db.prepare("SELECT * FROM threshold_generations WHERE generation_id=?").get(generationId);
|
|
544
|
+
return row ? thresholdGenerationFromRow(row) : undefined;
|
|
545
|
+
}
|
|
546
|
+
thresholdGenerations(runId, role, sessionKey) {
|
|
547
|
+
const rows = role && sessionKey
|
|
548
|
+
? this.db.prepare("SELECT * FROM threshold_generations WHERE run_id=? AND role=? AND session_key=? ORDER BY generation").all(runId, role, sessionKey)
|
|
549
|
+
: role
|
|
550
|
+
? this.db.prepare("SELECT * FROM threshold_generations WHERE run_id=? AND role=? ORDER BY created_at,generation").all(runId, role)
|
|
551
|
+
: this.db.prepare("SELECT * FROM threshold_generations WHERE run_id=? ORDER BY created_at,generation").all(runId);
|
|
552
|
+
return rows.map(thresholdGenerationFromRow);
|
|
553
|
+
}
|
|
554
|
+
markRoleAdapterActive(input) {
|
|
555
|
+
this.ensureRun(input.runId);
|
|
556
|
+
const sessionKey = input.sessionKey ?? `${input.role}:unscoped:${input.providerSessionId ?? "pending"}`;
|
|
557
|
+
this.db.prepare(`INSERT INTO active_role_adapter_sessions(run_id,role,provider_session_id,session_key,settings_revision,observed_at)
|
|
558
|
+
VALUES(?,?,?,?,?,?) ON CONFLICT(run_id,role,session_key) DO UPDATE SET provider_session_id=excluded.provider_session_id,settings_revision=excluded.settings_revision,observed_at=excluded.observed_at`)
|
|
559
|
+
.run(input.runId, input.role, input.providerSessionId ?? null, sessionKey, input.settingsRevision, input.observedAt);
|
|
560
|
+
}
|
|
561
|
+
clearRoleAdapterActive(runId, role, sessionKey) {
|
|
562
|
+
if (sessionKey)
|
|
563
|
+
this.db.prepare("DELETE FROM active_role_adapter_sessions WHERE run_id=? AND role=? AND session_key=?").run(runId, role, sessionKey);
|
|
564
|
+
else
|
|
565
|
+
this.db.prepare("DELETE FROM active_role_adapter_sessions WHERE run_id=? AND role=?").run(runId, role);
|
|
566
|
+
}
|
|
567
|
+
activeRoleAdapters(runId, observedAfter) {
|
|
568
|
+
const clauses = [];
|
|
569
|
+
const values = [];
|
|
570
|
+
if (runId) {
|
|
571
|
+
clauses.push("run_id=?");
|
|
572
|
+
values.push(runId);
|
|
573
|
+
}
|
|
574
|
+
if (observedAfter) {
|
|
575
|
+
clauses.push("observed_at>=?");
|
|
576
|
+
values.push(observedAfter);
|
|
577
|
+
}
|
|
578
|
+
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
579
|
+
const rows = this.db.prepare(`SELECT * FROM active_role_adapter_sessions${where} ORDER BY run_id,role,session_key`).all(...values);
|
|
580
|
+
return rows.map((row) => ({ runId: row.run_id, role: row.role, ...(row.provider_session_id ? { providerSessionId: row.provider_session_id } : {}), ...(row.session_key ? { sessionKey: row.session_key } : {}), settingsRevision: row.settings_revision, observedAt: row.observed_at }));
|
|
581
|
+
}
|
|
582
|
+
recordRecoveryDecision(receipt) {
|
|
583
|
+
this.ensureRun(receipt.runId);
|
|
584
|
+
const digest = digestJson(receipt);
|
|
585
|
+
const session = sessionParts(receipt.predecessorSessionRef, receipt.predecessorSessionId);
|
|
586
|
+
this.db.transaction(() => {
|
|
587
|
+
this.db.prepare(`INSERT INTO recovery_decisions(run_id,decided_at,mode,digest,session_key,session_ref_json,receipt_json)
|
|
588
|
+
VALUES(?,?,?,?,?,?,?)`).run(receipt.runId, receipt.decidedAt, receipt.mode, digest, session.key, session.refJson, json(receipt));
|
|
589
|
+
this.insertEvent(receipt.runId, "recovery_decision", "recovery", { mode: receipt.mode, digest }, receipt.decidedAt);
|
|
590
|
+
})();
|
|
591
|
+
}
|
|
592
|
+
recoveryDecisions(runId) {
|
|
593
|
+
return this.db.prepare("SELECT receipt_json FROM recovery_decisions WHERE run_id=? ORDER BY decision_id").all(runId).map((row) => parseJson(row.receipt_json));
|
|
594
|
+
}
|
|
144
595
|
beginPublication(runId, intent, previousDigests, now = new Date()) {
|
|
145
596
|
const transactionId = randomUUID();
|
|
146
597
|
const at = now.toISOString();
|
|
@@ -230,6 +681,22 @@ export class WorkflowDb {
|
|
|
230
681
|
}
|
|
231
682
|
return count;
|
|
232
683
|
}
|
|
684
|
+
upsertContinuityHead(head) {
|
|
685
|
+
this.db.prepare(`INSERT INTO continuity_heads(run_id,role,state,event_sequence,digest,authoritative_state_revision,updated_at)
|
|
686
|
+
VALUES(?,?,?,?,?,?,?) ON CONFLICT(run_id,role) DO UPDATE SET state=excluded.state,event_sequence=excluded.event_sequence,digest=excluded.digest,authoritative_state_revision=excluded.authoritative_state_revision,updated_at=excluded.updated_at`)
|
|
687
|
+
.run(head.runId, head.role, head.state, head.sequence, head.digest, head.authoritativeStateRevision, head.updatedAt);
|
|
688
|
+
}
|
|
689
|
+
refreshRunContinuityHead(runId, authoritativeStateRevision, at) {
|
|
690
|
+
const rows = this.db.prepare("SELECT role,state,event_sequence,digest FROM continuity_heads WHERE run_id=? AND role IN ('builder','qa') ORDER BY role").all(runId);
|
|
691
|
+
if (!rows.length)
|
|
692
|
+
return;
|
|
693
|
+
const state = rows.some((row) => row.state === "invalid") ? "invalid"
|
|
694
|
+
: rows.some((row) => row.state === "degraded") ? "degraded"
|
|
695
|
+
: rows.some((row) => row.state === "stale") ? "stale" : "current";
|
|
696
|
+
const sequence = Math.max(...rows.map((row) => row.event_sequence));
|
|
697
|
+
const digest = digestJson(rows.map((row) => ({ role: row.role, digest: row.digest })));
|
|
698
|
+
this.upsertContinuityHead({ runId, role: "run", state, sequence, digest, authoritativeStateRevision, updatedAt: at });
|
|
699
|
+
}
|
|
233
700
|
insertEvent(runId, type, checkpoint, payload, at) {
|
|
234
701
|
this.db.prepare("INSERT INTO workflow_events(run_id,event_type,checkpoint,payload_json,created_at) VALUES(?,?,?,?,?)").run(runId, type, checkpoint, json(payload), at);
|
|
235
702
|
}
|
|
@@ -239,24 +706,118 @@ export class WorkflowDb {
|
|
|
239
706
|
CREATE TABLE IF NOT EXISTS workflow_events(sequence INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),event_type TEXT NOT NULL,checkpoint TEXT NOT NULL,payload_json TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
240
707
|
CREATE TABLE IF NOT EXISTS role_settings(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,boundary INTEGER NOT NULL,revision INTEGER NOT NULL,settings_json TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,role,boundary));
|
|
241
708
|
CREATE TABLE IF NOT EXISTS project_settings_revisions(revision INTEGER PRIMARY KEY,defaults_json TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
709
|
+
CREATE TABLE IF NOT EXISTS project_settings_revision_targets(revision INTEGER NOT NULL REFERENCES project_settings_revisions(revision),role TEXT NOT NULL,fields_json TEXT NOT NULL,PRIMARY KEY(revision,role));
|
|
242
710
|
CREATE TABLE IF NOT EXISTS workflow_telemetry(id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),snapshot_json TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
243
|
-
CREATE TABLE IF NOT EXISTS provider_sessions(id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,stream TEXT NOT NULL,provider TEXT NOT NULL,model TEXT NOT NULL,session_id TEXT,transition TEXT NOT NULL,settings_revision INTEGER NOT NULL,created_at TEXT NOT NULL);
|
|
711
|
+
CREATE TABLE IF NOT EXISTS provider_sessions(id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,stream TEXT NOT NULL,provider TEXT NOT NULL,model TEXT NOT NULL,session_id TEXT,session_key TEXT,session_ref_json TEXT,transition TEXT NOT NULL,settings_revision INTEGER NOT NULL,created_at TEXT NOT NULL);
|
|
712
|
+
CREATE TABLE IF NOT EXISTS provider_session_bindings(session_key TEXT PRIMARY KEY,provider_session_id TEXT NOT NULL,role TEXT NOT NULL,session_ref_json TEXT NOT NULL,observed_at TEXT NOT NULL);
|
|
244
713
|
CREATE TABLE IF NOT EXISTS workflow_issues(issue_id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),code TEXT NOT NULL,issue_json TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
245
714
|
CREATE TABLE IF NOT EXISTS operation_journal(idempotency_key TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),kind TEXT NOT NULL,status TEXT NOT NULL,intent_json TEXT NOT NULL,result_json TEXT,external_id TEXT,error TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
246
715
|
CREATE TABLE IF NOT EXISTS content_refs(digest TEXT PRIMARY KEY,kind TEXT NOT NULL,content BLOB NOT NULL,created_at TEXT NOT NULL);
|
|
247
716
|
CREATE TABLE IF NOT EXISTS project_lease(singleton INTEGER PRIMARY KEY CHECK(singleton=1),owner TEXT NOT NULL,generation INTEGER NOT NULL,pid INTEGER NOT NULL,host TEXT NOT NULL,process_start TEXT NOT NULL,heartbeat_at TEXT NOT NULL,run_id TEXT NOT NULL);
|
|
248
717
|
CREATE TABLE IF NOT EXISTS legacy_imports(source_path TEXT NOT NULL,digest TEXT NOT NULL,run_id TEXT NOT NULL,imported_at TEXT NOT NULL,PRIMARY KEY(source_path,digest));
|
|
249
718
|
CREATE TABLE IF NOT EXISTS publication_transactions(transaction_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,status TEXT NOT NULL,intent_json TEXT NOT NULL,previous_digests_json TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
719
|
+
CREATE TABLE IF NOT EXISTS context_samples(sample_id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,provider_session_id TEXT,session_key TEXT,session_ref_json TEXT,sample_json TEXT NOT NULL,observed_at TEXT NOT NULL);
|
|
720
|
+
CREATE TABLE IF NOT EXISTS session_usage_samples(sample_id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,provider_session_id TEXT,session_key TEXT,session_ref_json TEXT,sample_json TEXT NOT NULL,observed_at TEXT NOT NULL);
|
|
721
|
+
CREATE TABLE IF NOT EXISTS live_settings_acknowledgments(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,provider_session_id TEXT,session_key TEXT,session_ref_json TEXT,revision INTEGER NOT NULL,acknowledged_at TEXT NOT NULL,PRIMARY KEY(run_id,role,revision));
|
|
722
|
+
CREATE TABLE IF NOT EXISTS live_settings_session_acknowledgments(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,provider_session_id TEXT,session_key TEXT NOT NULL,session_ref_json TEXT,revision INTEGER NOT NULL,acknowledged_at TEXT NOT NULL,PRIMARY KEY(run_id,role,session_key,revision));
|
|
723
|
+
CREATE TABLE IF NOT EXISTS continuity_events(sequence INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,kind TEXT NOT NULL,payload_json TEXT NOT NULL,digest TEXT NOT NULL UNIQUE,authoritative_state_revision INTEGER NOT NULL,session_key TEXT,session_ref_json TEXT,created_at TEXT NOT NULL);
|
|
724
|
+
CREATE TABLE IF NOT EXISTS continuity_checkpoints(checkpoint_id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,event_sequence INTEGER NOT NULL,state TEXT NOT NULL,delta_json TEXT NOT NULL,digest TEXT NOT NULL UNIQUE,predecessor_digest TEXT,authoritative_state_revision INTEGER NOT NULL,session_key TEXT,session_ref_json TEXT,created_at TEXT NOT NULL);
|
|
725
|
+
CREATE TABLE IF NOT EXISTS continuity_heads(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,state TEXT NOT NULL,event_sequence INTEGER NOT NULL,digest TEXT NOT NULL,authoritative_state_revision INTEGER NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(run_id,role));
|
|
726
|
+
CREATE TABLE IF NOT EXISTS handoffs(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),generation INTEGER NOT NULL,role TEXT NOT NULL,manifest_digest TEXT NOT NULL,markdown_digest TEXT NOT NULL,predecessor_session_id TEXT,predecessor_session_key TEXT,predecessor_session_ref_json TEXT,successor_session_id TEXT,successor_session_key TEXT,successor_session_ref_json TEXT,state TEXT NOT NULL,failure TEXT,created_at TEXT NOT NULL,accepted_at TEXT,PRIMARY KEY(run_id,generation));
|
|
727
|
+
CREATE TABLE IF NOT EXISTS role_mutation_leases(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,generation INTEGER NOT NULL,provider_session_id TEXT NOT NULL,provider_session_key TEXT,provider_session_ref_json TEXT,moved_at TEXT NOT NULL,PRIMARY KEY(run_id,role));
|
|
728
|
+
CREATE TABLE IF NOT EXISTS compaction_attempts(idempotency_key TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,provider_session_id TEXT,session_key TEXT,session_ref_json TEXT,crossing_key TEXT NOT NULL,status TEXT NOT NULL,before_sample_json TEXT,after_sample_json TEXT,error TEXT,origin TEXT NOT NULL DEFAULT 'rafi-manual',provider_event_id TEXT,threshold_generation_id TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
729
|
+
CREATE TABLE IF NOT EXISTS threshold_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,provider_session_id TEXT,session_key TEXT,session_ref_json TEXT,generation INTEGER NOT NULL,state TEXT NOT NULL,configured_ceiling_percent REAL NOT NULL,installed_native_token_limit INTEGER,installed_native_percent REAL,settings_revision INTEGER NOT NULL,model TEXT NOT NULL,latest_sample_json TEXT,frozen_action_digest TEXT,provider_event_id TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,UNIQUE(run_id,role,session_key,generation));
|
|
730
|
+
CREATE TABLE IF NOT EXISTS active_role_adapter_sessions(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,provider_session_id TEXT,session_key TEXT NOT NULL,settings_revision INTEGER NOT NULL,observed_at TEXT NOT NULL,PRIMARY KEY(run_id,role,session_key));
|
|
731
|
+
CREATE TABLE IF NOT EXISTS recovery_decisions(decision_id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),decided_at TEXT NOT NULL,mode TEXT NOT NULL,digest TEXT NOT NULL UNIQUE,session_key TEXT,session_ref_json TEXT,receipt_json TEXT NOT NULL);
|
|
250
732
|
CREATE INDEX IF NOT EXISTS workflow_events_run ON workflow_events(run_id,sequence);
|
|
251
733
|
CREATE INDEX IF NOT EXISTS operations_run ON operation_journal(run_id,status);
|
|
734
|
+
CREATE INDEX IF NOT EXISTS context_samples_run_role ON context_samples(run_id,role,sample_id);
|
|
735
|
+
CREATE INDEX IF NOT EXISTS continuity_events_run ON continuity_events(run_id,sequence);
|
|
736
|
+
CREATE INDEX IF NOT EXISTS continuity_checkpoints_run_role ON continuity_checkpoints(run_id,role,checkpoint_id);
|
|
737
|
+
CREATE INDEX IF NOT EXISTS compaction_session ON compaction_attempts(run_id,role,provider_session_id,status);
|
|
252
738
|
`);
|
|
739
|
+
for (const [table, columns] of Object.entries({
|
|
740
|
+
provider_sessions: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
741
|
+
context_samples: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
742
|
+
session_usage_samples: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
743
|
+
live_settings_acknowledgments: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
744
|
+
continuity_events: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
745
|
+
continuity_checkpoints: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
746
|
+
handoffs: { predecessor_session_key: "TEXT", predecessor_session_ref_json: "TEXT", successor_session_key: "TEXT", successor_session_ref_json: "TEXT" },
|
|
747
|
+
role_mutation_leases: { provider_session_key: "TEXT", provider_session_ref_json: "TEXT" },
|
|
748
|
+
compaction_attempts: { session_key: "TEXT", session_ref_json: "TEXT", origin: "TEXT NOT NULL DEFAULT 'rafi-manual'", provider_event_id: "TEXT", threshold_generation_id: "TEXT" },
|
|
749
|
+
recovery_decisions: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
750
|
+
})) {
|
|
751
|
+
for (const [column, definition] of Object.entries(columns))
|
|
752
|
+
this.ensureColumn(table, column, definition);
|
|
753
|
+
}
|
|
754
|
+
this.db.exec(`
|
|
755
|
+
CREATE INDEX IF NOT EXISTS provider_sessions_scoped ON provider_sessions(run_id,role,session_key,id);
|
|
756
|
+
CREATE INDEX IF NOT EXISTS provider_session_bindings_id ON provider_session_bindings(provider_session_id,role,observed_at);
|
|
757
|
+
CREATE INDEX IF NOT EXISTS context_samples_scoped ON context_samples(run_id,role,session_key,sample_id);
|
|
758
|
+
CREATE INDEX IF NOT EXISTS session_usage_scoped ON session_usage_samples(run_id,role,session_key,sample_id);
|
|
759
|
+
CREATE INDEX IF NOT EXISTS compaction_session_scoped ON compaction_attempts(run_id,role,session_key,status);
|
|
760
|
+
CREATE UNIQUE INDEX IF NOT EXISTS compaction_native_event_unique ON compaction_attempts(run_id,role,session_key,provider_event_id) WHERE provider_event_id IS NOT NULL;
|
|
761
|
+
CREATE INDEX IF NOT EXISTS threshold_generation_scoped ON threshold_generations(run_id,role,session_key,generation);
|
|
762
|
+
CREATE INDEX IF NOT EXISTS active_role_adapter_session_observed ON active_role_adapter_sessions(observed_at,run_id,role);
|
|
763
|
+
CREATE INDEX IF NOT EXISTS live_settings_session_revision ON live_settings_session_acknowledgments(revision,run_id,role,session_key);
|
|
764
|
+
`);
|
|
765
|
+
}
|
|
766
|
+
ensureColumn(table, column, definition) {
|
|
767
|
+
const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
768
|
+
if (!columns.some((entry) => entry.name === column))
|
|
769
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
253
770
|
}
|
|
254
771
|
}
|
|
255
772
|
function rowToRun(row) { return { runId: row.run_id, kind: row.kind, status: row.status, checkpoint: row.checkpoint, originalWork: parseJson(row.original_work_json), remainingWork: parseJson(row.remaining_work_json), state: parseJson(row.state_json), ...(row.lease_generation === null ? {} : { leaseGeneration: row.lease_generation }), legacy: Boolean(row.legacy), createdAt: row.created_at, updatedAt: row.updated_at }; }
|
|
256
773
|
function operationFromRow(row) { return { idempotencyKey: row.idempotency_key, runId: row.run_id, kind: row.kind, status: row.status, intent: parseJson(row.intent_json), ...(row.result_json ? { result: parseJson(row.result_json) } : {}), ...(row.external_id ? { externalId: row.external_id } : {}), ...(row.error ? { error: row.error } : {}), createdAt: row.created_at, updatedAt: row.updated_at }; }
|
|
774
|
+
function continuityEventFromRow(row) { return { sequence: row.sequence, runId: row.run_id, role: row.role, kind: row.kind, payload: parseJson(row.payload_json), digest: row.digest, authoritativeStateRevision: row.authoritative_state_revision, createdAt: row.created_at, ...(row.session_key ? { sessionKey: row.session_key } : {}), ...(row.session_ref_json ? { sessionRef: parseJson(row.session_ref_json) } : {}) }; }
|
|
775
|
+
function continuityCheckpointFromRow(row) { return { checkpointId: row.checkpoint_id, runId: row.run_id, role: row.role, sequence: row.event_sequence, state: row.state, delta: parseJson(row.delta_json), digest: row.digest, ...(row.predecessor_digest ? { predecessorDigest: row.predecessor_digest } : {}), authoritativeStateRevision: row.authoritative_state_revision, createdAt: row.created_at, ...(row.session_key ? { sessionKey: row.session_key } : {}), ...(row.session_ref_json ? { sessionRef: parseJson(row.session_ref_json) } : {}) }; }
|
|
776
|
+
function continuityHeadFromRow(row) { return { runId: row.run_id, role: row.role, state: row.state, sequence: row.event_sequence, digest: row.digest, authoritativeStateRevision: row.authoritative_state_revision, updatedAt: row.updated_at }; }
|
|
777
|
+
function handoffFromRow(row) { return { runId: row.run_id, generation: row.generation, manifestDigest: row.manifest_digest, markdownDigest: row.markdown_digest, ...(row.predecessor_session_id ? { predecessorSessionId: row.predecessor_session_id } : {}), ...(row.predecessor_session_ref_json ? { predecessorSessionRef: parseJson(row.predecessor_session_ref_json) } : {}), ...(row.successor_session_id ? { successorSessionId: row.successor_session_id } : {}), ...(row.successor_session_ref_json ? { successorSessionRef: parseJson(row.successor_session_ref_json) } : {}), state: row.state, createdAt: row.created_at, ...(row.accepted_at ? { acceptedAt: row.accepted_at } : {}) }; }
|
|
778
|
+
function compactionAttemptFromRow(row) { return { idempotencyKey: row.idempotency_key, runId: row.run_id, role: row.role, ...(row.provider_session_id ? { providerSessionId: row.provider_session_id } : {}), ...(row.session_key ? { sessionKey: row.session_key } : {}), ...(row.session_ref_json ? { sessionRef: parseJson(row.session_ref_json) } : {}), crossingKey: row.crossing_key, status: row.status, origin: row.origin ?? "rafi-manual", ...(row.provider_event_id ? { providerEventId: row.provider_event_id } : {}), ...(row.threshold_generation_id ? { thresholdGenerationId: row.threshold_generation_id } : {}), ...(row.before_sample_json ? { beforeSample: parseJson(row.before_sample_json) } : {}), ...(row.after_sample_json ? { afterSample: parseJson(row.after_sample_json) } : {}), ...(row.error ? { error: row.error } : {}), createdAt: row.created_at, updatedAt: row.updated_at }; }
|
|
779
|
+
function thresholdGenerationFromRow(row) {
|
|
780
|
+
return {
|
|
781
|
+
generationId: row.generation_id, runId: row.run_id, role: row.role,
|
|
782
|
+
...(row.provider_session_id ? { providerSessionId: row.provider_session_id } : {}),
|
|
783
|
+
...(row.session_key ? { sessionKey: row.session_key } : {}),
|
|
784
|
+
...(row.session_ref_json ? { sessionRef: parseJson(row.session_ref_json) } : {}),
|
|
785
|
+
generation: row.generation, state: row.state, configuredCeilingPercent: row.configured_ceiling_percent,
|
|
786
|
+
...(row.installed_native_token_limit === null ? {} : { installedNativeTokenLimit: row.installed_native_token_limit }),
|
|
787
|
+
...(row.installed_native_percent === null ? {} : { installedNativePercent: row.installed_native_percent }),
|
|
788
|
+
settingsRevision: row.settings_revision, model: row.model,
|
|
789
|
+
...(row.latest_sample_json ? { latestSample: parseJson(row.latest_sample_json) } : {}),
|
|
790
|
+
...(row.frozen_action_digest ? { frozenActionDigest: row.frozen_action_digest } : {}),
|
|
791
|
+
...(row.provider_event_id ? { providerEventId: row.provider_event_id } : {}),
|
|
792
|
+
createdAt: row.created_at, updatedAt: row.updated_at,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
257
795
|
function json(value) { return JSON.stringify(value ?? null); }
|
|
258
796
|
function parseJson(value) { return JSON.parse(value); }
|
|
797
|
+
function sessionParts(ref, rawId, suppliedKey) {
|
|
798
|
+
const key = ref ? providerSessionKey(ref) : suppliedKey;
|
|
799
|
+
return { id: ref?.sessionId ?? rawId ?? null, key: key ?? null, ...(ref ? { ref } : {}), refJson: ref ? json(ref) : null };
|
|
800
|
+
}
|
|
259
801
|
function sanitizeText(value) { return value.replace(/\b(sk-[A-Za-z0-9_-]{12,}|(?:api[_-]?key|token|password)\s*[:=]\s*\S+)/gi, "[REDACTED]"); }
|
|
802
|
+
function stableJson(value) {
|
|
803
|
+
if (Array.isArray(value))
|
|
804
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
805
|
+
if (value && typeof value === "object")
|
|
806
|
+
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
|
|
807
|
+
return JSON.stringify(value ?? null);
|
|
808
|
+
}
|
|
809
|
+
function digestJson(value) { return createHash("sha256").update(stableJson(value)).digest("hex"); }
|
|
810
|
+
function sanitizeContinuityValue(value, depth = 0) {
|
|
811
|
+
if (depth > 12)
|
|
812
|
+
return "[TRUNCATED]";
|
|
813
|
+
if (typeof value === "string")
|
|
814
|
+
return sanitizeText(value).slice(0, 20_000);
|
|
815
|
+
if (Array.isArray(value))
|
|
816
|
+
return value.slice(0, 500).map((entry) => sanitizeContinuityValue(entry, depth + 1));
|
|
817
|
+
if (value && typeof value === "object")
|
|
818
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !/^(credential|secret|hidden_reasoning|raw_transcript|api_key|token|password)$/i.test(key)).slice(0, 500).map(([key, entry]) => [key, sanitizeContinuityValue(entry, depth + 1)]));
|
|
819
|
+
return value;
|
|
820
|
+
}
|
|
260
821
|
function processStartIdentity(pid = process.pid) { try {
|
|
261
822
|
return readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21] ?? "unavailable";
|
|
262
823
|
}
|
|
@@ -279,7 +840,7 @@ function leaseVerifiedLive(lease, now, staleMs) {
|
|
|
279
840
|
function ensureRecoveryGitignore(projectDir) {
|
|
280
841
|
const localExclude = join(projectDir, ".git", "info", "exclude");
|
|
281
842
|
const path = existsSync(localExclude) ? localExclude : join(projectDir, ".gitignore");
|
|
282
|
-
const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm
|
|
843
|
+
const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/"];
|
|
283
844
|
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
284
845
|
const missing = entries.filter((entry) => !existing.split(/\r?\n/).includes(entry));
|
|
285
846
|
if (missing.length)
|