omp-conductor 0.3.25 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +733 -30
- package/package.json +1 -1
- package/src/board.ts +24 -5
- package/src/briefs/orchestrator.md +106 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +155 -2
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1000 -32
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +71 -3
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +42 -19
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +506 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +847 -10
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +202 -109
- package/systemd/omp-conductor.service.example +96 -8
package/src/store.ts
CHANGED
|
@@ -21,9 +21,22 @@ import type {
|
|
|
21
21
|
FrictionKind,
|
|
22
22
|
FrictionObservation,
|
|
23
23
|
FrictionSignal,
|
|
24
|
+
MergeLock,
|
|
25
|
+
ReportDeliveryState,
|
|
26
|
+
ReportDraft,
|
|
27
|
+
ReportEnqueue,
|
|
28
|
+
ReportKind,
|
|
29
|
+
ReportRecord,
|
|
24
30
|
RunRecord,
|
|
25
31
|
RunState,
|
|
32
|
+
SessionRole,
|
|
33
|
+
SettlementFlag,
|
|
26
34
|
Store,
|
|
35
|
+
VerbDecision,
|
|
36
|
+
VerbLedgerDraft,
|
|
37
|
+
VerbLedgerEntry,
|
|
38
|
+
VerbName,
|
|
39
|
+
VerbRefusal,
|
|
27
40
|
} from "./types.ts";
|
|
28
41
|
|
|
29
42
|
/**
|
|
@@ -91,6 +104,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
91
104
|
startedAt: true,
|
|
92
105
|
endedAt: true,
|
|
93
106
|
lastError: true,
|
|
107
|
+
settlementFlags: true,
|
|
94
108
|
};
|
|
95
109
|
|
|
96
110
|
/** Everything SQLite will accept from us. */
|
|
@@ -118,6 +132,7 @@ interface RunRow {
|
|
|
118
132
|
startedAt: number;
|
|
119
133
|
endedAt: number | null;
|
|
120
134
|
lastError: string | null;
|
|
135
|
+
settlementFlags: string | null;
|
|
121
136
|
}
|
|
122
137
|
|
|
123
138
|
interface FrictionRollupRow {
|
|
@@ -136,6 +151,49 @@ interface FrictionSurfaceRow {
|
|
|
136
151
|
at: number;
|
|
137
152
|
}
|
|
138
153
|
|
|
154
|
+
/** The `reports` table exactly as SQLite hands it back. */
|
|
155
|
+
interface ReportRow {
|
|
156
|
+
id: string;
|
|
157
|
+
project: string;
|
|
158
|
+
kind: string;
|
|
159
|
+
body: string;
|
|
160
|
+
state: string;
|
|
161
|
+
attempts: number;
|
|
162
|
+
attemptId: string | null;
|
|
163
|
+
ambiguous: number;
|
|
164
|
+
dedupeKey: string | null;
|
|
165
|
+
createdAt: number;
|
|
166
|
+
updatedAt: number;
|
|
167
|
+
nextAttemptAt: number;
|
|
168
|
+
messageId: number | null;
|
|
169
|
+
deliveredAt: number | null;
|
|
170
|
+
lastError: string | null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The `verb_ledger` table exactly as SQLite hands it back (#126). */
|
|
174
|
+
interface VerbLedgerRow {
|
|
175
|
+
id: string;
|
|
176
|
+
project: string;
|
|
177
|
+
runId: string | null;
|
|
178
|
+
issue: number | null;
|
|
179
|
+
verb: string;
|
|
180
|
+
role: string;
|
|
181
|
+
args: string;
|
|
182
|
+
decision: string;
|
|
183
|
+
refusal: string | null;
|
|
184
|
+
detail: string;
|
|
185
|
+
sha: string | null;
|
|
186
|
+
at: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The `merge_locks` table exactly as SQLite hands it back (#126). */
|
|
190
|
+
interface MergeLockRow {
|
|
191
|
+
project: string;
|
|
192
|
+
holder: string;
|
|
193
|
+
prUrl: string;
|
|
194
|
+
at: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
139
197
|
const SCHEMA = `
|
|
140
198
|
CREATE TABLE IF NOT EXISTS runs (
|
|
141
199
|
id TEXT PRIMARY KEY,
|
|
@@ -157,7 +215,8 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
157
215
|
salvageAckAt INTEGER,
|
|
158
216
|
startedAt INTEGER NOT NULL,
|
|
159
217
|
endedAt INTEGER,
|
|
160
|
-
lastError TEXT
|
|
218
|
+
lastError TEXT,
|
|
219
|
+
settlementFlags TEXT
|
|
161
220
|
);
|
|
162
221
|
CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
|
|
163
222
|
CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
|
|
@@ -192,6 +251,72 @@ CREATE TABLE IF NOT EXISTS friction_surfaces (
|
|
|
192
251
|
at INTEGER NOT NULL,
|
|
193
252
|
PRIMARY KEY (project, kind)
|
|
194
253
|
);
|
|
254
|
+
|
|
255
|
+
-- The report outbox (#123). Deliberately NOT an extension of notifications:
|
|
256
|
+
-- that table's primary key is its dedupe key, so it cannot hold two attempts of
|
|
257
|
+
-- one report, and widening it would put escalation dedupe and report delivery
|
|
258
|
+
-- on the same row. This one is keyed by report id and carries delivery state,
|
|
259
|
+
-- the attempt in flight, and the message id Telegram actually returned.
|
|
260
|
+
--
|
|
261
|
+
-- The partial unique index is the digest guard: a digest:<day> key can exist at
|
|
262
|
+
-- most once per project, so "has today's digest been handed over?" is a question
|
|
263
|
+
-- the ledger answers rather than the model's memory of the last tick. Material
|
|
264
|
+
-- reports carry no key and are never suppressed.
|
|
265
|
+
CREATE TABLE IF NOT EXISTS reports (
|
|
266
|
+
id TEXT PRIMARY KEY,
|
|
267
|
+
project TEXT NOT NULL,
|
|
268
|
+
kind TEXT NOT NULL,
|
|
269
|
+
body TEXT NOT NULL,
|
|
270
|
+
state TEXT NOT NULL,
|
|
271
|
+
attempts INTEGER NOT NULL,
|
|
272
|
+
attemptId TEXT,
|
|
273
|
+
ambiguous INTEGER NOT NULL,
|
|
274
|
+
dedupeKey TEXT,
|
|
275
|
+
createdAt INTEGER NOT NULL,
|
|
276
|
+
updatedAt INTEGER NOT NULL,
|
|
277
|
+
nextAttemptAt INTEGER NOT NULL,
|
|
278
|
+
messageId INTEGER,
|
|
279
|
+
deliveredAt INTEGER,
|
|
280
|
+
lastError TEXT
|
|
281
|
+
);
|
|
282
|
+
CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
|
|
283
|
+
ON reports (project, dedupeKey) WHERE dedupeKey IS NOT NULL;
|
|
284
|
+
CREATE INDEX IF NOT EXISTS reports_project_state
|
|
285
|
+
ON reports (project, state, nextAttemptAt);
|
|
286
|
+
|
|
287
|
+
-- The action ledger for the conductor-owned mutation verbs (#126). Every
|
|
288
|
+
-- decided call lands here, refusals included: the question an escalation asks
|
|
289
|
+
-- is "what did this run try", and a table that only records what succeeded
|
|
290
|
+
-- cannot answer it. The 'args' column is the payload verbatim, so the row is
|
|
291
|
+
-- evidence rather than a summary of evidence.
|
|
292
|
+
CREATE TABLE IF NOT EXISTS verb_ledger (
|
|
293
|
+
id TEXT PRIMARY KEY,
|
|
294
|
+
project TEXT NOT NULL,
|
|
295
|
+
runId TEXT,
|
|
296
|
+
issue INTEGER,
|
|
297
|
+
verb TEXT NOT NULL,
|
|
298
|
+
role TEXT NOT NULL,
|
|
299
|
+
args TEXT NOT NULL,
|
|
300
|
+
decision TEXT NOT NULL,
|
|
301
|
+
refusal TEXT,
|
|
302
|
+
detail TEXT NOT NULL,
|
|
303
|
+
sha TEXT,
|
|
304
|
+
at INTEGER NOT NULL
|
|
305
|
+
);
|
|
306
|
+
CREATE INDEX IF NOT EXISTS verb_ledger_project_at ON verb_ledger (project, at);
|
|
307
|
+
CREATE INDEX IF NOT EXISTS verb_ledger_run ON verb_ledger (runId, at);
|
|
308
|
+
|
|
309
|
+
-- One merge in flight per project (#126). A table rather than a mutex because
|
|
310
|
+
-- the CLI, the daemon and every session are separate processes: an in-process
|
|
311
|
+
-- lock serialises one process against itself and nothing else, which is the
|
|
312
|
+
-- shape "one merge in flight" had on main -- none at all. The project is the
|
|
313
|
+
-- primary key, so the claim is the row's existence and the race is SQLite's.
|
|
314
|
+
CREATE TABLE IF NOT EXISTS merge_locks (
|
|
315
|
+
project TEXT PRIMARY KEY,
|
|
316
|
+
holder TEXT NOT NULL,
|
|
317
|
+
prUrl TEXT NOT NULL,
|
|
318
|
+
at INTEGER NOT NULL
|
|
319
|
+
);
|
|
195
320
|
`;
|
|
196
321
|
|
|
197
322
|
/**
|
|
@@ -201,9 +326,41 @@ CREATE TABLE IF NOT EXISTS friction_surfaces (
|
|
|
201
326
|
function toSql(value: unknown): SqlValue {
|
|
202
327
|
if (value === undefined || value === null) return null;
|
|
203
328
|
if (typeof value === "boolean") return value ? 1 : 0;
|
|
329
|
+
// `RunRecord.settlementFlags` is the only structured field on a run row, and
|
|
330
|
+
// it is JSON in one TEXT column rather than a table of its own: the flags are
|
|
331
|
+
// 1:1 with the run, written once at settlement, and never read except beside
|
|
332
|
+
// the row they belong to. A join per `status` render would buy a query nobody
|
|
333
|
+
// makes (#128).
|
|
334
|
+
if (Array.isArray(value)) return JSON.stringify(value);
|
|
204
335
|
return value as SqlValue;
|
|
205
336
|
}
|
|
206
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Settlement flags out of their column, or undefined for anything that is not
|
|
340
|
+
* an array of flag-shaped objects.
|
|
341
|
+
*
|
|
342
|
+
* Same posture as {@link toDispatchSummary}: this is advisory evidence, so a
|
|
343
|
+
* row hand-edited or written by a future version drops its flags rather than
|
|
344
|
+
* throwing and taking `status` down with it.
|
|
345
|
+
*/
|
|
346
|
+
function toSettlementFlags(text: string): SettlementFlag[] | undefined {
|
|
347
|
+
try {
|
|
348
|
+
const value: unknown = JSON.parse(text);
|
|
349
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
350
|
+
const flags = value.filter(
|
|
351
|
+
(flag): flag is SettlementFlag =>
|
|
352
|
+
typeof flag === "object" &&
|
|
353
|
+
flag !== null &&
|
|
354
|
+
typeof (flag as SettlementFlag).kind === "string" &&
|
|
355
|
+
typeof (flag as SettlementFlag).file === "string" &&
|
|
356
|
+
typeof (flag as SettlementFlag).detail === "string",
|
|
357
|
+
);
|
|
358
|
+
return flags.length === 0 ? undefined : flags;
|
|
359
|
+
} catch {
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
207
364
|
/**
|
|
208
365
|
* A NULL column becomes an absent property rather than an `undefined` one, so
|
|
209
366
|
* a record read back out of the store deep-equals the one that went in.
|
|
@@ -231,9 +388,82 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
231
388
|
if (row.salvageAckAt !== null) record.salvageAckAt = row.salvageAckAt;
|
|
232
389
|
if (row.endedAt !== null) record.endedAt = row.endedAt;
|
|
233
390
|
if (row.lastError !== null) record.lastError = row.lastError;
|
|
391
|
+
if (row.settlementFlags !== null) {
|
|
392
|
+
const flags = toSettlementFlags(row.settlementFlags);
|
|
393
|
+
if (flags !== undefined) record.settlementFlags = flags;
|
|
394
|
+
}
|
|
234
395
|
return record;
|
|
235
396
|
}
|
|
236
397
|
|
|
398
|
+
/**
|
|
399
|
+
* NULL columns become absent properties, matching {@link toRecord}: a report
|
|
400
|
+
* read back out of the store deep-equals the one that went in.
|
|
401
|
+
*/
|
|
402
|
+
function toReport(row: ReportRow): ReportRecord {
|
|
403
|
+
const record: ReportRecord = {
|
|
404
|
+
id: row.id,
|
|
405
|
+
project: row.project,
|
|
406
|
+
kind: row.kind as ReportKind,
|
|
407
|
+
body: row.body,
|
|
408
|
+
state: row.state as ReportDeliveryState,
|
|
409
|
+
attempts: row.attempts,
|
|
410
|
+
ambiguous: row.ambiguous !== 0,
|
|
411
|
+
createdAt: row.createdAt,
|
|
412
|
+
updatedAt: row.updatedAt,
|
|
413
|
+
nextAttemptAt: row.nextAttemptAt,
|
|
414
|
+
};
|
|
415
|
+
if (row.attemptId !== null) record.attemptId = row.attemptId;
|
|
416
|
+
if (row.dedupeKey !== null) record.dedupeKey = row.dedupeKey;
|
|
417
|
+
if (row.messageId !== null) record.messageId = row.messageId;
|
|
418
|
+
if (row.deliveredAt !== null) record.deliveredAt = row.deliveredAt;
|
|
419
|
+
if (row.lastError !== null) record.lastError = row.lastError;
|
|
420
|
+
return record;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Long enough that a collision is not a thing anyone has to handle, short
|
|
425
|
+
* enough that an operator can compare it against the id printed in a Telegram
|
|
426
|
+
* message by eye — which is the only reconciliation this transport allows.
|
|
427
|
+
*/
|
|
428
|
+
function newReportId(): string {
|
|
429
|
+
return crypto.randomUUID().replaceAll("-", "").slice(0, 12);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* A ledger row, with the same NULL-becomes-absent posture as {@link toReport}.
|
|
434
|
+
*
|
|
435
|
+
* `args` is stored as JSON and read back defensively: a row whose payload no
|
|
436
|
+
* longer parses is still a row that says which verb was called and how it was
|
|
437
|
+
* decided, and losing the whole entry to a malformed blob would erase exactly
|
|
438
|
+
* the call somebody is trying to audit.
|
|
439
|
+
*/
|
|
440
|
+
function toVerbLedgerEntry(row: VerbLedgerRow): VerbLedgerEntry {
|
|
441
|
+
let args: Record<string, unknown> = {};
|
|
442
|
+
try {
|
|
443
|
+
const parsed: unknown = JSON.parse(row.args);
|
|
444
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
445
|
+
args = parsed as Record<string, unknown>;
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
args = { "(unreadable)": row.args };
|
|
449
|
+
}
|
|
450
|
+
const entry: VerbLedgerEntry = {
|
|
451
|
+
id: row.id,
|
|
452
|
+
project: row.project,
|
|
453
|
+
verb: row.verb as VerbName,
|
|
454
|
+
role: row.role as SessionRole,
|
|
455
|
+
args,
|
|
456
|
+
decision: row.decision as VerbDecision,
|
|
457
|
+
detail: row.detail,
|
|
458
|
+
at: row.at,
|
|
459
|
+
};
|
|
460
|
+
if (row.runId !== null) entry.runId = row.runId;
|
|
461
|
+
if (row.issue !== null) entry.issue = row.issue;
|
|
462
|
+
if (row.refusal !== null) entry.refusal = row.refusal as VerbRefusal;
|
|
463
|
+
if (row.sha !== null) entry.sha = row.sha;
|
|
464
|
+
return entry;
|
|
465
|
+
}
|
|
466
|
+
|
|
237
467
|
function toDispatchSummary(text: string): DispatchSummary | undefined {
|
|
238
468
|
try {
|
|
239
469
|
const value = JSON.parse(text) as DispatchSummary;
|
|
@@ -350,13 +580,20 @@ export function openStore(dbPath: string): Store {
|
|
|
350
580
|
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
351
581
|
}
|
|
352
582
|
}
|
|
583
|
+
// Rows written before the settlement audit existed (#128) were never audited,
|
|
584
|
+
// so a NULL here means "no audit ran" and reads the same as "found nothing".
|
|
585
|
+
// That conflation is deliberate and harmless: the flags are advisory, and no
|
|
586
|
+
// decision anywhere is taken on their absence.
|
|
587
|
+
if (!columns.some((column) => column.name === "settlementFlags")) {
|
|
588
|
+
db.exec("ALTER TABLE runs ADD COLUMN settlementFlags TEXT");
|
|
589
|
+
}
|
|
353
590
|
|
|
354
591
|
const insertRun = db.query<unknown, SqlValue[]>(
|
|
355
592
|
`INSERT INTO runs (
|
|
356
593
|
id, project, issue, repo, branch, worktree, state, attempt, turns,
|
|
357
594
|
maxTurns, spendUsd, sessionFile, prUrl, headSha, salvageSha, salvageError,
|
|
358
|
-
salvageAckAt, startedAt, endedAt, lastError
|
|
359
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
595
|
+
salvageAckAt, startedAt, endedAt, lastError, settlementFlags
|
|
596
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
360
597
|
);
|
|
361
598
|
const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
|
|
362
599
|
const selectActive = db.query<RunRow, SqlValue[]>(
|
|
@@ -474,6 +711,106 @@ export function openStore(dbPath: string): Store {
|
|
|
474
711
|
ON CONFLICT(project, kind) DO UPDATE SET at = excluded.at`,
|
|
475
712
|
);
|
|
476
713
|
|
|
714
|
+
// The report outbox (#123). Every terminal transition names the attempt it is
|
|
715
|
+
// settling, because a request can come back *after* the stale-`sending` sweep
|
|
716
|
+
// has already reclaimed its row — and a late answer must not overwrite a
|
|
717
|
+
// newer attempt's outcome. That predicate is also what stops one report being
|
|
718
|
+
// concurrently in flight twice: only one claim can move a `pending` row.
|
|
719
|
+
const insertReport = db.query<unknown, SqlValue[]>(
|
|
720
|
+
`INSERT OR IGNORE INTO reports (
|
|
721
|
+
id, project, kind, body, state, attempts, attemptId, ambiguous, dedupeKey,
|
|
722
|
+
createdAt, updatedAt, nextAttemptAt, messageId, deliveredAt, lastError
|
|
723
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
724
|
+
);
|
|
725
|
+
const selectReport = db.query<ReportRow, [string]>(`SELECT * FROM reports WHERE id = ?`);
|
|
726
|
+
const selectReportByDedupe = db.query<ReportRow, [string, string]>(
|
|
727
|
+
`SELECT * FROM reports WHERE project = ? AND dedupeKey = ?`,
|
|
728
|
+
);
|
|
729
|
+
const selectDueReports = db.query<ReportRow, [string, number, number]>(
|
|
730
|
+
`SELECT * FROM reports
|
|
731
|
+
WHERE project = ? AND state = 'pending' AND nextAttemptAt <= ?
|
|
732
|
+
ORDER BY createdAt ASC, rowid ASC
|
|
733
|
+
LIMIT ?`,
|
|
734
|
+
);
|
|
735
|
+
const claimReportRow = db.query<unknown, [string, number, string, number]>(
|
|
736
|
+
`UPDATE reports
|
|
737
|
+
SET state = 'sending', attemptId = ?, attempts = attempts + 1, updatedAt = ?
|
|
738
|
+
WHERE id = ? AND state = 'pending' AND nextAttemptAt <= ?`,
|
|
739
|
+
);
|
|
740
|
+
const deliverReportRow = db.query<unknown, SqlValue[]>(
|
|
741
|
+
`UPDATE reports
|
|
742
|
+
SET state = 'delivered', attemptId = NULL, messageId = ?, deliveredAt = ?,
|
|
743
|
+
updatedAt = ?, lastError = NULL
|
|
744
|
+
WHERE id = ? AND state = 'sending' AND attemptId = ?`,
|
|
745
|
+
);
|
|
746
|
+
const requeueReportRow = db.query<unknown, [number, string, number, string, string]>(
|
|
747
|
+
`UPDATE reports
|
|
748
|
+
SET state = 'pending', attemptId = NULL, nextAttemptAt = ?, lastError = ?, updatedAt = ?
|
|
749
|
+
WHERE id = ? AND state = 'sending' AND attemptId = ?`,
|
|
750
|
+
);
|
|
751
|
+
// Stays `sending` and keeps its attempt id: the request is over but its
|
|
752
|
+
// outcome is not known, which is precisely what `sending` means. `updatedAt`
|
|
753
|
+
// is left alone so the stale window keeps running from when the request left.
|
|
754
|
+
const uncertainReportRow = db.query<unknown, [string, string, string]>(
|
|
755
|
+
`UPDATE reports
|
|
756
|
+
SET ambiguous = 1, lastError = ?
|
|
757
|
+
WHERE id = ? AND state = 'sending' AND attemptId = ?`,
|
|
758
|
+
);
|
|
759
|
+
const failReportRow = db.query<unknown, [string, number, string, string]>(
|
|
760
|
+
`UPDATE reports
|
|
761
|
+
SET state = 'failed', attemptId = NULL, lastError = ?, updatedAt = ?
|
|
762
|
+
WHERE id = ? AND state = 'sending' AND attemptId = ?`,
|
|
763
|
+
);
|
|
764
|
+
const selectStaleSending = db.query<ReportRow, [string, number]>(
|
|
765
|
+
`SELECT * FROM reports
|
|
766
|
+
WHERE project = ? AND state = 'sending' AND updatedAt <= ?
|
|
767
|
+
ORDER BY createdAt ASC, rowid ASC`,
|
|
768
|
+
);
|
|
769
|
+
const recoverSendingRows = db.query<unknown, [number, number, string, number]>(
|
|
770
|
+
`UPDATE reports
|
|
771
|
+
SET state = 'pending', attemptId = NULL, ambiguous = 1, nextAttemptAt = ?, updatedAt = ?
|
|
772
|
+
WHERE project = ? AND state = 'sending' AND updatedAt <= ?`,
|
|
773
|
+
);
|
|
774
|
+
const selectOpenReports = db.query<ReportRow, [string]>(
|
|
775
|
+
`SELECT * FROM reports
|
|
776
|
+
WHERE project = ? AND state IN ('pending', 'sending', 'failed')
|
|
777
|
+
ORDER BY createdAt ASC, rowid ASC`,
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
const insertVerbLedger = db.query<unknown, SqlValue[]>(
|
|
781
|
+
`INSERT INTO verb_ledger
|
|
782
|
+
(id, project, runId, issue, verb, role, args, decision, refusal, detail, sha, at)
|
|
783
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
784
|
+
);
|
|
785
|
+
const selectVerbLedger = db.query<VerbLedgerRow, [string, string, string, number, number, number]>(
|
|
786
|
+
`SELECT * FROM verb_ledger
|
|
787
|
+
WHERE project = ?
|
|
788
|
+
AND (? = '' OR runId = ?)
|
|
789
|
+
AND (? = 0 OR issue = ?)
|
|
790
|
+
ORDER BY at DESC, rowid DESC
|
|
791
|
+
LIMIT ?`,
|
|
792
|
+
);
|
|
793
|
+
// Breaking a stale claim and taking it must be one transaction, or two
|
|
794
|
+
// daemons both see the stale row, both delete it, and both insert.
|
|
795
|
+
const breakStaleMergeLock = db.query<unknown, [string, number]>(
|
|
796
|
+
`DELETE FROM merge_locks WHERE project = ? AND at <= ?`,
|
|
797
|
+
);
|
|
798
|
+
const claimMergeLock = db.query<unknown, [string, string, string, number]>(
|
|
799
|
+
`INSERT OR IGNORE INTO merge_locks (project, holder, prUrl, at) VALUES (?, ?, ?, ?)`,
|
|
800
|
+
);
|
|
801
|
+
const dropMergeLock = db.query<unknown, [string, string]>(
|
|
802
|
+
`DELETE FROM merge_locks WHERE project = ? AND holder = ?`,
|
|
803
|
+
);
|
|
804
|
+
const selectMergeLock = db.query<MergeLockRow, [string]>(
|
|
805
|
+
`SELECT * FROM merge_locks WHERE project = ?`,
|
|
806
|
+
);
|
|
807
|
+
const takeMergeLock = db.transaction(
|
|
808
|
+
(project: string, holder: string, prUrl: string, now: number, staleAt: number): boolean => {
|
|
809
|
+
breakStaleMergeLock.run(project, staleAt);
|
|
810
|
+
return claimMergeLock.run(project, holder, prUrl, now).changes > 0;
|
|
811
|
+
},
|
|
812
|
+
);
|
|
813
|
+
|
|
477
814
|
const recordFriction = (project: string, observation: FrictionObservation): void => {
|
|
478
815
|
if (
|
|
479
816
|
!Number.isSafeInteger(observation.occurrences) ||
|
|
@@ -535,6 +872,7 @@ export function openStore(dbPath: string): Store {
|
|
|
535
872
|
record.startedAt,
|
|
536
873
|
toSql(record.endedAt),
|
|
537
874
|
toSql(record.lastError),
|
|
875
|
+
toSql(record.settlementFlags),
|
|
538
876
|
);
|
|
539
877
|
return record;
|
|
540
878
|
},
|
|
@@ -702,6 +1040,171 @@ export function openStore(dbPath: string): Store {
|
|
|
702
1040
|
for (const kind of new Set(kinds)) upsertFrictionSurface.run(project, kind, at);
|
|
703
1041
|
},
|
|
704
1042
|
|
|
1043
|
+
enqueueReport(draft: ReportDraft): ReportEnqueue {
|
|
1044
|
+
const report: ReportRecord = {
|
|
1045
|
+
id: newReportId(),
|
|
1046
|
+
project: draft.project,
|
|
1047
|
+
kind: draft.kind,
|
|
1048
|
+
body: draft.body,
|
|
1049
|
+
state: "pending",
|
|
1050
|
+
attempts: 0,
|
|
1051
|
+
ambiguous: false,
|
|
1052
|
+
...(draft.dedupeKey === undefined ? {} : { dedupeKey: draft.dedupeKey }),
|
|
1053
|
+
createdAt: draft.at,
|
|
1054
|
+
updatedAt: draft.at,
|
|
1055
|
+
// Due immediately. A backoff nobody has earned yet would delay the
|
|
1056
|
+
// first attempt at a report an operator is already waiting for.
|
|
1057
|
+
nextAttemptAt: draft.at,
|
|
1058
|
+
};
|
|
1059
|
+
const inserted = insertReport.run(
|
|
1060
|
+
report.id,
|
|
1061
|
+
report.project,
|
|
1062
|
+
report.kind,
|
|
1063
|
+
report.body,
|
|
1064
|
+
report.state,
|
|
1065
|
+
report.attempts,
|
|
1066
|
+
null,
|
|
1067
|
+
0,
|
|
1068
|
+
toSql(draft.dedupeKey),
|
|
1069
|
+
report.createdAt,
|
|
1070
|
+
report.updatedAt,
|
|
1071
|
+
report.nextAttemptAt,
|
|
1072
|
+
null,
|
|
1073
|
+
null,
|
|
1074
|
+
null,
|
|
1075
|
+
);
|
|
1076
|
+
if (inserted.changes > 0) return { report, deduped: false };
|
|
1077
|
+
// `INSERT OR IGNORE` swallowed a conflict, and the only conflict this
|
|
1078
|
+
// table has is the digest guard — a 48-bit id collision is not a thing
|
|
1079
|
+
// anyone needs to handle. Return the row that won rather than the one
|
|
1080
|
+
// that lost, so whoever handed the digest over is told the truth about
|
|
1081
|
+
// which report their operator will actually receive.
|
|
1082
|
+
const existing =
|
|
1083
|
+
draft.dedupeKey === undefined ? null : selectReportByDedupe.get(draft.project, draft.dedupeKey);
|
|
1084
|
+
if (existing === null) {
|
|
1085
|
+
throw new Error(`report ${report.id} could not be enqueued for ${draft.project}`);
|
|
1086
|
+
}
|
|
1087
|
+
return { report: toReport(existing), deduped: true };
|
|
1088
|
+
},
|
|
1089
|
+
|
|
1090
|
+
getReport(id: string): ReportRecord | undefined {
|
|
1091
|
+
const row = selectReport.get(id);
|
|
1092
|
+
return row === null ? undefined : toReport(row);
|
|
1093
|
+
},
|
|
1094
|
+
|
|
1095
|
+
dueReports(project: string, now: number, limit: number): ReportRecord[] {
|
|
1096
|
+
return selectDueReports.all(project, now, limit).map(toReport);
|
|
1097
|
+
},
|
|
1098
|
+
|
|
1099
|
+
claimReport(id: string, attemptId: string, at: number): ReportRecord | undefined {
|
|
1100
|
+
// The row is written `sending` *before* the caller's request leaves, so a
|
|
1101
|
+
// crash in the window leaves an explicitly ambiguous row rather than a
|
|
1102
|
+
// silently lost one. Losing the race is normal, not an error: it means
|
|
1103
|
+
// another pass already owns this attempt.
|
|
1104
|
+
if (claimReportRow.run(attemptId, at, id, at).changes === 0) return undefined;
|
|
1105
|
+
const row = selectReport.get(id);
|
|
1106
|
+
return row === null ? undefined : toReport(row);
|
|
1107
|
+
},
|
|
1108
|
+
|
|
1109
|
+
markReportDelivered(
|
|
1110
|
+
id: string,
|
|
1111
|
+
attemptId: string,
|
|
1112
|
+
messageId: number | undefined,
|
|
1113
|
+
at: number,
|
|
1114
|
+
): boolean {
|
|
1115
|
+
return (
|
|
1116
|
+
deliverReportRow.run(toSql(messageId), at, at, id, attemptId).changes > 0
|
|
1117
|
+
);
|
|
1118
|
+
},
|
|
1119
|
+
|
|
1120
|
+
markReportUncertain(id: string, attemptId: string, error: string): boolean {
|
|
1121
|
+
return uncertainReportRow.run(error, id, attemptId).changes > 0;
|
|
1122
|
+
},
|
|
1123
|
+
|
|
1124
|
+
markReportPending(
|
|
1125
|
+
id: string,
|
|
1126
|
+
attemptId: string,
|
|
1127
|
+
nextAttemptAt: number,
|
|
1128
|
+
error: string,
|
|
1129
|
+
at: number,
|
|
1130
|
+
): boolean {
|
|
1131
|
+
return requeueReportRow.run(nextAttemptAt, error, at, id, attemptId).changes > 0;
|
|
1132
|
+
},
|
|
1133
|
+
|
|
1134
|
+
markReportFailed(id: string, attemptId: string, error: string, at: number): boolean {
|
|
1135
|
+
return failReportRow.run(error, at, id, attemptId).changes > 0;
|
|
1136
|
+
},
|
|
1137
|
+
|
|
1138
|
+
recoverSendingReports(project: string, staleAt: number, at: number): ReportRecord[] {
|
|
1139
|
+
const stale = selectStaleSending.all(project, staleAt);
|
|
1140
|
+
if (stale.length === 0) return [];
|
|
1141
|
+
recoverSendingRows.run(at, at, project, staleAt);
|
|
1142
|
+
// Re-read rather than patching the pre-update rows: the caller logs and
|
|
1143
|
+
// renders these, and a row that says `sending` while the ledger says
|
|
1144
|
+
// `pending` is exactly the kind of quiet disagreement #123 is about.
|
|
1145
|
+
return stale
|
|
1146
|
+
.map((row) => selectReport.get(row.id))
|
|
1147
|
+
.filter((row): row is ReportRow => row !== null)
|
|
1148
|
+
.map(toReport);
|
|
1149
|
+
},
|
|
1150
|
+
|
|
1151
|
+
openReports(project: string): ReportRecord[] {
|
|
1152
|
+
return selectOpenReports.all(project).map(toReport);
|
|
1153
|
+
},
|
|
1154
|
+
|
|
1155
|
+
appendVerbLedger(draft: VerbLedgerDraft): VerbLedgerEntry {
|
|
1156
|
+
const entry: VerbLedgerEntry = { ...draft, id: crypto.randomUUID(), at: Date.now() };
|
|
1157
|
+
insertVerbLedger.run(
|
|
1158
|
+
entry.id,
|
|
1159
|
+
entry.project,
|
|
1160
|
+
toSql(entry.runId),
|
|
1161
|
+
toSql(entry.issue),
|
|
1162
|
+
entry.verb,
|
|
1163
|
+
entry.role,
|
|
1164
|
+
JSON.stringify(entry.args),
|
|
1165
|
+
entry.decision,
|
|
1166
|
+
toSql(entry.refusal),
|
|
1167
|
+
entry.detail,
|
|
1168
|
+
toSql(entry.sha),
|
|
1169
|
+
entry.at,
|
|
1170
|
+
);
|
|
1171
|
+
return entry;
|
|
1172
|
+
},
|
|
1173
|
+
|
|
1174
|
+
verbLedger(
|
|
1175
|
+
project: string,
|
|
1176
|
+
opts: { runId?: string; issue?: number; limit?: number } = {},
|
|
1177
|
+
): VerbLedgerEntry[] {
|
|
1178
|
+
// Sentinels rather than a composed SQL string: the filters are optional,
|
|
1179
|
+
// and building the WHERE clause by concatenation is how a project name
|
|
1180
|
+
// ends up interpolated into a query in a package that otherwise binds
|
|
1181
|
+
// every value (see `UPDATABLE_COLUMNS` for the one place that cannot).
|
|
1182
|
+
const runId = opts.runId ?? "";
|
|
1183
|
+
const issue = opts.issue ?? 0;
|
|
1184
|
+
const limit = opts.limit ?? 50;
|
|
1185
|
+
return selectVerbLedger.all(project, runId, runId, issue, issue, limit).map(toVerbLedgerEntry);
|
|
1186
|
+
},
|
|
1187
|
+
|
|
1188
|
+
acquireMergeLock(
|
|
1189
|
+
project: string,
|
|
1190
|
+
holder: string,
|
|
1191
|
+
prUrl: string,
|
|
1192
|
+
now: number,
|
|
1193
|
+
staleAfterMs: number,
|
|
1194
|
+
): MergeLock | undefined {
|
|
1195
|
+
if (!takeMergeLock(project, holder, prUrl, now, now - staleAfterMs)) return undefined;
|
|
1196
|
+
return { project, holder, prUrl, at: now };
|
|
1197
|
+
},
|
|
1198
|
+
|
|
1199
|
+
releaseMergeLock(project: string, holder: string): void {
|
|
1200
|
+
dropMergeLock.run(project, holder);
|
|
1201
|
+
},
|
|
1202
|
+
|
|
1203
|
+
mergeLock(project: string): MergeLock | undefined {
|
|
1204
|
+
const row = selectMergeLock.get(project);
|
|
1205
|
+
return row === null ? undefined : { ...row };
|
|
1206
|
+
},
|
|
1207
|
+
|
|
705
1208
|
close(): void {
|
|
706
1209
|
db.close(false);
|
|
707
1210
|
},
|
package/src/tracker/github.ts
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
* daemon never handles a token itself: credentials stay in the user's keychain
|
|
6
6
|
* or `gh` config and are never passed as argv, written to a file, or logged.
|
|
7
7
|
*
|
|
8
|
+
* That reachability is exactly what #125 confines. The environment this adapter
|
|
9
|
+
* hands `gh` comes from `credentialedEnv()` — the one named construction site
|
|
10
|
+
* of credential material in the daemon — so "which processes can reach the
|
|
11
|
+
* operator's GitHub write credential" is answered by that function's call
|
|
12
|
+
* sites, not by auditing every spawn in the tree. Sessions get the opposite
|
|
13
|
+
* environment, and on a `per-run` host they are a different OS principal
|
|
14
|
+
* entirely and could not use this credential even if they found it.
|
|
15
|
+
*
|
|
8
16
|
* ponytail: shelling out to `gh` is the deliberate simplification. The ceiling
|
|
9
17
|
* is per-call cost (one process spawn plus one TLS handshake per operation,
|
|
10
18
|
* ~200-400ms) and failure classification by matching human-readable stderr
|
|
@@ -13,9 +21,12 @@
|
|
|
13
21
|
* `gh auth token`; the eleven Tracker methods above it stay untouched.
|
|
14
22
|
*/
|
|
15
23
|
|
|
24
|
+
import { credentialedEnv } from "../credentials.ts";
|
|
25
|
+
import { parsePrDiff } from "../diff-flags.ts";
|
|
16
26
|
import type {
|
|
17
27
|
IssueState,
|
|
18
28
|
OpenCloser,
|
|
29
|
+
PrDiff,
|
|
19
30
|
PrState,
|
|
20
31
|
PrVerification,
|
|
21
32
|
ProjectConfig,
|
|
@@ -129,6 +140,11 @@ async function gh(argv: string[], stdin?: string): Promise<string> {
|
|
|
129
140
|
stdin: new Blob([stdin ?? ""]),
|
|
130
141
|
stdout: "pipe",
|
|
131
142
|
stderr: "pipe",
|
|
143
|
+
// The privileged side of the boundary (#125). Explicit rather than
|
|
144
|
+
// inherited so that adding a credential-scrubbing default to this process
|
|
145
|
+
// could never silently break the tracker, and so that the grep for
|
|
146
|
+
// `credentialedEnv` finds this call.
|
|
147
|
+
env: credentialedEnv(),
|
|
132
148
|
});
|
|
133
149
|
|
|
134
150
|
const [stdout, stderr, code] = await Promise.all([
|
|
@@ -208,6 +224,17 @@ export function firstOpenCloser(raw: string): OpenCloser | undefined {
|
|
|
208
224
|
*/
|
|
209
225
|
const PR_URL = /^https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+\/?$/;
|
|
210
226
|
|
|
227
|
+
/**
|
|
228
|
+
* How much of a pull request's diff the settlement audit will hold in memory.
|
|
229
|
+
*
|
|
230
|
+
* The daemon runs every worker in its own process, so an unbounded read of a
|
|
231
|
+
* PR that regenerated a 90 MB lockfile would be paid for out of the same
|
|
232
|
+
* resident set the live workers are using. Truncation only ever loses trailing
|
|
233
|
+
* files, which costs findings and never invents them, and the parsed diff
|
|
234
|
+
* carries `truncated` so the report never presents a short read as a clean one.
|
|
235
|
+
*/
|
|
236
|
+
const MAX_DIFF_BYTES = 4 * 1024 * 1024;
|
|
237
|
+
|
|
211
238
|
type CheckVerdict = PrVerification["status"];
|
|
212
239
|
|
|
213
240
|
function checkName(check: GhCheck): string {
|
|
@@ -528,5 +555,23 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
|
528
555
|
return undefined;
|
|
529
556
|
}
|
|
530
557
|
},
|
|
558
|
+
|
|
559
|
+
async prDiff(url: string): Promise<PrDiff | undefined> {
|
|
560
|
+
if (!PR_URL.test(url)) return undefined;
|
|
561
|
+
try {
|
|
562
|
+
// `--color never` rather than relying on the default: `auto` is decided
|
|
563
|
+
// from the child's stdout, and this adapter pipes it, but a diff with
|
|
564
|
+
// ANSI escapes in it would silently stop matching every pattern the
|
|
565
|
+
// audit looks for — a failure that looks exactly like a clean PR.
|
|
566
|
+
const raw = await runGh(["pr", "diff", url, "--color", "never"]);
|
|
567
|
+
return raw.length > MAX_DIFF_BYTES
|
|
568
|
+
? parsePrDiff(raw.slice(0, MAX_DIFF_BYTES), true)
|
|
569
|
+
: parsePrDiff(raw);
|
|
570
|
+
} catch {
|
|
571
|
+
// Advisory: an unreadable diff costs one unaudited settlement, never a
|
|
572
|
+
// run's state. Undefined is "could not tell", not "clean".
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
},
|
|
531
576
|
};
|
|
532
577
|
}
|