ai-foreman 1.7.6 → 1.7.7
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 +16 -1
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +29 -4
- package/dist/adapters/claude.d.ts +33 -2
- package/dist/adapters/claude.js +208 -17
- package/dist/adapters/codex.d.ts +11 -1
- package/dist/adapters/codex.js +195 -20
- package/dist/adapters/recovering.d.ts +8 -2
- package/dist/adapters/recovering.js +23 -3
- package/dist/adapters/sessionFailure.d.ts +18 -0
- package/dist/adapters/sessionFailure.js +51 -0
- package/dist/adapters/types.d.ts +52 -9
- package/dist/agentRun.d.ts +4 -1
- package/dist/agentRun.js +96 -9
- package/dist/branch/currentGuard.d.ts +34 -0
- package/dist/branch/currentGuard.js +65 -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 +940 -42
- package/dist/cli/tickets.js +199 -3
- package/dist/continuity.d.ts +86 -0
- package/dist/continuity.js +365 -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 +326 -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 +20 -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 +102 -3
- package/dist/sessionLifecycle.js +578 -20
- package/dist/statusReporter.d.ts +58 -8
- package/dist/statusReporter.js +210 -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 +99 -2
- package/dist/workflowDb.js +434 -8
- package/package.json +31 -3
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,6 +102,7 @@ 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());
|
|
@@ -97,11 +113,150 @@ export class WorkflowDb {
|
|
|
97
113
|
this.db.prepare("INSERT OR REPLACE INTO project_settings_revisions(revision,defaults_json,created_at) VALUES(?,?,?)").run(revision, json(defaults), now.toISOString());
|
|
98
114
|
}
|
|
99
115
|
recordTelemetry(runId, snapshot, now = new Date()) {
|
|
116
|
+
this.ensureRun(runId, "build", now);
|
|
100
117
|
this.db.prepare("INSERT INTO workflow_telemetry(run_id,snapshot_json,created_at) VALUES(?,?,?)").run(runId, json(snapshot), now.toISOString());
|
|
101
118
|
}
|
|
102
|
-
|
|
103
|
-
this.
|
|
104
|
-
|
|
119
|
+
recordContextSample(sample) {
|
|
120
|
+
this.ensureRun(sample.runId);
|
|
121
|
+
const session = sessionParts(sample.sessionRef, sample.providerSessionId, sample.sessionKey);
|
|
122
|
+
if (session.ref)
|
|
123
|
+
this.recordProviderSessionBinding(session.ref, new Date(sample.observedAt));
|
|
124
|
+
const normalized = { ...sample, ...(session.ref ? { sessionRef: session.ref } : {}), ...(session.key ? { sessionKey: session.key } : {}) };
|
|
125
|
+
this.db.prepare(`INSERT INTO context_samples(run_id,role,provider_session_id,session_key,session_ref_json,sample_json,observed_at)
|
|
126
|
+
VALUES(?,?,?,?,?,?,?)`).run(sample.runId, sample.role, session.id, session.key, session.refJson, json(normalized), sample.observedAt);
|
|
127
|
+
}
|
|
128
|
+
contextSamples(runId, role) {
|
|
129
|
+
const rows = role
|
|
130
|
+
? this.db.prepare("SELECT sample_json FROM context_samples WHERE run_id=? AND role=? ORDER BY sample_id").all(runId, role)
|
|
131
|
+
: this.db.prepare("SELECT sample_json FROM context_samples WHERE run_id=? ORDER BY sample_id").all(runId);
|
|
132
|
+
return rows.map((row) => parseJson(row.sample_json));
|
|
133
|
+
}
|
|
134
|
+
recordSessionUsage(sample) {
|
|
135
|
+
this.ensureRun(sample.runId);
|
|
136
|
+
const session = sessionParts(sample.sessionRef, sample.providerSessionId, sample.sessionKey);
|
|
137
|
+
if (session.ref)
|
|
138
|
+
this.recordProviderSessionBinding(session.ref, new Date(sample.observedAt));
|
|
139
|
+
const normalized = { ...sample, ...(session.ref ? { sessionRef: session.ref } : {}), ...(session.key ? { sessionKey: session.key } : {}) };
|
|
140
|
+
this.db.prepare(`INSERT INTO session_usage_samples(run_id,role,provider_session_id,session_key,session_ref_json,sample_json,observed_at)
|
|
141
|
+
VALUES(?,?,?,?,?,?,?)`).run(sample.runId, sample.role, session.id, session.key, session.refJson, json(normalized), sample.observedAt);
|
|
142
|
+
}
|
|
143
|
+
sessionUsageSamples(runId, role) {
|
|
144
|
+
const rows = role
|
|
145
|
+
? this.db.prepare("SELECT sample_json FROM session_usage_samples WHERE run_id=? AND role=? ORDER BY sample_id").all(runId, role)
|
|
146
|
+
: this.db.prepare("SELECT sample_json FROM session_usage_samples WHERE run_id=? ORDER BY sample_id").all(runId);
|
|
147
|
+
return rows.map((row) => parseJson(row.sample_json));
|
|
148
|
+
}
|
|
149
|
+
acknowledgeSettings(ack) {
|
|
150
|
+
this.ensureRun(ack.runId);
|
|
151
|
+
const session = sessionParts(ack.sessionRef, ack.providerSessionId, ack.sessionKey);
|
|
152
|
+
this.db.transaction(() => {
|
|
153
|
+
this.db.prepare(`INSERT INTO live_settings_acknowledgments(run_id,role,provider_session_id,session_key,session_ref_json,revision,acknowledged_at)
|
|
154
|
+
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`)
|
|
155
|
+
.run(ack.runId, ack.role, session.id, session.key, session.refJson, ack.revision, ack.acknowledgedAt);
|
|
156
|
+
this.insertEvent(ack.runId, "live_settings_acknowledged", `settings:${ack.revision}`, { role: ack.role, providerSessionId: session.id, sessionKey: session.key }, ack.acknowledgedAt);
|
|
157
|
+
})();
|
|
158
|
+
}
|
|
159
|
+
settingsAcknowledgments(revision) {
|
|
160
|
+
const rows = this.db.prepare("SELECT * FROM live_settings_acknowledgments WHERE revision=? ORDER BY acknowledged_at").all(revision);
|
|
161
|
+
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 } : {}), ...(row.session_ref_json ? { sessionRef: parseJson(row.session_ref_json) } : {}), revision: row.revision, acknowledgedAt: row.acknowledged_at }));
|
|
162
|
+
}
|
|
163
|
+
appendContinuityEvent(input, now = new Date()) {
|
|
164
|
+
this.ensureRun(input.runId, "build", now);
|
|
165
|
+
const at = now.toISOString();
|
|
166
|
+
const safePayload = sanitizeContinuityValue(input.payload);
|
|
167
|
+
const session = sessionParts(input.sessionRef, undefined, input.sessionKey);
|
|
168
|
+
const digest = digestJson({ runId: input.runId, role: input.role, kind: input.kind, payload: safePayload, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key, at });
|
|
169
|
+
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)
|
|
170
|
+
VALUES(?,?,?,?,?,?,?,?,?)`).run(input.runId, input.role, input.kind, json(safePayload), digest, input.authoritativeStateRevision, session.key, session.refJson, at);
|
|
171
|
+
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 } : {}) };
|
|
172
|
+
}
|
|
173
|
+
continuityEvents(runId, afterSequence = 0) {
|
|
174
|
+
const rows = this.db.prepare("SELECT * FROM continuity_events WHERE run_id=? AND sequence>? ORDER BY sequence").all(runId, afterSequence);
|
|
175
|
+
return rows.map(continuityEventFromRow);
|
|
176
|
+
}
|
|
177
|
+
publishContinuityCheckpoint(input, now = new Date()) {
|
|
178
|
+
this.ensureRun(input.runId, "build", now);
|
|
179
|
+
return this.db.transaction(() => {
|
|
180
|
+
const at = now.toISOString();
|
|
181
|
+
const previous = this.continuityHead(input.runId, input.role);
|
|
182
|
+
const latestEvent = this.db.prepare("SELECT COALESCE(MAX(sequence),0) AS sequence FROM continuity_events WHERE run_id=?").get(input.runId);
|
|
183
|
+
const safeDelta = sanitizeContinuityValue(input.delta);
|
|
184
|
+
const session = sessionParts(input.sessionRef, undefined, input.sessionKey);
|
|
185
|
+
const digest = digestJson({ runId: input.runId, role: input.role, sequence: latestEvent.sequence, predecessorDigest: previous?.digest, delta: safeDelta, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key });
|
|
186
|
+
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)
|
|
187
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(input.runId, input.role, latestEvent.sequence, input.state ?? "current", json(safeDelta), digest, previous?.digest ?? null, input.authoritativeStateRevision, session.key, session.refJson, at);
|
|
188
|
+
const checkpoint = {
|
|
189
|
+
checkpointId: Number(result.lastInsertRowid), runId: input.runId, role: input.role,
|
|
190
|
+
sequence: latestEvent.sequence, state: input.state ?? "current", delta: safeDelta, digest,
|
|
191
|
+
...(previous ? { predecessorDigest: previous.digest } : {}),
|
|
192
|
+
authoritativeStateRevision: input.authoritativeStateRevision, createdAt: at,
|
|
193
|
+
...(session.key ? { sessionKey: session.key } : {}), ...(session.ref ? { sessionRef: session.ref } : {}),
|
|
194
|
+
};
|
|
195
|
+
this.upsertContinuityHead({ runId: input.runId, role: input.role, state: checkpoint.state, sequence: checkpoint.sequence, digest, authoritativeStateRevision: input.authoritativeStateRevision, updatedAt: at });
|
|
196
|
+
this.refreshRunContinuityHead(input.runId, input.authoritativeStateRevision, at);
|
|
197
|
+
this.insertEvent(input.runId, "continuity_checkpoint", `continuity:${input.role}`, { checkpointId: checkpoint.checkpointId, digest, state: checkpoint.state }, at);
|
|
198
|
+
return checkpoint;
|
|
199
|
+
})();
|
|
200
|
+
}
|
|
201
|
+
continuityCheckpoints(runId, role) {
|
|
202
|
+
const rows = role
|
|
203
|
+
? this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id").all(runId, role)
|
|
204
|
+
: this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? ORDER BY checkpoint_id").all(runId);
|
|
205
|
+
return rows.map(continuityCheckpointFromRow);
|
|
206
|
+
}
|
|
207
|
+
latestContinuityCheckpoint(runId, role) {
|
|
208
|
+
const row = this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id DESC LIMIT 1").get(runId, role);
|
|
209
|
+
return row ? continuityCheckpointFromRow(row) : undefined;
|
|
210
|
+
}
|
|
211
|
+
continuityHead(runId, role) {
|
|
212
|
+
const row = this.db.prepare("SELECT * FROM continuity_heads WHERE run_id=? AND role=?").get(runId, role);
|
|
213
|
+
return row ? continuityHeadFromRow(row) : undefined;
|
|
214
|
+
}
|
|
215
|
+
setContinuityHeadState(runId, role, state, now = new Date()) {
|
|
216
|
+
const head = this.continuityHead(runId, role);
|
|
217
|
+
if (!head)
|
|
218
|
+
return undefined;
|
|
219
|
+
const next = { ...head, state, updatedAt: now.toISOString() };
|
|
220
|
+
this.upsertContinuityHead(next);
|
|
221
|
+
this.refreshRunContinuityHead(runId, head.authoritativeStateRevision, next.updatedAt);
|
|
222
|
+
return next;
|
|
223
|
+
}
|
|
224
|
+
recordSession(runId, role, stream, session, transition, settings, now = new Date()) {
|
|
225
|
+
this.ensureRun(runId, "build", now);
|
|
226
|
+
const scoped = sessionParts(typeof session === "object" ? session : undefined, typeof session === "string" ? session : undefined);
|
|
227
|
+
if (scoped.ref)
|
|
228
|
+
this.recordProviderSessionBinding(scoped.ref, now);
|
|
229
|
+
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)
|
|
230
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(runId, role, stream, settings.make, settings.model, scoped.id, scoped.key, scoped.refJson, transition, settings.settings_revision, now.toISOString());
|
|
231
|
+
}
|
|
232
|
+
providerSessionRefs(runId, role) {
|
|
233
|
+
const rows = role
|
|
234
|
+
? 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)
|
|
235
|
+
: 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);
|
|
236
|
+
const refs = rows.map((row) => parseJson(row.session_ref_json));
|
|
237
|
+
return [...new Map(refs.map((ref) => [providerSessionKey(ref), ref])).values()];
|
|
238
|
+
}
|
|
239
|
+
/** Project-local lookup used to resolve compatibility raw IDs into scoped references. */
|
|
240
|
+
recordProviderSessionBinding(ref, now = new Date()) {
|
|
241
|
+
const key = providerSessionKey(ref);
|
|
242
|
+
this.db.prepare(`INSERT INTO provider_session_bindings(session_key,provider_session_id,role,session_ref_json,observed_at)
|
|
243
|
+
VALUES(?,?,?,?,?) ON CONFLICT(session_key) DO UPDATE SET session_ref_json=excluded.session_ref_json,observed_at=excluded.observed_at`)
|
|
244
|
+
.run(key, ref.sessionId, ref.role, json(ref), now.toISOString());
|
|
245
|
+
}
|
|
246
|
+
providerSessionBindings(sessionId, role) {
|
|
247
|
+
const clauses = [];
|
|
248
|
+
const values = [];
|
|
249
|
+
if (sessionId) {
|
|
250
|
+
clauses.push("provider_session_id=?");
|
|
251
|
+
values.push(sessionId);
|
|
252
|
+
}
|
|
253
|
+
if (role) {
|
|
254
|
+
clauses.push("role=?");
|
|
255
|
+
values.push(role);
|
|
256
|
+
}
|
|
257
|
+
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
258
|
+
const rows = this.db.prepare(`SELECT session_ref_json FROM provider_session_bindings${where} ORDER BY observed_at`).all(...values);
|
|
259
|
+
return rows.map((row) => parseJson(row.session_ref_json));
|
|
105
260
|
}
|
|
106
261
|
recordIssue(runId, issue) {
|
|
107
262
|
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 +273,15 @@ export class WorkflowDb {
|
|
|
118
273
|
return this.operation(input.idempotencyKey);
|
|
119
274
|
}
|
|
120
275
|
updateOperation(idempotencyKey, status, details = {}, now = new Date()) {
|
|
121
|
-
|
|
276
|
+
const prior = this.operation(idempotencyKey);
|
|
277
|
+
if (!prior)
|
|
122
278
|
throw new Error(`operation not found: ${idempotencyKey}`);
|
|
123
|
-
|
|
124
|
-
|
|
279
|
+
const at = now.toISOString();
|
|
280
|
+
this.db.transaction(() => {
|
|
281
|
+
this.db.prepare("UPDATE operation_journal SET status=?,result_json=?,external_id=?,error=?,updated_at=? WHERE idempotency_key=?")
|
|
282
|
+
.run(status, details.result === undefined ? null : json(details.result), details.externalId ?? null, details.error ?? null, at, idempotencyKey);
|
|
283
|
+
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);
|
|
284
|
+
})();
|
|
125
285
|
return this.operation(idempotencyKey);
|
|
126
286
|
}
|
|
127
287
|
operation(idempotencyKey) {
|
|
@@ -141,6 +301,186 @@ export class WorkflowDb {
|
|
|
141
301
|
const row = this.db.prepare("SELECT content FROM content_refs WHERE digest=?").get(digest);
|
|
142
302
|
return row?.content;
|
|
143
303
|
}
|
|
304
|
+
stageHandoff(manifest, markdown) {
|
|
305
|
+
this.ensureRun(manifest.runId);
|
|
306
|
+
const manifestText = stableJson(sanitizeContinuityValue(manifest));
|
|
307
|
+
const manifestDigest = this.putEvidence("handoff", manifestText);
|
|
308
|
+
const markdownDigest = this.putEvidence("handoff", markdown);
|
|
309
|
+
const lineage = {
|
|
310
|
+
runId: manifest.runId,
|
|
311
|
+
generation: manifest.generation,
|
|
312
|
+
manifestDigest,
|
|
313
|
+
markdownDigest,
|
|
314
|
+
...(manifest.predecessorSessionId ? { predecessorSessionId: manifest.predecessorSessionId } : {}),
|
|
315
|
+
...(manifest.predecessorSessionRef ? { predecessorSessionRef: manifest.predecessorSessionRef } : {}),
|
|
316
|
+
state: "staged",
|
|
317
|
+
createdAt: manifest.createdAt,
|
|
318
|
+
};
|
|
319
|
+
this.db.transaction(() => {
|
|
320
|
+
const existing = this.db.prepare("SELECT manifest_digest,markdown_digest FROM handoffs WHERE run_id=? AND generation=?").get(manifest.runId, manifest.generation);
|
|
321
|
+
if (existing && (existing.manifest_digest !== manifestDigest || existing.markdown_digest !== markdownDigest)) {
|
|
322
|
+
throw new Error(`handoff generation ${manifest.generation} is immutable`);
|
|
323
|
+
}
|
|
324
|
+
const predecessor = sessionParts(manifest.predecessorSessionRef, manifest.predecessorSessionId);
|
|
325
|
+
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)
|
|
326
|
+
VALUES(?,?,?,?,?,?,?,?,?,?)`).run(manifest.runId, manifest.generation, manifest.role, manifestDigest, markdownDigest, predecessor.id, predecessor.key, predecessor.refJson, "staged", manifest.createdAt);
|
|
327
|
+
this.insertEvent(manifest.runId, "handoff_staged", `handoff:${manifest.generation}`, { generation: manifest.generation, manifestDigest, markdownDigest, resources: manifest.resources }, manifest.createdAt);
|
|
328
|
+
})();
|
|
329
|
+
return this.handoff(manifest.runId, manifest.generation) ?? lineage;
|
|
330
|
+
}
|
|
331
|
+
acceptHandoff(runId, generation, successor, now = new Date()) {
|
|
332
|
+
const session = sessionParts(typeof successor === "object" ? successor : undefined, typeof successor === "string" ? successor : undefined);
|
|
333
|
+
if (!session.id?.trim())
|
|
334
|
+
throw new Error("a validated successor session ID is required before handoff acceptance");
|
|
335
|
+
if (session.ref)
|
|
336
|
+
this.recordProviderSessionBinding(session.ref, now);
|
|
337
|
+
return this.db.transaction(() => {
|
|
338
|
+
const prior = this.handoff(runId, generation);
|
|
339
|
+
if (!prior)
|
|
340
|
+
throw new Error(`handoff generation ${generation} not found for run ${runId}`);
|
|
341
|
+
if (prior.state === "failed")
|
|
342
|
+
throw new Error("a failed handoff cannot be accepted");
|
|
343
|
+
const at = now.toISOString();
|
|
344
|
+
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=?`)
|
|
345
|
+
.run(session.id, session.key, session.refJson, at, runId, generation);
|
|
346
|
+
this.db.prepare(`INSERT INTO role_mutation_leases(run_id,role,generation,provider_session_id,provider_session_key,provider_session_ref_json,moved_at)
|
|
347
|
+
SELECT run_id,role,generation,?,?,?,? FROM handoffs WHERE run_id=? AND generation=?
|
|
348
|
+
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`)
|
|
349
|
+
.run(session.id, session.key, session.refJson, at, runId, generation);
|
|
350
|
+
this.insertEvent(runId, "handoff_accepted", `handoff:${generation}`, { generation, successorSessionId: session.id, sessionKey: session.key, leaseMoved: true }, at);
|
|
351
|
+
return this.handoff(runId, generation);
|
|
352
|
+
})();
|
|
353
|
+
}
|
|
354
|
+
/** Fence the initial role owner without overwriting a lease already moved by a handoff. */
|
|
355
|
+
claimInitialRoleLease(runId, role, providerSession, now = new Date()) {
|
|
356
|
+
const session = sessionParts(typeof providerSession === "object" ? providerSession : undefined, typeof providerSession === "string" ? providerSession : undefined);
|
|
357
|
+
if (!session.id?.trim())
|
|
358
|
+
throw new Error("a provider session ID is required to claim the role lease");
|
|
359
|
+
this.ensureRun(runId);
|
|
360
|
+
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(?,?,?,?,?,?,?)`)
|
|
361
|
+
.run(runId, role, 0, session.id, session.key, session.refJson, now.toISOString());
|
|
362
|
+
return this.roleMutationLease(runId, role);
|
|
363
|
+
}
|
|
364
|
+
roleMutationLease(runId, role) {
|
|
365
|
+
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=?`)
|
|
366
|
+
.get(runId, role);
|
|
367
|
+
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;
|
|
368
|
+
}
|
|
369
|
+
/** Move a dead predecessor's lease only after a fresh recovery produced a schema-valid checkpoint. */
|
|
370
|
+
moveRoleLeaseAfterValidatedRecovery(runId, role, providerSession, reason, now = new Date()) {
|
|
371
|
+
const session = sessionParts(typeof providerSession === "object" ? providerSession : undefined, typeof providerSession === "string" ? providerSession : undefined);
|
|
372
|
+
if (!session.id?.trim())
|
|
373
|
+
throw new Error("a validated recovery session ID is required to move the role lease");
|
|
374
|
+
this.ensureRun(runId);
|
|
375
|
+
const prior = this.roleMutationLease(runId, role);
|
|
376
|
+
if ((session.key && prior?.sessionKey === session.key) || (!session.key && prior?.providerSessionId === session.id))
|
|
377
|
+
return prior;
|
|
378
|
+
const at = now.toISOString();
|
|
379
|
+
const generation = (prior?.generation ?? -1) + 1;
|
|
380
|
+
return this.db.transaction(() => {
|
|
381
|
+
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(?,?,?,?,?,?,?)
|
|
382
|
+
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`)
|
|
383
|
+
.run(runId, role, generation, session.id, session.key, session.refJson, at);
|
|
384
|
+
this.insertEvent(runId, "validated_recovery_lease_moved", `recovery:${role}:${generation}`, {
|
|
385
|
+
role, generation, predecessorSessionId: prior?.providerSessionId, successorSessionId: session.id, successorSessionKey: session.key,
|
|
386
|
+
reason: sanitizeText(reason).slice(0, 500),
|
|
387
|
+
}, at);
|
|
388
|
+
return this.roleMutationLease(runId, role);
|
|
389
|
+
})();
|
|
390
|
+
}
|
|
391
|
+
failHandoff(runId, generation, reason, now = new Date()) {
|
|
392
|
+
const prior = this.handoff(runId, generation);
|
|
393
|
+
if (!prior)
|
|
394
|
+
throw new Error(`handoff generation ${generation} not found for run ${runId}`);
|
|
395
|
+
if (prior.state === "accepted")
|
|
396
|
+
throw new Error("an accepted handoff cannot be failed");
|
|
397
|
+
const at = now.toISOString();
|
|
398
|
+
this.db.prepare("UPDATE handoffs SET state='failed',failure=? WHERE run_id=? AND generation=?").run(sanitizeText(reason).slice(0, 2000), runId, generation);
|
|
399
|
+
this.insertEvent(runId, "handoff_failed", `handoff:${generation}`, { generation, reason: sanitizeText(reason).slice(0, 500) }, at);
|
|
400
|
+
return this.handoff(runId, generation);
|
|
401
|
+
}
|
|
402
|
+
handoff(runId, generation) {
|
|
403
|
+
const row = this.db.prepare("SELECT * FROM handoffs WHERE run_id=? AND generation=?").get(runId, generation);
|
|
404
|
+
return row ? handoffFromRow(row) : undefined;
|
|
405
|
+
}
|
|
406
|
+
handoffs(runId) {
|
|
407
|
+
return this.db.prepare("SELECT * FROM handoffs WHERE run_id=? ORDER BY generation").all(runId).map(handoffFromRow);
|
|
408
|
+
}
|
|
409
|
+
handoffContent(runId, generation) {
|
|
410
|
+
const lineage = this.handoff(runId, generation);
|
|
411
|
+
if (!lineage)
|
|
412
|
+
return undefined;
|
|
413
|
+
const manifest = this.getEvidence(lineage.manifestDigest);
|
|
414
|
+
const markdown = this.getEvidence(lineage.markdownDigest);
|
|
415
|
+
if (!manifest || !markdown)
|
|
416
|
+
throw new Error(`handoff ${runId}/${generation} references missing durable content`);
|
|
417
|
+
return { lineage, manifest: JSON.parse(manifest.toString("utf8")), markdown: markdown.toString("utf8") };
|
|
418
|
+
}
|
|
419
|
+
deleteHandoffHistory(runId) {
|
|
420
|
+
const run = this.getRun(runId);
|
|
421
|
+
if (!run)
|
|
422
|
+
throw new Error(`run not found: ${runId}`);
|
|
423
|
+
if (!["completed", "cancelled", "superseded"].includes(run.status))
|
|
424
|
+
throw new Error(`run ${runId} is active or recoverable; durable handoff history cannot be deleted`);
|
|
425
|
+
const rows = this.db.prepare("SELECT manifest_digest,markdown_digest FROM handoffs WHERE run_id=?").all(runId);
|
|
426
|
+
this.db.transaction(() => {
|
|
427
|
+
this.db.prepare("DELETE FROM role_mutation_leases WHERE run_id=?").run(runId);
|
|
428
|
+
this.db.prepare("DELETE FROM handoffs WHERE run_id=?").run(runId);
|
|
429
|
+
for (const row of rows) {
|
|
430
|
+
for (const digest of [row.manifest_digest, row.markdown_digest]) {
|
|
431
|
+
const referenced = this.db.prepare("SELECT 1 FROM handoffs WHERE manifest_digest=? OR markdown_digest=? LIMIT 1").get(digest, digest);
|
|
432
|
+
if (!referenced)
|
|
433
|
+
this.db.prepare("DELETE FROM content_refs WHERE digest=? AND kind='handoff'").run(digest);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
})();
|
|
437
|
+
return rows.length;
|
|
438
|
+
}
|
|
439
|
+
startCompactionAttempt(input, now = new Date()) {
|
|
440
|
+
this.ensureRun(input.runId);
|
|
441
|
+
const at = now.toISOString();
|
|
442
|
+
const session = sessionParts(input.sessionRef, input.providerSessionId, input.sessionKey);
|
|
443
|
+
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,created_at,updated_at)
|
|
444
|
+
VALUES(?,?,?,?,?,?,?,'started',?,?,?)`).run(input.idempotencyKey, input.runId, input.role, session.id, session.key, session.refJson, input.crossingKey, input.beforeSample ? json(input.beforeSample) : null, at, at);
|
|
445
|
+
return this.compactionAttempt(input.idempotencyKey);
|
|
446
|
+
}
|
|
447
|
+
finishCompactionAttempt(idempotencyKey, input, now = new Date()) {
|
|
448
|
+
const prior = this.compactionAttempt(idempotencyKey);
|
|
449
|
+
if (!prior)
|
|
450
|
+
throw new Error(`compaction attempt not found: ${idempotencyKey}`);
|
|
451
|
+
if (prior.status !== "started")
|
|
452
|
+
return prior;
|
|
453
|
+
this.db.prepare("UPDATE compaction_attempts SET status=?,after_sample_json=?,error=?,updated_at=? WHERE idempotency_key=?")
|
|
454
|
+
.run(input.ok ? "succeeded" : "failed", input.afterSample ? json(input.afterSample) : null, input.error ? sanitizeText(input.error).slice(0, 2000) : null, now.toISOString(), idempotencyKey);
|
|
455
|
+
this.insertEvent(prior.runId, input.ok ? "compaction_succeeded" : "compaction_failed", `context:${prior.role}`, { idempotencyKey, providerSessionId: prior.providerSessionId, sessionKey: prior.sessionKey, crossingKey: prior.crossingKey }, now.toISOString());
|
|
456
|
+
return this.compactionAttempt(idempotencyKey);
|
|
457
|
+
}
|
|
458
|
+
compactionAttempt(idempotencyKey) {
|
|
459
|
+
const row = this.db.prepare("SELECT * FROM compaction_attempts WHERE idempotency_key=?").get(idempotencyKey);
|
|
460
|
+
return row ? compactionAttemptFromRow(row) : undefined;
|
|
461
|
+
}
|
|
462
|
+
successfulCompactionCount(runId, role, providerSession) {
|
|
463
|
+
if (!providerSession)
|
|
464
|
+
return 0;
|
|
465
|
+
const session = sessionParts(typeof providerSession === "object" ? providerSession : undefined, typeof providerSession === "string" ? providerSession : undefined);
|
|
466
|
+
const row = session.key
|
|
467
|
+
? this.db.prepare(`SELECT COUNT(*) AS count FROM compaction_attempts WHERE run_id=? AND role=? AND session_key=? AND status='succeeded'`).get(runId, role, session.key)
|
|
468
|
+
: 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='succeeded'`).get(runId, role, session.id);
|
|
469
|
+
return row.count;
|
|
470
|
+
}
|
|
471
|
+
recordRecoveryDecision(receipt) {
|
|
472
|
+
this.ensureRun(receipt.runId);
|
|
473
|
+
const digest = digestJson(receipt);
|
|
474
|
+
const session = sessionParts(receipt.predecessorSessionRef, receipt.predecessorSessionId);
|
|
475
|
+
this.db.transaction(() => {
|
|
476
|
+
this.db.prepare(`INSERT INTO recovery_decisions(run_id,decided_at,mode,digest,session_key,session_ref_json,receipt_json)
|
|
477
|
+
VALUES(?,?,?,?,?,?,?)`).run(receipt.runId, receipt.decidedAt, receipt.mode, digest, session.key, session.refJson, json(receipt));
|
|
478
|
+
this.insertEvent(receipt.runId, "recovery_decision", "recovery", { mode: receipt.mode, digest }, receipt.decidedAt);
|
|
479
|
+
})();
|
|
480
|
+
}
|
|
481
|
+
recoveryDecisions(runId) {
|
|
482
|
+
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));
|
|
483
|
+
}
|
|
144
484
|
beginPublication(runId, intent, previousDigests, now = new Date()) {
|
|
145
485
|
const transactionId = randomUUID();
|
|
146
486
|
const at = now.toISOString();
|
|
@@ -230,6 +570,22 @@ export class WorkflowDb {
|
|
|
230
570
|
}
|
|
231
571
|
return count;
|
|
232
572
|
}
|
|
573
|
+
upsertContinuityHead(head) {
|
|
574
|
+
this.db.prepare(`INSERT INTO continuity_heads(run_id,role,state,event_sequence,digest,authoritative_state_revision,updated_at)
|
|
575
|
+
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`)
|
|
576
|
+
.run(head.runId, head.role, head.state, head.sequence, head.digest, head.authoritativeStateRevision, head.updatedAt);
|
|
577
|
+
}
|
|
578
|
+
refreshRunContinuityHead(runId, authoritativeStateRevision, at) {
|
|
579
|
+
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);
|
|
580
|
+
if (!rows.length)
|
|
581
|
+
return;
|
|
582
|
+
const state = rows.some((row) => row.state === "invalid") ? "invalid"
|
|
583
|
+
: rows.some((row) => row.state === "degraded") ? "degraded"
|
|
584
|
+
: rows.some((row) => row.state === "stale") ? "stale" : "current";
|
|
585
|
+
const sequence = Math.max(...rows.map((row) => row.event_sequence));
|
|
586
|
+
const digest = digestJson(rows.map((row) => ({ role: row.role, digest: row.digest })));
|
|
587
|
+
this.upsertContinuityHead({ runId, role: "run", state, sequence, digest, authoritativeStateRevision, updatedAt: at });
|
|
588
|
+
}
|
|
233
589
|
insertEvent(runId, type, checkpoint, payload, at) {
|
|
234
590
|
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
591
|
}
|
|
@@ -240,23 +596,93 @@ export class WorkflowDb {
|
|
|
240
596
|
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
597
|
CREATE TABLE IF NOT EXISTS project_settings_revisions(revision INTEGER PRIMARY KEY,defaults_json TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
242
598
|
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);
|
|
599
|
+
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);
|
|
600
|
+
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
601
|
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
602
|
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
603
|
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
604
|
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
605
|
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
606
|
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);
|
|
607
|
+
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);
|
|
608
|
+
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);
|
|
609
|
+
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));
|
|
610
|
+
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);
|
|
611
|
+
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);
|
|
612
|
+
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));
|
|
613
|
+
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));
|
|
614
|
+
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));
|
|
615
|
+
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,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
616
|
+
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
617
|
CREATE INDEX IF NOT EXISTS workflow_events_run ON workflow_events(run_id,sequence);
|
|
251
618
|
CREATE INDEX IF NOT EXISTS operations_run ON operation_journal(run_id,status);
|
|
619
|
+
CREATE INDEX IF NOT EXISTS context_samples_run_role ON context_samples(run_id,role,sample_id);
|
|
620
|
+
CREATE INDEX IF NOT EXISTS continuity_events_run ON continuity_events(run_id,sequence);
|
|
621
|
+
CREATE INDEX IF NOT EXISTS continuity_checkpoints_run_role ON continuity_checkpoints(run_id,role,checkpoint_id);
|
|
622
|
+
CREATE INDEX IF NOT EXISTS compaction_session ON compaction_attempts(run_id,role,provider_session_id,status);
|
|
252
623
|
`);
|
|
624
|
+
for (const [table, columns] of Object.entries({
|
|
625
|
+
provider_sessions: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
626
|
+
context_samples: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
627
|
+
session_usage_samples: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
628
|
+
live_settings_acknowledgments: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
629
|
+
continuity_events: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
630
|
+
continuity_checkpoints: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
631
|
+
handoffs: { predecessor_session_key: "TEXT", predecessor_session_ref_json: "TEXT", successor_session_key: "TEXT", successor_session_ref_json: "TEXT" },
|
|
632
|
+
role_mutation_leases: { provider_session_key: "TEXT", provider_session_ref_json: "TEXT" },
|
|
633
|
+
compaction_attempts: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
634
|
+
recovery_decisions: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
635
|
+
})) {
|
|
636
|
+
for (const [column, definition] of Object.entries(columns))
|
|
637
|
+
this.ensureColumn(table, column, definition);
|
|
638
|
+
}
|
|
639
|
+
this.db.exec(`
|
|
640
|
+
CREATE INDEX IF NOT EXISTS provider_sessions_scoped ON provider_sessions(run_id,role,session_key,id);
|
|
641
|
+
CREATE INDEX IF NOT EXISTS provider_session_bindings_id ON provider_session_bindings(provider_session_id,role,observed_at);
|
|
642
|
+
CREATE INDEX IF NOT EXISTS context_samples_scoped ON context_samples(run_id,role,session_key,sample_id);
|
|
643
|
+
CREATE INDEX IF NOT EXISTS session_usage_scoped ON session_usage_samples(run_id,role,session_key,sample_id);
|
|
644
|
+
CREATE INDEX IF NOT EXISTS compaction_session_scoped ON compaction_attempts(run_id,role,session_key,status);
|
|
645
|
+
`);
|
|
646
|
+
}
|
|
647
|
+
ensureColumn(table, column, definition) {
|
|
648
|
+
const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
649
|
+
if (!columns.some((entry) => entry.name === column))
|
|
650
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
253
651
|
}
|
|
254
652
|
}
|
|
255
653
|
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
654
|
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 }; }
|
|
655
|
+
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) } : {}) }; }
|
|
656
|
+
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) } : {}) }; }
|
|
657
|
+
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 }; }
|
|
658
|
+
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 } : {}) }; }
|
|
659
|
+
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, ...(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 }; }
|
|
257
660
|
function json(value) { return JSON.stringify(value ?? null); }
|
|
258
661
|
function parseJson(value) { return JSON.parse(value); }
|
|
662
|
+
function sessionParts(ref, rawId, suppliedKey) {
|
|
663
|
+
const key = ref ? providerSessionKey(ref) : suppliedKey;
|
|
664
|
+
return { id: ref?.sessionId ?? rawId ?? null, key: key ?? null, ...(ref ? { ref } : {}), refJson: ref ? json(ref) : null };
|
|
665
|
+
}
|
|
259
666
|
function sanitizeText(value) { return value.replace(/\b(sk-[A-Za-z0-9_-]{12,}|(?:api[_-]?key|token|password)\s*[:=]\s*\S+)/gi, "[REDACTED]"); }
|
|
667
|
+
function stableJson(value) {
|
|
668
|
+
if (Array.isArray(value))
|
|
669
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
670
|
+
if (value && typeof value === "object")
|
|
671
|
+
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
|
|
672
|
+
return JSON.stringify(value ?? null);
|
|
673
|
+
}
|
|
674
|
+
function digestJson(value) { return createHash("sha256").update(stableJson(value)).digest("hex"); }
|
|
675
|
+
function sanitizeContinuityValue(value, depth = 0) {
|
|
676
|
+
if (depth > 12)
|
|
677
|
+
return "[TRUNCATED]";
|
|
678
|
+
if (typeof value === "string")
|
|
679
|
+
return sanitizeText(value).slice(0, 20_000);
|
|
680
|
+
if (Array.isArray(value))
|
|
681
|
+
return value.slice(0, 500).map((entry) => sanitizeContinuityValue(entry, depth + 1));
|
|
682
|
+
if (value && typeof value === "object")
|
|
683
|
+
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)]));
|
|
684
|
+
return value;
|
|
685
|
+
}
|
|
260
686
|
function processStartIdentity(pid = process.pid) { try {
|
|
261
687
|
return readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21] ?? "unavailable";
|
|
262
688
|
}
|
|
@@ -279,7 +705,7 @@ function leaseVerifiedLive(lease, now, staleMs) {
|
|
|
279
705
|
function ensureRecoveryGitignore(projectDir) {
|
|
280
706
|
const localExclude = join(projectDir, ".git", "info", "exclude");
|
|
281
707
|
const path = existsSync(localExclude) ? localExclude : join(projectDir, ".gitignore");
|
|
282
|
-
const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm
|
|
708
|
+
const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/"];
|
|
283
709
|
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
284
710
|
const missing = entries.filter((entry) => !existing.split(/\r?\n/).includes(entry));
|
|
285
711
|
if (missing.length)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-foreman",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.7",
|
|
4
4
|
"description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -53,10 +53,30 @@
|
|
|
53
53
|
"types": "./dist/workflowDb.d.ts",
|
|
54
54
|
"default": "./dist/workflowDb.js"
|
|
55
55
|
},
|
|
56
|
+
"./continuity.js": {
|
|
57
|
+
"types": "./dist/continuity.d.ts",
|
|
58
|
+
"default": "./dist/continuity.js"
|
|
59
|
+
},
|
|
60
|
+
"./handoffs.js": {
|
|
61
|
+
"types": "./dist/handoffs.d.ts",
|
|
62
|
+
"default": "./dist/handoffs.js"
|
|
63
|
+
},
|
|
56
64
|
"./session-lifecycle.js": {
|
|
57
65
|
"types": "./dist/sessionLifecycle.d.ts",
|
|
58
66
|
"default": "./dist/sessionLifecycle.js"
|
|
59
67
|
},
|
|
68
|
+
"./session-identity.js": {
|
|
69
|
+
"types": "./dist/sessionIdentity.d.ts",
|
|
70
|
+
"default": "./dist/sessionIdentity.js"
|
|
71
|
+
},
|
|
72
|
+
"./session-availability.js": {
|
|
73
|
+
"types": "./dist/sessionAvailability.d.ts",
|
|
74
|
+
"default": "./dist/sessionAvailability.js"
|
|
75
|
+
},
|
|
76
|
+
"./branch-prefix.js": {
|
|
77
|
+
"types": "./dist/branch/prefix.d.ts",
|
|
78
|
+
"default": "./dist/branch/prefix.js"
|
|
79
|
+
},
|
|
60
80
|
"./operation-recovery.js": {
|
|
61
81
|
"types": "./dist/operationRecovery.d.ts",
|
|
62
82
|
"default": "./dist/operationRecovery.js"
|
|
@@ -77,6 +97,10 @@
|
|
|
77
97
|
"types": "./dist/tickets/reset.d.ts",
|
|
78
98
|
"default": "./dist/tickets/reset.js"
|
|
79
99
|
},
|
|
100
|
+
"./ticket-groups.js": {
|
|
101
|
+
"types": "./dist/tickets/groups.d.ts",
|
|
102
|
+
"default": "./dist/tickets/groups.js"
|
|
103
|
+
},
|
|
80
104
|
"./sources/source-registry.js": {
|
|
81
105
|
"types": "./dist/sources/sourceRegistry.d.ts",
|
|
82
106
|
"default": "./dist/sources/sourceRegistry.js"
|
|
@@ -96,6 +120,10 @@
|
|
|
96
120
|
"./cli/doctor.js": {
|
|
97
121
|
"types": "./dist/cli/doctor.d.ts",
|
|
98
122
|
"default": "./dist/cli/doctor.js"
|
|
123
|
+
},
|
|
124
|
+
"./cli/handoffs.js": {
|
|
125
|
+
"types": "./dist/cli/handoffs.d.ts",
|
|
126
|
+
"default": "./dist/cli/handoffs.js"
|
|
99
127
|
}
|
|
100
128
|
},
|
|
101
129
|
"files": [
|
|
@@ -130,8 +158,8 @@
|
|
|
130
158
|
"@anthropic-ai/claude-agent-sdk": "^0.3.148"
|
|
131
159
|
},
|
|
132
160
|
"dependencies": {
|
|
133
|
-
"special-agents": "0.8.
|
|
134
|
-
"rafi-spec": "0.8.
|
|
161
|
+
"special-agents": "0.8.5",
|
|
162
|
+
"rafi-spec": "0.8.5",
|
|
135
163
|
"@clack/prompts": "^1.4.0",
|
|
136
164
|
"ajv": "^8.20.0",
|
|
137
165
|
"ajv-formats": "^3.0.1",
|