toolroll 0.9.51 → 0.9.52

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/dist/store.js CHANGED
@@ -1,6 +1,8 @@
1
+ import { reclaimDatabase } from "./database-reclaim.js";
1
2
  import { chatSchema, chatTables } from "./contracts/chat-tables.js";
2
3
  import { ServerTelemetry } from "./server-telemetry.js";
3
4
  import { instrumentDatabase, measureWriteWait } from "./sqlite-telemetry.js";
5
+ import { trackWorkspaceWrites } from "./workspace-revision.js";
4
6
  import { parseStoreColumn, readStoreColumn, readStoreStringifiedList, readStoreTextList } from "./contracts/store-json.js";
5
7
  import { assessmentFromSavedEvidence, verificationEvidence } from "./verification-evidence.js";
6
8
  import { LEARNING_SCHEMA, queueLearning } from "./project-learning.js";
@@ -582,7 +584,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
582
584
  // v111 keeps an API token's project limit and its rotation: the token that replaced it, and when the old one stops.
583
585
  // v112 keeps request budgets for API tokens: owner overrides (request_budget_limit) and saved usage (request_budget_usage).
584
586
  // v113 keeps MCP sign-ins (OAuth): registered clients, one-time codes, and each grant's binding to its API token.
585
- export const SCHEMA_VERSION = 113;
587
+ // v114 names one exact shape, migrated once: a settled run keeps one process summary instead of its exited witnesses,
588
+ // notification's single-destination delivery columns move into legacy receipts, and the workspace revision triggers are gone
589
+ // (the write wrapper bumps the revision). Every later DDL change bumps the version.
590
+ export const SCHEMA_VERSION = 114;
586
591
  /**
587
592
  * The migrations `toolroll update` may carry a database through in place: each
588
593
  * one only adds (a table, a column, an accepted value) and leaves every saved row
@@ -596,8 +601,13 @@ export const SCHEMA_VERSION = 113;
596
601
  * api_token columns (project limit, rotation); existing tokens read as before.
597
602
  * v112 only adds the two request-budget tables.
598
603
  * v113 adds MCP sign-in tables and API-token purpose metadata.
604
+ * v114 is the one declared exception to "only adds", and the rehearsal checks
605
+ * it as such: a settled run's exited process witnesses become one summary row
606
+ * (rows removed equal the witnesses summarized), and notification drops its
607
+ * delivery columns only after every value moves into one legacy receipt per row
608
+ * (rows and ids carried whole; legacy receipts added equal the rows carried).
599
609
  */
600
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([110, 111, 112, 113]);
610
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([110, 111, 112, 113, 114]);
601
611
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
602
612
  export function updateSafeSchema(version) {
603
613
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -2324,26 +2334,7 @@ CREATE TABLE IF NOT EXISTS incident (
2324
2334
  -- lives only in a process's memory dies with the process, at exactly the
2325
2335
  -- moment it was most worth sending. dedupe_key is an episode identity: a
2326
2336
  -- gap nags once per occurrence, not once per cron firing and not forever.
2327
- CREATE TABLE IF NOT EXISTS notification (
2328
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2329
- dedupe_key TEXT NOT NULL UNIQUE,
2330
- kind TEXT NOT NULL,
2331
- subject TEXT NOT NULL,
2332
- body TEXT NOT NULL,
2333
- created_at TEXT NOT NULL,
2334
- attempts INTEGER NOT NULL DEFAULT 0,
2335
- last_attempt_at TEXT,
2336
- last_error TEXT,
2337
- delivered_at TEXT,
2338
- -- What the delivery command said on success — the closest thing to a
2339
- -- provider receipt a shell command can hand back.
2340
- receipt TEXT,
2341
- -- The push surface (arc 3, v23): a CLOSED attention class and a
2342
- -- machine-minted console link, stamped by producers at enqueue.
2343
- -- Unstamped kinds never reach a phone; subject/body never do either.
2344
- push_class TEXT CHECK (push_class IN ('decision','pick','merge','attention')),
2345
- link TEXT
2346
- );
2337
+ ${NOTIFICATION_DDL("notification")};
2347
2338
 
2348
2339
  -- Away mode (v34, mate arc §10): the Telegram bridge's digest cadence.
2349
2340
  -- One row. every_ms NULL = off (every fact pages as it lands); set, the
@@ -2707,8 +2698,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS telegram_team_chat_live
2707
2698
  CREATE UNIQUE INDEX IF NOT EXISTS telegram_team_chat_group
2708
2699
  ON telegram_team_chat (conversation) WHERE revoked_at IS NULL AND kind = 'group';
2709
2700
 
2710
- -- v61: destination receipts around the existing outbox, independent of its
2711
- -- legacy shell/webhook receipt and of push_delivery. No second scheduler.
2701
+ -- v61: destination receipts around the existing outbox, independent of
2702
+ -- push_delivery: where each notification went, per destination (v114
2703
+ -- removed the old single-destination receipt). No second scheduler.
2712
2704
  CREATE TABLE IF NOT EXISTS notification_delivery (
2713
2705
  notification INTEGER NOT NULL REFERENCES notification(id) ON DELETE RESTRICT,
2714
2706
  destination TEXT NOT NULL,
@@ -3779,6 +3771,19 @@ CREATE TABLE IF NOT EXISTS run_process (
3779
3771
  container_identity TEXT
3780
3772
  );
3781
3773
  CREATE INDEX IF NOT EXISTS run_process_by_run ON run_process(run, exited_at);
3774
+ -- v114: a settled run (finished, every witness exited) keeps this one row
3775
+ -- instead of its exited witnesses. Its presence is the proof the run's
3776
+ -- processes were witnessed and all exited, so a run with neither witnesses
3777
+ -- nor a summary still reads as "exit unproven".
3778
+ CREATE TABLE IF NOT EXISTS run_process_summary (
3779
+ run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,
3780
+ witnesses INTEGER NOT NULL CHECK (witnesses > 0),
3781
+ process_groups INTEGER NOT NULL,
3782
+ first_observed_at TEXT NOT NULL,
3783
+ last_observed_at TEXT NOT NULL,
3784
+ last_exited_at TEXT NOT NULL,
3785
+ settled_at TEXT NOT NULL
3786
+ );
3782
3787
 
3783
3788
  CREATE INDEX IF NOT EXISTS task_by_state ON task (state);
3784
3789
  CREATE INDEX IF NOT EXISTS edge_by_blocker ON task_edge (blocker);
@@ -4071,6 +4076,12 @@ function initializeStore(db, file) {
4071
4076
  if (!tableExists(db, table))
4072
4077
  throw new Error(`${file}: Discord history is missing; refusing to recreate receipts`);
4073
4078
  }
4079
+ // A current file is this build's exact shape: the checks above read, the connection gets its settings, and no DDL
4080
+ // runs. Everything below runs once, for a fresh file or on the way up from an older version.
4081
+ if (preflight === SCHEMA_VERSION) {
4082
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
4083
+ return new Store(db);
4084
+ }
4074
4085
  if (preflight !== null && preflight > 0 && preflight < SCHEMA_VERSION) {
4075
4086
  const stamped = db.prepare("UPDATE schema_version SET version = ? WHERE version = ?").run(-preflight, preflight);
4076
4087
  if (Number(stamped.changes) !== 1) {
@@ -4281,6 +4292,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS one_blocked_revision_per_task
4281
4292
  ON plan_revision (task_ref) WHERE status = 'blocked';
4282
4293
  CREATE INDEX IF NOT EXISTS run_checkpoint_by_run ON run_checkpoint (run, id);
4283
4294
  CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, id);`);
4295
+ if (preflight !== null) {
4296
+ migrateToV114(db);
4297
+ // Keep the epoch until reclamation succeeds, including a retry after compaction already committed.
4298
+ reclaimDatabase(db, "migration");
4299
+ }
4284
4300
  // THE BOOKKEEPING WRITE, checked (raw authority repair): migrate() has
4285
4301
  // already done the work by the time this runs; the row is bookkeeping
4286
4302
  // about it, not the trigger for it — and it is written ONLY from the
@@ -4508,10 +4524,6 @@ function migrate(db, origin) {
4508
4524
  addColumn(db, "task_ref", "repo", "TEXT");
4509
4525
  addColumn(db, "claim", "released_by", "TEXT");
4510
4526
  addColumn(db, "notification", "resolved_at", "TEXT");
4511
- // Delivery claiming: two deliverers (the bridge, `outbox deliver`) must
4512
- // not both send one row. A claim is a short lease on the act of sending.
4513
- addColumn(db, "notification", "claim_owner", "TEXT");
4514
- addColumn(db, "notification", "claim_expires_at", "TEXT");
4515
4527
  addColumn(db, "approver", "generation", "INTEGER NOT NULL DEFAULT 1");
4516
4528
  // v77: a lead thread belongs to the lead conversation, a project or a task.
4517
4529
  addColumn(db, "mate_thread", "scope_kind", "TEXT NOT NULL DEFAULT 'lead' CHECK (scope_kind IN ('lead','project','task'))");
@@ -5471,6 +5483,93 @@ function reviewRequestOriginOf(row) {
5471
5483
  * cannot prove, and the upgrade refuses in words rather than binding a
5472
5484
  * retry allowance to it.
5473
5485
  */
5486
+ /**
5487
+ * v114, once, on the way up (the sentinel is already stamped): the workspace
5488
+ * revision triggers go (the write wrapper bumps the revision now), notification
5489
+ * is rebuilt without its delivery columns once their values are legacy receipts, and every settled
5490
+ * run's exited witnesses become its process summary. Each step is idempotent,
5491
+ * so a resumed upgrade finishes it.
5492
+ */
5493
+ function migrateToV114(db) {
5494
+ for (const row of db.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND name LIKE 'workspace_revision_v1_%'").all()) {
5495
+ db.exec(`DROP TRIGGER IF EXISTS "${String(row["name"])}"`);
5496
+ }
5497
+ const columns = db.prepare("PRAGMA table_info(notification)").all().map(row => String(row["name"]));
5498
+ if (columns.some(column => NOTIFICATION_DROPPED.includes(column))) {
5499
+ // Recognized by its columns, not its DDL text: older files reached this shape by different addColumn orders.
5500
+ const known = new Set([...NOTIFICATION_COLUMNS, ...NOTIFICATION_DROPPED]);
5501
+ const unknown = columns.filter(column => !known.has(column));
5502
+ if (unknown.length > 0 || NOTIFICATION_COLUMNS.some(column => !columns.includes(column))) {
5503
+ throw new Error(`the notification table's columns are not a shape this migration knows${unknown.length > 0 ? ` (${unknown.join(", ")})` : ""} — refusing to rebuild it`);
5504
+ }
5505
+ db.exec("PRAGMA foreign_keys = OFF");
5506
+ try {
5507
+ db.exec("BEGIN IMMEDIATE");
5508
+ try {
5509
+ // The AUTOINCREMENT bookkeeping comes back as it was (rebuildExact's rule): the counter, never the surviving
5510
+ // max id, in the original row order, and no row the predecessor never wrote.
5511
+ const sequence = db.prepare("SELECT name, seq FROM sqlite_sequence ORDER BY rowid").all();
5512
+ carryLegacyDelivery(db, columns);
5513
+ db.exec(NOTIFICATION_DDL("notification_next"));
5514
+ const names = NOTIFICATION_COLUMNS.join(", ");
5515
+ db.exec(`INSERT INTO notification_next (${names}) SELECT ${names} FROM notification ORDER BY id`);
5516
+ db.exec("DROP TABLE notification");
5517
+ db.exec("ALTER TABLE notification_next RENAME TO notification");
5518
+ db.exec("DELETE FROM sqlite_sequence");
5519
+ const restore = db.prepare("INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)");
5520
+ for (const row of sequence)
5521
+ restore.run(row.name, typeof row.seq === "bigint" ? row.seq : BigInt(row.seq));
5522
+ if (db.prepare("PRAGMA foreign_key_check").all().length > 0)
5523
+ throw new Error("foreign keys did not survive the notification rebuild");
5524
+ db.exec("COMMIT");
5525
+ }
5526
+ catch (error) {
5527
+ db.exec("ROLLBACK");
5528
+ throw error;
5529
+ }
5530
+ }
5531
+ finally {
5532
+ db.exec("PRAGMA foreign_keys = ON");
5533
+ }
5534
+ }
5535
+ if (db.prepare("SELECT 1 FROM run_process WHERE exited_at IS NOT NULL LIMIT 1").get() === undefined)
5536
+ return;
5537
+ db.exec("BEGIN IMMEDIATE");
5538
+ try {
5539
+ compactSettledRunProcesses(db, new Date());
5540
+ db.exec("COMMIT");
5541
+ }
5542
+ catch (error) {
5543
+ db.exec("ROLLBACK");
5544
+ throw error;
5545
+ }
5546
+ }
5547
+ /**
5548
+ * Settled custody, compacted (v114): a finished run whose every process
5549
+ * witness has exited keeps one run_process_summary row instead of them. A run
5550
+ * with any open witness keeps every row, so no custody fence ever reads less
5551
+ * than it did. Idempotent; the caller owns the transaction. Returns the
5552
+ * witnesses removed.
5553
+ */
5554
+ function compactSettledRunProcesses(db, now, only = null) {
5555
+ const runs = db.prepare(`SELECT p.run FROM run_process p JOIN run r ON r.id = p.run
5556
+ WHERE r.outcome IS NOT NULL AND r.finished_at IS NOT NULL AND (? IS NULL OR p.run = ?)
5557
+ GROUP BY p.run HAVING COUNT(*) = COUNT(p.exited_at)`).all(only, only).map(row => Number(row["run"]));
5558
+ if (runs.length === 0)
5559
+ return 0;
5560
+ const summarize = db.prepare(`INSERT INTO run_process_summary (run, witnesses, process_groups, first_observed_at, last_observed_at, last_exited_at, settled_at)
5561
+ SELECT run, COUNT(*), SUM(process_group), MIN(observed_at), MAX(observed_at), MAX(exited_at), ? FROM run_process WHERE run = ? GROUP BY run
5562
+ ON CONFLICT (run) DO UPDATE SET witnesses = witnesses + excluded.witnesses, process_groups = process_groups + excluded.process_groups,
5563
+ first_observed_at = MIN(first_observed_at, excluded.first_observed_at), last_observed_at = MAX(last_observed_at, excluded.last_observed_at),
5564
+ last_exited_at = MAX(last_exited_at, excluded.last_exited_at), settled_at = excluded.settled_at`);
5565
+ const remove = db.prepare("DELETE FROM run_process WHERE run = ? AND exited_at IS NOT NULL");
5566
+ let removed = 0;
5567
+ for (const run of runs) {
5568
+ summarize.run(now.toISOString(), run);
5569
+ removed += Number(remove.run(run).changes);
5570
+ }
5571
+ return removed;
5572
+ }
5474
5573
  function migrateToV50(db) {
5475
5574
  if (!tableExists(db, "run") || !tableExists(db, "review_request"))
5476
5575
  return;
@@ -5570,6 +5669,60 @@ function V51_ARTIFACT_DDL(name) {
5570
5669
  function V38_INCIDENT_DDL(name) {
5571
5670
  return V34_INCIDENT_DDL(name).replace("'plan-attempts-exhausted','malformed-report'", "'plan-attempts-exhausted','malformed-report','malformed-proof'");
5572
5671
  }
5672
+ /** The durable outbox (§6). A notification is a fact that something wants a
5673
+ * person, recorded next to the record that made it true. dedupe_key is an
5674
+ * episode identity: a gap nags once per occurrence. Where it went is
5675
+ * notification_delivery's, per destination (v114 moved the old
5676
+ * single-destination attempt, receipt and claim columns into legacy receipts). */
5677
+ function NOTIFICATION_DDL(name) {
5678
+ return `CREATE TABLE IF NOT EXISTS ${name} (
5679
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5680
+ dedupe_key TEXT NOT NULL UNIQUE,
5681
+ kind TEXT NOT NULL,
5682
+ subject TEXT NOT NULL,
5683
+ body TEXT NOT NULL,
5684
+ created_at TEXT NOT NULL,
5685
+ -- The push surface (arc 3, v23): a CLOSED attention class and a
5686
+ -- machine-minted console link, stamped by producers at enqueue.
5687
+ push_class TEXT CHECK (push_class IN ('decision','pick','merge','attention')),
5688
+ link TEXT,
5689
+ resolved_at TEXT,
5690
+ provenance_scope TEXT NOT NULL DEFAULT 'unknown' CHECK (provenance_scope IN ('unknown','installation','project','task')),
5691
+ project TEXT,
5692
+ task_ref INTEGER REFERENCES task_ref(id),
5693
+ task_id TEXT,
5694
+ source_run INTEGER REFERENCES run(id),
5695
+ recipient TEXT
5696
+ )`;
5697
+ }
5698
+ const NOTIFICATION_COLUMNS = ["id", "dedupe_key", "kind", "subject", "body", "created_at", "push_class", "link", "resolved_at",
5699
+ "provenance_scope", "project", "task_ref", "task_id", "source_run", "recipient"];
5700
+ /** What v114 drops from notification: the removed single-destination delivery's columns. Their values move first. */
5701
+ const NOTIFICATION_DROPPED = ["attempts", "last_attempt_at", "last_error", "delivered_at", "receipt", "claim_owner", "claim_expires_at"];
5702
+ /** The receipt v114 keeps a notification's old single-destination delivery under. No sender claims it: every real
5703
+ * destination is a kind and a 64-character hash, or a Telegram binding. */
5704
+ export const LEGACY_NOTIFICATION_DESTINATION = "legacy:single-destination";
5705
+ /**
5706
+ * v114, inside the notification rebuild's transaction: every row with any saved delivery value (attempts is never
5707
+ * null, so every row the old schema wrote) gets one legacy receipt holding exactly those values, then the copy is
5708
+ * proved field for field. A reserved receipt that differs, or one with nothing to carry, refuses the upgrade.
5709
+ */
5710
+ function carryLegacyDelivery(db, columns) {
5711
+ const present = NOTIFICATION_DROPPED.filter(column => columns.includes(column));
5712
+ if (present.length === 0)
5713
+ return;
5714
+ const value = (column) => present.includes(column) ? `n.${column}` : column === "attempts" ? "0" : "NULL";
5715
+ const populated = present.map(column => `n.${column} IS NOT NULL`).join(" OR ");
5716
+ db.prepare(`INSERT OR IGNORE INTO notification_delivery (notification, destination, ${NOTIFICATION_DROPPED.join(", ")})
5717
+ SELECT n.id, ?, ${NOTIFICATION_DROPPED.map(value).join(", ")} FROM notification n WHERE ${populated} ORDER BY n.id`).run(LEGACY_NOTIFICATION_DESTINATION);
5718
+ const expected = Number(db.prepare(`SELECT COUNT(*) AS n FROM notification n WHERE ${populated}`).get()?.["n"]);
5719
+ const matched = Number(db.prepare(`SELECT COUNT(*) AS n FROM notification n JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
5720
+ WHERE (${populated}) AND ${NOTIFICATION_DROPPED.map(column => `d.${column} IS ${value(column)}`).join(" AND ")}`).get(LEGACY_NOTIFICATION_DESTINATION)?.["n"]);
5721
+ const reserved = Number(db.prepare("SELECT COUNT(*) AS n FROM notification_delivery WHERE destination = ?").get(LEGACY_NOTIFICATION_DESTINATION)?.["n"]);
5722
+ if (matched !== expected || reserved !== expected) {
5723
+ throw new Error(`notification delivery history did not copy whole (${matched} of ${expected} rows match, ${reserved} legacy receipts) — refusing to drop its columns`);
5724
+ }
5725
+ }
5573
5726
  /**
5574
5727
  * One exact copy-rename (v4 review, finding 7 — substrings are not
5575
5728
  * recognizers): the stored DDL must equal the known old shape or the
@@ -7358,7 +7511,8 @@ export class Store {
7358
7511
  db;
7359
7512
  constructor(db) {
7360
7513
  this.telemetry = new ServerTelemetry();
7361
- this.db = instrumentDatabase(db, this.telemetry);
7514
+ // Outside in: telemetry, then the workspace revision (one bump per committed meaningful write), then SQLite.
7515
+ this.db = instrumentDatabase(trackWorkspaceWrites(db), this.telemetry);
7362
7516
  }
7363
7517
  /** TESTS ONLY: raw database access for migration fixtures. Production
7364
7518
  * code never calls this — the typed methods are the API. */
@@ -9621,11 +9775,28 @@ export class Store {
9621
9775
  return report;
9622
9776
  }
9623
9777
  /** A checkpoint of the chain as it stands: its head, recorded here and handed back to be copied off the machine.
9624
- * Never over a chain that doesn't verify: that would vouch for what broke it. */
9625
- ledgerCheckpoint(by, now) {
9778
+ * Never over a chain that doesn't verify: that would vouch for what broke it. `automatic` (before a backup, after
9779
+ * a monitoring delivery): at the verified head, or at `head` (one that was sent) once the walk reached it, only past
9780
+ * the newest checkpoint (null otherwise), and with no ledger entry of its own, so a checkpoint never makes new
9781
+ * activity for the next backup or delivery to checkpoint again. */
9782
+ ledgerCheckpoint(by, now, automatic) {
9626
9783
  const report = this.ledgerChain({ full: true });
9627
9784
  if (!report.ok)
9628
9785
  return { problem: report.problem?.what ?? "the chain doesn't verify" };
9786
+ if (automatic !== undefined) {
9787
+ const head = automatic.head ?? (report.through === null ? null : { through: report.through, hash: report.head });
9788
+ if (head === null || report.through === null || head.through > report.through)
9789
+ return null;
9790
+ return this.transact(() => {
9791
+ if (this.db.prepare("SELECT hash FROM ledger_seal WHERE id = ?").get(head.through)?.["hash"] !== head.hash)
9792
+ return { problem: `entry #${head.through} isn't sealed as ${head.hash.slice(0, 12)}` };
9793
+ const newest = this.db.prepare(`SELECT MAX(through) AS through FROM ledger_checkpoint WHERE typeof(through) = 'integer' AND through ${IN_RANGE}`).get()?.["through"];
9794
+ if (newest != null && Number(newest) >= head.through)
9795
+ return null;
9796
+ this.db.prepare("INSERT INTO ledger_checkpoint (through, hash, at, by) VALUES (?, ?, ?, ?)").run(head.through, head.hash, now.toISOString(), by);
9797
+ return head;
9798
+ });
9799
+ }
9629
9800
  return this.transact(() => {
9630
9801
  sealLedger(this.db);
9631
9802
  const last = this.db.prepare(`SELECT id, hash FROM ledger_seal WHERE id ${IN_RANGE} ORDER BY id DESC LIMIT 1`).get();
@@ -14073,6 +14244,11 @@ export class Store {
14073
14244
  recordProviderReadiness(runner, observations, now) {
14074
14245
  this.transact(() => {
14075
14246
  for (const one of observations) {
14247
+ // The same observation again only renews its time: a quiet write that moves no workspace revision.
14248
+ const renewed = this.db.prepare("UPDATE provider_readiness SET observed_at = ? WHERE runner = ? AND provider = ? AND state = ? AND reason = ? AND probe IS ?")
14249
+ .run(now.toISOString(), runner, one.provider, one.state, one.reason, one.probe);
14250
+ if (Number(renewed.changes) > 0)
14251
+ continue;
14076
14252
  this.db
14077
14253
  .prepare(`INSERT INTO provider_readiness (runner, provider, state, reason, probe, observed_at) VALUES (?, ?, ?, ?, ?, ?)
14078
14254
  ON CONFLICT (runner, provider) DO UPDATE SET state = excluded.state, reason = excluded.reason,
@@ -15766,37 +15942,28 @@ export class Store {
15766
15942
  return this.db.prepare("SELECT * FROM notification WHERE id>? ORDER BY id LIMIT ?").all(id, Math.max(1, Math.min(100, limit))).map(readNotification);
15767
15943
  }
15768
15944
  listNotifications(only = "pending") {
15769
- // Resolved-but-undelivered is not pending: a decision answered before the
15770
- // outbox ran is a fact that stopped wanting a person, and paging someone
15771
- // about it anyway would teach them to ignore the pager.
15772
- const where = only === "pending" ? "WHERE delivered_at IS NULL AND resolved_at IS NULL" : "";
15945
+ // Pending means unresolved: a decision answered is a fact that stopped
15946
+ // wanting a person. Delivery state lives per destination
15947
+ // (notification_delivery), not on the fact.
15948
+ const where = only === "pending" ? "WHERE resolved_at IS NULL" : "";
15773
15949
  return this.db
15774
15950
  .prepare(`SELECT * FROM notification ${where} ORDER BY id`)
15775
15951
  .all()
15776
15952
  .map(readNotification);
15777
15953
  }
15778
- recordDelivery(id, outcome, now) {
15779
- if (outcome.ok) {
15780
- this.db
15781
- .prepare(`UPDATE notification SET delivered_at = ?, receipt = ?, attempts = attempts + 1, last_attempt_at = ?, last_error = NULL
15782
- WHERE id = ?`)
15783
- .run(now.toISOString(), outcome.receipt, now.toISOString(), id);
15784
- return;
15785
- }
15786
- this.db
15787
- .prepare(`UPDATE notification SET attempts = attempts + 1, last_attempt_at = ?, last_error = ?
15788
- WHERE id = ?`)
15789
- .run(now.toISOString(), outcome.error, id);
15790
- }
15791
15954
  /**
15792
15955
  * Close a gap's notification episode. Called when its capability verifies:
15793
15956
  * the next failure is a *new* fact, and a dedupe key that suppressed it
15794
15957
  * forever would be a gap the operator hears about exactly once per lifetime.
15795
15958
  */
15796
15959
  clearGapEpisode(repo, kind, name) {
15797
- this.db
15798
- .prepare("DELETE FROM notification WHERE dedupe_key = ?")
15799
- .run(`gap:${repo}:${kind}:${name}`);
15960
+ const key = `gap:${repo}:${kind}:${name}`;
15961
+ this.transact(() => {
15962
+ // Its v114 legacy receipt goes with it, as the old columns went with the row.
15963
+ this.db.prepare("DELETE FROM notification_delivery WHERE destination = ? AND notification IN (SELECT id FROM notification WHERE dedupe_key = ?)")
15964
+ .run(LEGACY_NOTIFICATION_DESTINATION, key);
15965
+ this.db.prepare("DELETE FROM notification WHERE dedupe_key = ?").run(key);
15966
+ });
15800
15967
  }
15801
15968
  /**
15802
15969
  * The other way an episode ends: the fact stopped wanting a person. Unlike
@@ -21814,8 +21981,10 @@ export class Store {
21814
21981
  if (this.stopQuiescenceProblem(runId) !== null)
21815
21982
  return false;
21816
21983
  const recordExit = this.db.prepare("UPDATE run_process SET exited_at = ? WHERE run = ? AND exited_at IS NULL");
21817
- for (const owned of this.ownedRunsOf(runId))
21984
+ for (const owned of this.ownedRunsOf(runId)) {
21818
21985
  recordExit.run(now.toISOString(), owned);
21986
+ compactSettledRunProcesses(this.db, now, owned);
21987
+ }
21819
21988
  const { changes } = this.db
21820
21989
  .prepare("UPDATE run_stop SET settled_at = ?, settlement = ? WHERE run = ? AND settled_at IS NULL")
21821
21990
  .run(now.toISOString(), settlement, runId);
@@ -21851,7 +22020,7 @@ export class Store {
21851
22020
  return fact(id, "alive", `run #${id}'s held supervisor has not finished shutdown`);
21852
22021
  const witnesses = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(id);
21853
22022
  let incompleteSpawn = false;
21854
- if (run.providerStartedAt !== null && witnesses.length === 0) {
22023
+ if (run.providerStartedAt !== null && witnesses.length === 0 && !this.processesSummarized(id)) {
21855
22024
  return fact(id, "unprovable", `run #${id} may have spawned before its process witness was recorded; exit is unproven`);
21856
22025
  }
21857
22026
  for (const witness of witnesses) {
@@ -21972,14 +22141,27 @@ export class Store {
21972
22141
  for (const run of runs)
21973
22142
  recorded += this.recordRunProcessExits(run, now);
21974
22143
  if (runs.length < batch)
21975
- return recorded;
22144
+ break;
21976
22145
  after = runs[runs.length - 1];
21977
22146
  }
22147
+ // A run whose witnesses all exited before it finished settles here, on the same pass.
22148
+ this.compactRunProcesses(now);
22149
+ return recorded;
21978
22150
  }
21979
22151
  finishUnspawnedProcess(witness, now) {
21980
22152
  this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND pid IS NULL AND ((containment IS NULL AND container IS NULL) OR container_empty_at IS NOT NULL)")
21981
22153
  .run(now.toISOString(), witness);
21982
22154
  }
22155
+ /** Settled custody, compacted (v114; compactSettledRunProcesses): a finished run whose witnesses all exited keeps
22156
+ * one summary row. A settled stop, an approver's settlement and a recovery compact their run at once; the worker's
22157
+ * reconcile pass (recordFinishedRunExits) compacts every other settled run, including one that settled at run end. */
22158
+ compactRunProcesses(now, only) {
22159
+ return this.transact(() => compactSettledRunProcesses(this.db, now, only ?? null));
22160
+ }
22161
+ /** Whether a run's witnesses were compacted into its summary: they existed, and all exited. */
22162
+ processesSummarized(runId) {
22163
+ return this.db.prepare("SELECT 1 AS hit FROM run_process_summary WHERE run = ?").get(runId) !== undefined;
22164
+ }
21983
22165
  /** The reconcile road for a pid-less witness a crash or a thrown spawn
21984
22166
  * left behind: once its run has finished, no live child is tracked for it
21985
22167
  * here, every other witness of the run has exited and none of the run's
@@ -22095,7 +22277,7 @@ export class Store {
22095
22277
  }
22096
22278
  // A run that may have spawned before any witness was written has none
22097
22279
  // to settle: the approver's word is recorded as one ended witness.
22098
- if (rows.length === 0 && run.providerStartedAt !== null) {
22280
+ if (rows.length === 0 && run.providerStartedAt !== null && !this.processesSummarized(args.runId)) {
22099
22281
  const id = Number(this.db.prepare("INSERT INTO run_process (run,host,process_group,observed_at,boot_id,exited_at) VALUES (?,?,0,?,?,?)")
22100
22282
  .run(args.runId, hostname(), now.toISOString(), currentBootId(), now.toISOString()).lastInsertRowid);
22101
22283
  this.recordAction({ at: now.toISOString(), actor: args.by, repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: args.runId,
@@ -22113,6 +22295,7 @@ export class Store {
22113
22295
  this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND run = ? AND exited_at IS NULL").run(now.toISOString(), id, args.runId);
22114
22296
  this.recordAction({ at: now.toISOString(), actor: args.by, repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: args.runId,
22115
22297
  action: "process witness settled by approver", outcome: `witness ${ids.join(", ")}`, source: "request", detail: args.why });
22298
+ compactSettledRunProcesses(this.db, now, args.runId);
22116
22299
  this.settleQuiescentStops(now);
22117
22300
  return { ok: true, witnesses: ids, repeated: false };
22118
22301
  });
@@ -22148,7 +22331,8 @@ export class Store {
22148
22331
  * must independently prove gone; this never grants authority to signal. */
22149
22332
  runCustodyProvenGone(runId) {
22150
22333
  const rows = this.db.prepare("SELECT * FROM run_process WHERE run = ?").all(runId);
22151
- return rows.length > 0 && rows.every(row => {
22334
+ // A summarized run's witnesses all exited, each on recorded proof.
22335
+ return (rows.length > 0 || this.processesSummarized(runId)) && rows.every(row => {
22152
22336
  if (String(row["host"]) !== hostname())
22153
22337
  return false;
22154
22338
  if (provenDeadByBootChange({ host: String(row["host"]), bootId: row["boot_id"] == null ? null : String(row["boot_id"]) }))
@@ -22853,63 +23037,11 @@ export class Store {
22853
23037
  return true;
22854
23038
  });
22855
23039
  }
22856
- /** Read receipts without changing the meaning of shell/webhook history. */
23040
+ /** The notifications this pairing holds, each with its delivery state there. */
22857
23041
  telegramDeliveries(binding) {
22858
23042
  return this.db.prepare(`SELECT n.*, d.attempts, d.last_attempt_at, d.last_error, d.delivered_at, d.receipt
22859
23043
  FROM notification n JOIN notification_delivery d ON d.notification = n.id WHERE d.destination = ? ORDER BY n.id`)
22860
- .all(this.telegramDestination(binding)).map(readNotification);
22861
- }
22862
- /**
22863
- * Claim pending notifications for one deliverer. Two deliverers — the
22864
- * bridge and `outbox deliver` — select-then-send-then-record, and without
22865
- * a claim both can send the same row in the gap. The claim is a short
22866
- * lease on the act of sending; a deliverer that dies mid-send leaves rows
22867
- * that unclaim themselves by expiry.
22868
- */
22869
- claimDeliveries(owner, ttlMs, now, only = "all") {
22870
- return this.transact(() => {
22871
- const stamp = now.toISOString();
22872
- // `urgent` (v34, digests): only what must page NOW — a decision, or
22873
- // an attention-class fact. Routine rows stay unclaimed and pending
22874
- // until a pass asks for `all`; nothing about them is written here.
22875
- const rows = this.db
22876
- .prepare(`SELECT id FROM notification
22877
- WHERE delivered_at IS NULL AND resolved_at IS NULL AND recipient IS NULL
22878
- AND (claim_owner IS NULL OR claim_expires_at <= ?)
22879
- AND (? = 'all' OR dedupe_key LIKE 'decision:%' OR COALESCE(push_class, '') = 'attention')
22880
- ORDER BY id`)
22881
- .all(stamp, only)
22882
- .map(row => Number(row["id"]));
22883
- const expires = new Date(now.getTime() + ttlMs).toISOString();
22884
- for (const id of rows) {
22885
- this.db
22886
- .prepare("UPDATE notification SET claim_owner = ?, claim_expires_at = ? WHERE id = ?")
22887
- .run(owner, expires, id);
22888
- }
22889
- return rows
22890
- .map(id => this.db.prepare("SELECT * FROM notification WHERE id = ?").get(id))
22891
- .filter((row) => row !== undefined)
22892
- .map(readNotification);
22893
- });
22894
- }
22895
- /** Finalize only what this owner still holds. A lapsed claim writes nothing. */
22896
- finalizeDelivery(id, owner, outcome, now) {
22897
- const stamp = now.toISOString();
22898
- if (outcome.ok) {
22899
- const { changes } = this.db
22900
- .prepare(`UPDATE notification SET delivered_at = ?, receipt = ?, attempts = attempts + 1,
22901
- last_attempt_at = ?, last_error = NULL,
22902
- claim_owner = NULL, claim_expires_at = NULL
22903
- WHERE id = ? AND claim_owner = ? AND delivered_at IS NULL`)
22904
- .run(stamp, outcome.receipt, stamp, id, owner);
22905
- return Number(changes) > 0;
22906
- }
22907
- const { changes } = this.db
22908
- .prepare(`UPDATE notification SET attempts = attempts + 1, last_attempt_at = ?, last_error = ?,
22909
- claim_owner = NULL, claim_expires_at = NULL
22910
- WHERE id = ? AND claim_owner = ?`)
22911
- .run(stamp, outcome.error, id, owner);
22912
- return Number(changes) > 0;
23044
+ .all(this.telegramDestination(binding)).map(readNotificationReceipt);
22913
23045
  }
22914
23046
  // ---- the Telegram digest (v34, away mode) --------------------------------
22915
23047
  /** The digest cadence: null everyMs = off. */
@@ -23283,21 +23415,6 @@ export class Store {
23283
23415
  markTelegramDigestSent(now) {
23284
23416
  this.db.prepare("UPDATE telegram_digest SET last_sent_at = ? WHERE id = 1").run(now.toISOString());
23285
23417
  }
23286
- /** Every batched row finalized delivered AND the anchor moved, in ONE
23287
- * transaction (v4 review, finding 5): a digest is either recorded whole
23288
- * or not at all. Rows whose claim lapsed meanwhile are reported, not
23289
- * silently skipped. */
23290
- finalizeDigest(ids, owner, receipt, now) {
23291
- return this.transact(() => {
23292
- let finalized = 0;
23293
- for (const id of ids) {
23294
- if (this.finalizeDelivery(id, owner, { ok: true, receipt }, now))
23295
- finalized++;
23296
- }
23297
- this.markTelegramDigestSent(now);
23298
- return { finalized, lapsed: ids.length - finalized };
23299
- });
23300
- }
23301
23418
  /** Routine rows waiting for the next digest: pending, and not urgent.
23302
23419
  * A row the live pairing delivered, or skipped as pre-pairing history,
23303
23420
  * is settled and not waiting. */
@@ -23329,9 +23446,8 @@ export class Store {
23329
23446
  */
23330
23447
  pendingForAttention() {
23331
23448
  const holds = Object.values(TELEGRAM_HOLD_REASONS);
23332
- // A successful shell/webhook receipt does not settle a different
23333
- // destination's failure. Select that union before applying the global
23334
- // pending filter, and exclude resolved facts from both arms.
23449
+ // Select the troubled union before applying the global pending filter,
23450
+ // and exclude resolved facts from both arms.
23335
23451
  return this.db
23336
23452
  .prepare(`WITH troubled AS (
23337
23453
  SELECT d.notification AS id FROM notification_delivery d
@@ -23344,7 +23460,7 @@ export class Store {
23344
23460
  )
23345
23461
  SELECT n.* FROM notification n
23346
23462
  WHERE n.resolved_at IS NULL
23347
- AND ((n.delivered_at IS NULL AND n.recipient IS NULL AND substr(n.dedupe_key, 1, ${LIFECYCLE_KEY_PREFIX.length}) <> '${LIFECYCLE_KEY_PREFIX}')
23463
+ AND ((n.recipient IS NULL AND substr(n.dedupe_key, 1, ${LIFECYCLE_KEY_PREFIX.length}) <> '${LIFECYCLE_KEY_PREFIX}')
23348
23464
  OR n.id IN (SELECT id FROM troubled))
23349
23465
  ORDER BY n.id`)
23350
23466
  .all(...holds)
@@ -23823,11 +23939,6 @@ function readNotification(row) {
23823
23939
  subject: String(row["subject"]),
23824
23940
  body: String(row["body"]),
23825
23941
  createdAt: String(row["created_at"]),
23826
- attempts: Number(row["attempts"]),
23827
- lastAttemptAt: row["last_attempt_at"] === null ? null : String(row["last_attempt_at"]),
23828
- lastError: row["last_error"] === null ? null : String(row["last_error"]),
23829
- deliveredAt: row["delivered_at"] === null ? null : String(row["delivered_at"]),
23830
- receipt: row["receipt"] === null ? null : String(row["receipt"]),
23831
23942
  resolvedAt: row["resolved_at"] === null || row["resolved_at"] === undefined
23832
23943
  ? null
23833
23944
  : String(row["resolved_at"]),
@@ -23839,6 +23950,16 @@ function readNotification(row) {
23839
23950
  link: row["link"] === null || row["link"] === undefined ? null : String(row["link"]),
23840
23951
  };
23841
23952
  }
23953
+ function readNotificationReceipt(row) {
23954
+ return {
23955
+ ...readNotification(row),
23956
+ attempts: Number(row["attempts"]),
23957
+ lastAttemptAt: row["last_attempt_at"] == null ? null : String(row["last_attempt_at"]),
23958
+ lastError: row["last_error"] == null ? null : String(row["last_error"]),
23959
+ deliveredAt: row["delivered_at"] == null ? null : String(row["delivered_at"]),
23960
+ receipt: row["receipt"] == null ? null : String(row["receipt"]),
23961
+ };
23962
+ }
23842
23963
  function readPushSubscription(row) {
23843
23964
  return {
23844
23965
  id: Number(row["id"]),
package/dist/surface.js CHANGED
@@ -416,7 +416,6 @@ const GUIDE_ROWS = [
416
416
  operator("intake clear", "clear intake configuration"),
417
417
  operator("contest exclude", "exclude a contestant from a tournament"),
418
418
  operator("webhook primary", "choose which service receives alerts"),
419
- operator("webhook test", "send a test message"),
420
419
  operator("bridge", "the Telegram bridge (pairing, tokens, the follower)"),
421
420
  operator("enroll", "grant a repository authority (backends, dispatch) — its own explicit yes"),
422
421
  operator("revoke", "take an authority grant back"),
@@ -481,7 +480,7 @@ const REMOTE_YES = [
481
480
  "runner list", "coordinator list", "cap list", "cap add", "outbox list", "incident list", "incident resolve",
482
481
  "routine list", "routine show", "routine add", "routine refresh", "routine pause", "routine resume", "routine run-now",
483
482
  "config show", "verify show", "intake show", "intake run", "intake preview", "intake pr-comments", "template list", "template show",
484
- "contest show", "contest exclude", "webhook status", "webhook test", "review show", "chat-approval show", "chat-approval off",
483
+ "contest show", "contest exclude", "webhook status", "review show", "chat-approval show", "chat-approval off",
485
484
  "knowledge search", "knowledge impact", "knowledge refresh",
486
485
  "memory search", "memory decisions", "memory show", "memory decide", "memory retire", "memory review", "memory status",
487
486
  "flows list", "flows show", "flows export", "flows create", "flows edit", "flows trigger add", "flows trigger pause", "flows trigger resume",
@@ -103,14 +103,25 @@ export declare function releaseNotes(changelog: string, version: string): string
103
103
  * by its directory), and the directory after, so a crash never leaves a torn
104
104
  * database, catalog or command. */
105
105
  export declare function durableRename(temp: string, target: string): void;
106
- type TableDigest = {
106
+ export type TableDigest = {
107
107
  name: string;
108
108
  columns: string[];
109
+ rowid: boolean;
109
110
  count: number;
110
- hash: string;
111
+ last: number;
112
+ hash?: string;
113
+ summed?: number;
114
+ nonNull?: Record<string, number>;
115
+ carry?: number;
116
+ received?: {
117
+ count: number;
118
+ nonNull: Record<string, number>;
119
+ };
111
120
  };
112
- /** Every historical row, table by table (deploy-browser's rehearsal check). */
121
+ /** Each saved table's columns, row count and last rowid; the append-only ones hashed (deploy-browser's rehearsal check).
122
+ * Bounded: only the ledger and small tables are read row by row. */
113
123
  export declare function historySnapshot(db: DatabaseSync): TableDigest[];
124
+ /** The tables whose saved history the migration changed beyond what HISTORY_RULES declares. */
114
125
  export declare function changedHistory(db: DatabaseSync, before: TableDigest[]): string[];
115
126
  export type StartOptions = {
116
127
  stateDir: string;