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.
@@ -315,28 +315,115 @@ export function durableRename(temp, target) {
315
315
  if (process.platform !== "win32")
316
316
  fsyncPath(dirname(target));
317
317
  }
318
- function tableDigest(db, name, columns) {
319
- const rows = db.prepare("SELECT " + columns.map(quote).join(",") + " FROM " + quote(name)).all().map(r => JSON.stringify(r)).sort();
320
- return { count: rows.length, hash: sha(rows.join("\n")) };
321
- }
322
- /** Every historical row, table by table (deploy-browser's rehearsal check). */
318
+ // ---- database: snapshot, rehearse, restore --------------------------------
319
+ /** How a rehearsed migration may change each saved table. An unlisted table keeps every column, its row count and
320
+ * its last rowid (a WITHOUT ROWID table, all small, keeps the hash of its rows). `appendOnly`: every row through the
321
+ * last one before hashes the same, in rowid order; new rows may follow. `dropped`: columns the new schema may remove
322
+ * only when every saved value is null, or, with `carried`, when the receiving table gained exactly one receipt under
323
+ * that destination per row holding a value, and as many non-null values per column as the dropped columns held.
324
+ * `receives`: the only rows the table may gain are those receipts. `compacted`: rows may go only into summary rows,
325
+ * whose `sum` grows by exactly as many. */
326
+ const LEGACY_DESTINATION = "legacy:single-destination";
327
+ const HISTORY_RULES = {
328
+ action_ledger: { appendOnly: true }, ledger_seal: { appendOnly: true }, ledger_checkpoint: { appendOnly: true },
329
+ // v114: a finished run whose process witnesses have all exited keeps one summary row in place of them.
330
+ run_process: { compacted: { into: "run_process_summary", sum: "witnesses" } }, run_process_summary: { appendOnly: true },
331
+ // v114: single-destination delivery columns may be dropped only once their values are legacy receipts.
332
+ notification: { dropped: ["attempts", "last_attempt_at", "last_error", "delivered_at", "receipt", "claim_owner", "claim_expires_at"], carried: { into: "notification_delivery", destination: LEGACY_DESTINATION } },
333
+ notification_delivery: { receives: LEGACY_DESTINATION },
334
+ };
335
+ const tableColumns = (db, name) => db.prepare("PRAGMA table_info(" + quote(name) + ")").all().map(r => String(r["name"]));
336
+ const plainValue = (_, v) => v instanceof Uint8Array ? Buffer.from(v).toString("hex") : typeof v === "bigint" ? String(v) : v;
337
+ /** Rows streamed into a sha256, never held together: through a rowid in rowid order, or a WITHOUT ROWID table whole. */
338
+ function rowsHash(db, name, columns, through) {
339
+ const select = "SELECT " + (through === undefined ? "" : 'rowid AS "rowid:", ') + columns.map(quote).join(",") + " FROM " + quote(name);
340
+ const key = () => db.prepare("SELECT name FROM pragma_table_info(?) WHERE pk > 0 ORDER BY pk").all(name).map(r => quote(String(r["name"]))).join(",");
341
+ const rows = through === undefined ? db.prepare(select + " ORDER BY " + key()).iterate() : db.prepare(select + " WHERE rowid <= ? ORDER BY rowid").iterate(through);
342
+ const hash = createHash("sha256");
343
+ let count = 0;
344
+ for (const row of rows) {
345
+ hash.update(JSON.stringify(Object.values(row), plainValue) + "\n");
346
+ count++;
347
+ }
348
+ return { count, hash: hash.digest("hex") };
349
+ }
350
+ /** Rows under one receipt destination, and each column's non-null values among them: counted, never read. */
351
+ function receipts(db, name, destination, columns) {
352
+ const r = db.prepare("SELECT count(*) AS n" + columns.map(column => ",count(" + quote(column) + ") AS " + quote(column)).join("") + " FROM " + quote(name) + " WHERE destination = ?").get(destination);
353
+ return { count: Number(r["n"]), nonNull: Object.fromEntries(columns.map(column => [column, Number(r[column])])) };
354
+ }
355
+ const summed = (db, into) => db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(into.into) ? Number(db.prepare("SELECT coalesce(sum(" + quote(into.sum) + "),0) AS n FROM " + quote(into.into)).get()["n"]) : 0;
356
+ /** Each saved table's columns, row count and last rowid; the append-only ones hashed (deploy-browser's rehearsal check).
357
+ * Bounded: only the ledger and small tables are read row by row. */
323
358
  export function historySnapshot(db) {
324
- return db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name <> 'schema_version' ORDER BY name").all()
325
- .map(row => String(row["name"])).map(name => {
326
- const columns = db.prepare("PRAGMA table_info(" + quote(name) + ")").all().map(r => String(r["name"]));
327
- return { name, columns, ...tableDigest(db, name, columns) };
359
+ return db.prepare("SELECT name, wr FROM pragma_table_list WHERE schema='main' AND type IN ('table','shadow') AND name NOT LIKE 'sqlite_%' AND name <> 'schema_version' ORDER BY name").all()
360
+ .map(row => {
361
+ const name = String(row["name"]), rowid = row["wr"] === 0, columns = tableColumns(db, name), rule = HISTORY_RULES[name];
362
+ if (!rowid)
363
+ return { name, columns, rowid, last: 0, ...rowsHash(db, name, columns) };
364
+ const r = db.prepare("SELECT count(*) AS n, coalesce(max(rowid),0) AS last FROM " + quote(name)).get();
365
+ const t = { name, columns, rowid, count: Number(r["n"]), last: Number(r["last"]) };
366
+ const droppable = columns.filter(column => rule?.dropped?.includes(column));
367
+ if (droppable.length > 0) {
368
+ const occupancy = db.prepare("SELECT " + droppable.map(column => "count(" + quote(column) + ") AS " + quote(column)).join(",") + " FROM " + quote(name)).get();
369
+ t.nonNull = Object.fromEntries(droppable.map(column => [column, Number(occupancy[column])]));
370
+ t.carry = Number(db.prepare("SELECT count(*) AS n FROM " + quote(name) + " WHERE " + droppable.map(column => quote(column) + " IS NOT NULL").join(" OR ")).get()["n"]);
371
+ }
372
+ if (rule?.receives)
373
+ t.received = receipts(db, name, rule.receives, columns.filter(column => column !== "notification" && column !== "destination"));
374
+ if (rule?.appendOnly)
375
+ t.hash = rowsHash(db, name, columns, t.last).hash;
376
+ if (rule?.compacted)
377
+ t.summed = summed(db, rule.compacted);
378
+ return t;
328
379
  });
329
380
  }
381
+ /** The tables whose saved history the migration changed beyond what HISTORY_RULES declares. */
330
382
  export function changedHistory(db, before) {
331
383
  const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => String(r["name"])));
384
+ // Receipts each receiving table gained, and the dropped values a carrying table owes it.
385
+ const gained = new Map(), owed = new Map();
386
+ for (const t of before) {
387
+ if (t.received && tables.has(t.name)) {
388
+ const after = receipts(db, t.name, HISTORY_RULES[t.name].receives, Object.keys(t.received.nonNull));
389
+ gained.set(t.name, { count: after.count - t.received.count, nonNull: Object.fromEntries(Object.entries(after.nonNull).map(([c, n]) => [c, n - (t.received.nonNull[c] ?? 0)])) });
390
+ }
391
+ const carried = HISTORY_RULES[t.name]?.carried;
392
+ if (carried && t.carry && tables.has(t.name)) {
393
+ const present = new Set(tableColumns(db, t.name)), lost = t.columns.filter(c => !present.has(c) && HISTORY_RULES[t.name].dropped.includes(c));
394
+ if (lost.length > 0)
395
+ owed.set(carried.into, { count: t.carry, nonNull: Object.fromEntries(lost.map(c => [c, t.nonNull[c]])) });
396
+ }
397
+ }
398
+ const accounted = (rule, t) => {
399
+ if (!rule?.carried)
400
+ return false;
401
+ const got = gained.get(rule.carried.into), want = owed.get(rule.carried.into);
402
+ return got !== undefined && want !== undefined && got.count === want.count && Object.entries(want.nonNull).every(([c, n]) => got.nonNull[c] === n) && t.carry === want.count;
403
+ };
332
404
  return before.filter(t => {
333
405
  if (!tables.has(t.name))
334
406
  return true;
335
- const present = new Set(db.prepare("PRAGMA table_info(" + quote(t.name) + ")").all().map(r => String(r["name"])));
336
- if (t.columns.some(c => !present.has(c)))
407
+ const rule = HISTORY_RULES[t.name], present = new Set(tableColumns(db, t.name));
408
+ if (t.columns.some(c => !present.has(c) && (!rule?.dropped?.includes(c) || (t.nonNull?.[c] !== 0 && !accounted(rule, t)))))
337
409
  return true;
338
- const after = tableDigest(db, t.name, t.columns);
339
- return after.count !== t.count || after.hash !== t.hash;
410
+ const columns = t.columns.filter(c => present.has(c));
411
+ if (!t.rowid) {
412
+ const after = rowsHash(db, t.name, columns);
413
+ return after.count !== t.count || after.hash !== t.hash;
414
+ }
415
+ if (rule?.appendOnly) {
416
+ const after = rowsHash(db, t.name, columns, t.last);
417
+ return after.count !== t.count || after.hash !== t.hash;
418
+ }
419
+ const r = db.prepare("SELECT count(*) AS n, coalesce(max(rowid),0) AS last FROM " + quote(t.name)).get(), count = Number(r["n"]), last = Number(r["last"]);
420
+ if (rule?.compacted)
421
+ return count > t.count || last > t.last || t.count - count !== summed(db, rule.compacted) - (t.summed ?? 0);
422
+ if (rule?.receives) {
423
+ const added = gained.get(t.name)?.count ?? 0;
424
+ return count - t.count !== added || (added > 0 && added !== owed.get(t.name)?.count) || last < t.last;
425
+ }
426
+ return count !== t.count || last !== t.last;
340
427
  }).map(t => t.name);
341
428
  }
342
429
  async function rehearse(j, system, source) {
@@ -345,7 +432,9 @@ async function rehearse(j, system, source) {
345
432
  chmodSync(copy, 0o600);
346
433
  try {
347
434
  let db = new (sqlite().DatabaseSync)(copy, { readOnly: true });
348
- const before = historySnapshot(db);
435
+ // Rows already pointing nowhere before the update are the install's own; only ones the migration makes refuse.
436
+ const orphans = () => new Set([...db.prepare("PRAGMA foreign_key_check").iterate()].map(row => JSON.stringify(row)));
437
+ const before = historySnapshot(db), orphaned = orphans();
349
438
  db.close();
350
439
  await system.rehearse(j.to.dist, copy);
351
440
  db = new (sqlite().DatabaseSync)(copy, { readOnly: true });
@@ -355,6 +444,8 @@ async function rehearse(j, system, source) {
355
444
  throw new Refusal(`Toolroll ${j.to.version} would change saved history in ${changed.slice(0, 4).join(", ")}${changed.length > 4 ? ` and ${changed.length - 4} more` : ""}. Nothing was changed.`);
356
445
  if (db.prepare("PRAGMA integrity_check").get()?.["integrity_check"] !== "ok")
357
446
  throw new Refusal(`The rehearsed database failed its integrity check under ${j.to.version}. Nothing was changed.`);
447
+ if ([...orphans()].some(row => !orphaned.has(row)))
448
+ throw new Refusal(`The rehearsed database has rows pointing at missing rows under ${j.to.version}. Nothing was changed.`);
358
449
  }
359
450
  finally {
360
451
  db.close();
@@ -1,23 +1,15 @@
1
1
  import type { Notification, Store } from "./store.js";
2
- export declare const SLACK_ENV = "TOOLROLL_SLACK_WEBHOOK";
3
- export declare const DISCORD_ENV = "TOOLROLL_DISCORD_WEBHOOK";
4
- export declare const CONSOLE_URL_ENV = "TOOLROLL_CONSOLE_URL";
5
- export declare const PRIMARY_ENV = "TOOLROLL_MESSAGING_PRIMARY";
6
2
  export type WebhookKind = "slack" | "discord";
7
3
  export type WebhookTarget = {
8
4
  kind: WebhookKind;
9
5
  url: string;
10
6
  };
7
+ export declare const LEGACY_WEBHOOK_WARNING = "Legacy webhooks are deprecated. Connect Slack or Discord in Chat settings.";
8
+ export declare const CONSOLE_URL_ENV = "TOOLROLL_CONSOLE_URL";
9
+ export declare const PRIMARY_ENV = "TOOLROLL_MESSAGING_PRIMARY";
11
10
  export type MessagingChannel = "telegram" | "slack" | "discord" | "teams";
12
11
  export declare const MESSAGING_CHANNELS: readonly MessagingChannel[];
13
12
  export declare function isMessagingChannel(value: string): value is MessagingChannel;
14
- export declare function saveWebhook(dir: string, kind: WebhookKind, url: string): {
15
- ok: true;
16
- } | {
17
- ok: false;
18
- message: string;
19
- };
20
- export declare function clearWebhook(dir: string, kind: WebhookKind): void;
21
13
  export declare function saveConsoleUrl(dir: string, url: string): {
22
14
  ok: true;
23
15
  } | {
@@ -34,20 +26,21 @@ export declare function loadPrimary(env: Record<string, string | undefined>, dir
34
26
  * merely fell out of what happens to be configured. Explicit choice wins
35
27
  * WHEN its channel is actually configured (a primary pointing at nothing
36
28
  * falls through rather than silencing every page); otherwise Telegram if
37
- * present (it can hold buttons), else the mirrors. `implicit` is the flag
29
+ * present (it can hold buttons), else the first other connected service. `implicit` is the flag
38
30
  * every status surface uses to say "you have several — pick one".
39
31
  */
40
32
  export declare function effectivePrimary(env: Record<string, string | undefined>, dir: string, telegramConfigured: boolean): {
41
33
  channel: MessagingChannel | null;
42
34
  implicit: boolean;
43
35
  configured: MessagingChannel[];
36
+ legacyWarning?: string;
44
37
  };
45
38
  export declare function loadConsoleUrl(env: Record<string, string | undefined>, dir: string): string | null;
46
39
  /**
47
40
  * The origin a phone link may open, or null. The SAME console-url setting
48
- * the mirrors use — read again on every call, never cached, so a changed or
41
+ * chat links use — read again on every call, never cached, so a changed or
49
42
  * removed setting stops the next link — held to a stricter shape than a
50
- * mirror link: exactly an https origin (no credentials, path, query,
43
+ * desktop link: exactly an https origin (no credentials, path, query,
51
44
  * fragment), not loopback, and, when the console this process co-hosts
52
45
  * states its own `--public-url`, exactly that origin. A model, a Host
53
46
  * header or a proposal field never supplies it, and nothing here probes
@@ -74,14 +67,9 @@ export type WebhookReport = {
74
67
  sent: number;
75
68
  problems: string[];
76
69
  };
77
- /**
78
- * Deliver every pending notification to every configured mirror, through
79
- * the SAME claim/finalize discipline as every other deliverer — so a
80
- * webhook pass and a Telegram bridge can never both own one row. This
81
- * pass runs when Telegram is NOT configured; with a paired Telegram chat,
82
- * Telegram remains the delivery channel (it can carry buttons) and these
83
- * mirrors stay silent rather than double-paging every phone.
84
- */
70
+ /** Only the chosen service sends; an interactive adapter suppresses its old mirror. */
71
+ export declare function activeWebhookTargets(env: Record<string, string | undefined>, dir: string, channel: MessagingChannel | null): WebhookTarget[];
72
+ /** Each destination owns its claim and receipt; a successful one never hides another's retry. */
85
73
  export declare function webhookPass(store: Store, options: {
86
74
  targets: WebhookTarget[];
87
75
  consoleUrl: string | null;
package/dist/webhooks.js CHANGED
@@ -1,58 +1,29 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { notificationClaimHeld, claimNotifications, finalizeNotification, notificationDestination } from "./notification-delivery.js";
1
3
  import { loadDiscordCredentials } from "./discord-api.js";
2
4
  import { loadTeamsCredentials } from "./teams-api.js";
3
5
  import { loadSlackCredentials } from "./slack-api.js";
4
6
  /**
5
- * Legacy notification-only webhooks. Interactive Slack and Discord adapters
6
- * have their own paired identities and reuse the shared action engine.
7
- * A configured interactive adapter suppresses its legacy webhook delivery.
8
- *
9
- * The webhook URL is a CREDENTIAL (anyone holding it can post to the
10
- * channel): it lives in a 0600 file beside the database or in the
11
- * environment, is never a database column, and never appears in logs or
12
- * errors — the same rules as the bot token, for the same reasons.
7
+ * Messaging settings beside the database: which connected chat service
8
+ * receives alerts (the primary), and the console URL that links in chats
9
+ * and phone pushes open. The chat services themselves — Telegram and the
10
+ * interactive Slack, Discord and Teams adapters — keep their own
11
+ * credentials; this file only reads whether they are configured.
13
12
  */
14
- import { readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs";
13
+ import { readFileSync, writeFileSync } from "node:fs";
15
14
  import { join } from "node:path";
16
15
  import { envValue } from "./names.js";
17
- export const SLACK_ENV = "TOOLROLL_SLACK_WEBHOOK";
18
- export const DISCORD_ENV = "TOOLROLL_DISCORD_WEBHOOK";
16
+ const FILE_OF = { slack: "slack-webhook", discord: "discord-webhook" };
17
+ const POST_TIMEOUT_MS = 10_000;
18
+ export const LEGACY_WEBHOOK_WARNING = "Legacy webhooks are deprecated. Connect Slack or Discord in Chat settings.";
19
19
  export const CONSOLE_URL_ENV = "TOOLROLL_CONSOLE_URL";
20
20
  export const PRIMARY_ENV = "TOOLROLL_MESSAGING_PRIMARY";
21
- /** How long one delivery claim protects a row from a second sender. */
22
- const DELIVERY_CLAIM_MS = 60_000;
23
- const POST_TIMEOUT_MS = 10_000;
24
- const FILE_OF = {
25
- slack: "slack-webhook",
26
- discord: "discord-webhook",
27
- };
28
21
  const CONSOLE_FILE = "console-url";
29
22
  const PRIMARY_FILE = "messaging-primary";
30
23
  export const MESSAGING_CHANNELS = ["telegram", "slack", "discord", "teams"];
31
24
  export function isMessagingChannel(value) {
32
25
  return MESSAGING_CHANNELS.includes(value);
33
26
  }
34
- /** The shapes each platform hands out; anything else is probably a paste error. */
35
- const URL_SHAPE = {
36
- slack: /^https:\/\/hooks\.slack\.com\/\S+$/,
37
- discord: /^https:\/\/(canary\.|ptb\.)?discord\.com\/api\/webhooks\/\S+$/,
38
- };
39
- export function saveWebhook(dir, kind, url) {
40
- if (!URL_SHAPE[kind].test(url.trim())) {
41
- return { ok: false, message: `that does not look like a ${kind} webhook URL — paste it exactly as the platform issued it` };
42
- }
43
- const file = join(dir, FILE_OF[kind]);
44
- writeFileSync(file, `${url.trim()}\n`, { mode: 0o600 });
45
- chmodSync(file, 0o600);
46
- return { ok: true };
47
- }
48
- export function clearWebhook(dir, kind) {
49
- try {
50
- rmSync(join(dir, FILE_OF[kind]));
51
- }
52
- catch {
53
- // Already absent is cleared.
54
- }
55
- }
56
27
  export function saveConsoleUrl(dir, url) {
57
28
  // Parsed, not pattern-matched (attended review, finding 11): the stored
58
29
  // value becomes every deep link's base, so userinfo, queries, and
@@ -111,26 +82,27 @@ export function loadPrimary(env, dir) {
111
82
  * merely fell out of what happens to be configured. Explicit choice wins
112
83
  * WHEN its channel is actually configured (a primary pointing at nothing
113
84
  * falls through rather than silencing every page); otherwise Telegram if
114
- * present (it can hold buttons), else the mirrors. `implicit` is the flag
85
+ * present (it can hold buttons), else the first other connected service. `implicit` is the flag
115
86
  * every status surface uses to say "you have several — pick one".
116
87
  */
117
88
  export function effectivePrimary(env, dir, telegramConfigured) {
118
89
  const targets = loadWebhookTargets(env, dir);
90
+ const warning = targets.length ? { legacyWarning: LEGACY_WEBHOOK_WARNING } : {};
119
91
  const configured = [
120
92
  ...(telegramConfigured ? ["telegram"] : []),
121
93
  ...(loadSlackCredentials(dir) !== null ? ["slack"] : []),
122
94
  ...(loadDiscordCredentials(dir) !== null ? ["discord"] : []),
123
95
  ...(loadTeamsCredentials(dir) !== null ? ["teams"] : []),
124
- ...targets.map(one => one.kind).filter(kind => (kind !== "slack" || loadSlackCredentials(dir) === null) && (kind !== "discord" || loadDiscordCredentials(dir) === null)),
96
+ ...targets.map(one => one.kind).filter(kind => kind === "slack" ? loadSlackCredentials(dir) === null : loadDiscordCredentials(dir) === null),
125
97
  ];
126
98
  const chosen = loadPrimary(env, dir);
127
99
  if (chosen !== null && configured.includes(chosen)) {
128
- return { channel: chosen, implicit: false, configured };
100
+ return { channel: chosen, implicit: false, configured, ...warning };
129
101
  }
130
102
  if (telegramConfigured)
131
- return { channel: "telegram", implicit: configured.length > 1, configured };
103
+ return { channel: "telegram", implicit: configured.length > 1, configured, ...warning };
132
104
  const fallback = configured[0] ?? null;
133
- return { channel: fallback, implicit: configured.length > 1, configured };
105
+ return { channel: fallback, implicit: configured.length > 1, configured, ...warning };
134
106
  }
135
107
  export function loadConsoleUrl(env, dir) {
136
108
  const configured = envValue(env, "CONSOLE_URL") ?? readTrimmed(join(dir, CONSOLE_FILE));
@@ -161,9 +133,9 @@ function isLoopbackHost(hostname) {
161
133
  }
162
134
  /**
163
135
  * The origin a phone link may open, or null. The SAME console-url setting
164
- * the mirrors use — read again on every call, never cached, so a changed or
136
+ * chat links use — read again on every call, never cached, so a changed or
165
137
  * removed setting stops the next link — held to a stricter shape than a
166
- * mirror link: exactly an https origin (no credentials, path, query,
138
+ * desktop link: exactly an https origin (no credentials, path, query,
167
139
  * fragment), not loopback, and, when the console this process co-hosts
168
140
  * states its own `--public-url`, exactly that origin. A model, a Host
169
141
  * header or a proposal field never supplies it, and nothing here probes
@@ -216,7 +188,6 @@ export function linkFor(consoleUrl, notification) {
216
188
  * message is a mirror and the UI is the instrument.
217
189
  */
218
190
  export async function postWebhook(target, notification, link, fetcher = fetch) {
219
- const lines = [notification.subject, "", notification.body];
220
191
  const body = target.kind === "slack"
221
192
  ? {
222
193
  text: [
@@ -232,7 +203,6 @@ export async function postWebhook(target, notification, link, fetcher = fetch) {
232
203
  ...(link === null ? [] : [link]),
233
204
  ].join("\n").slice(0, 1900),
234
205
  };
235
- void lines;
236
206
  try {
237
207
  const response = await fetcher(target.url, {
238
208
  method: "POST",
@@ -246,43 +216,35 @@ export async function postWebhook(target, notification, link, fetcher = fetch) {
246
216
  }
247
217
  return { ok: true };
248
218
  }
249
- catch (error) {
250
- const said = error instanceof Error ? error.name : "failed";
251
- return { ok: false, error: `${target.kind} delivery ${said}` };
219
+ catch {
220
+ return { ok: false, error: `${target.kind} delivery failed` };
252
221
  }
253
222
  }
254
- /**
255
- * Deliver every pending notification to every configured mirror, through
256
- * the SAME claim/finalize discipline as every other deliverer — so a
257
- * webhook pass and a Telegram bridge can never both own one row. This
258
- * pass runs when Telegram is NOT configured; with a paired Telegram chat,
259
- * Telegram remains the delivery channel (it can carry buttons) and these
260
- * mirrors stay silent rather than double-paging every phone.
261
- */
223
+ /** Only the chosen service sends; an interactive adapter suppresses its old mirror. */
224
+ export function activeWebhookTargets(env, dir, channel) {
225
+ return loadWebhookTargets(env, dir).filter(one => one.kind === channel && (one.kind === "slack" ? loadSlackCredentials(dir) === null : loadDiscordCredentials(dir) === null));
226
+ }
227
+ /** Each destination owns its claim and receipt; a successful one never hides another's retry. */
262
228
  export async function webhookPass(store, options) {
263
- const clock = options.clock ?? (() => new Date());
264
- const owner = options.owner ?? `webhooks-${Math.floor(clock().getTime() / 1000)}`;
229
+ const clock = options.clock ?? (() => new Date()), owner = options.owner ?? `webhooks-${randomUUID()}`;
265
230
  const report = { sent: 0, problems: [] };
266
- if (options.targets.length === 0)
267
- return report;
268
- const claimed = store.claimDeliveries(owner, DELIVERY_CLAIM_MS, clock());
269
- for (const row of claimed) {
270
- // The lead's own work posts to no channel; the console keeps it.
271
- if (store.leadQuiet(row)) {
272
- store.finalizeDelivery(row.id, owner, { ok: true, receipt: "skipped:quiet" }, clock());
273
- continue;
231
+ for (const target of options.targets) {
232
+ const destination = notificationDestination(`webhook:${target.kind}`, target.url);
233
+ for (const row of claimNotifications(store, destination, owner, clock())) {
234
+ if (!notificationClaimHeld(store, row, owner, clock())) {
235
+ report.problems.push(`notification ${row.id}: claim expired or notification resolved`);
236
+ continue;
237
+ }
238
+ const outcome = store.leadQuiet(row) ? { ok: true, receipt: "skipped:quiet" }
239
+ : await postWebhook(target, row, linkFor(options.consoleUrl, row), options.fetcher);
240
+ const finalized = finalizeNotification(store, row, owner, outcome.ok ? { ok: true, receipt: "receipt" in outcome ? outcome.receipt : target.kind } : outcome, clock());
241
+ if (outcome.ok && finalized && !("receipt" in outcome))
242
+ report.sent++;
243
+ if (outcome.ok && !finalized)
244
+ report.problems.push(`notification ${row.id}: delivery receipt claim expired`);
245
+ if (!outcome.ok)
246
+ report.problems.push(`notification ${row.id}: ${outcome.error}`);
274
247
  }
275
- const link = linkFor(options.consoleUrl, row);
276
- const outcomes = await Promise.all(options.targets.map(target => postWebhook(target, row, link, options.fetcher ?? fetch)));
277
- const failed = outcomes.filter(one => !one.ok);
278
- const outcome = failed.length === 0
279
- ? { ok: true, receipt: options.targets.map(one => one.kind).join("+") }
280
- : { ok: false, error: failed.map(one => (one.ok ? "" : one.error)).join("; ") };
281
- const finalized = store.finalizeDelivery(row.id, owner, outcome, clock());
282
- if (outcome.ok && finalized)
283
- report.sent++;
284
- if (!outcome.ok)
285
- report.problems.push(`notification ${row.id}: ${outcome.error}`);
286
248
  }
287
249
  return report;
288
250
  }
@@ -75,7 +75,7 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
75
75
  SELECT r.task_ref FROM held_session h JOIN run r ON r.id=h.run JOIN admitted a ON a.ref_id=r.task_ref WHERE h.ended_at IS NULL
76
76
  UNION
77
77
  SELECT r.task_ref FROM run r INDEXED BY work_spawned_run JOIN admitted a ON a.ref_id=r.task_ref
78
- WHERE r.provider_started_at IS NOT NULL AND NOT EXISTS(SELECT 1 FROM run_process p WHERE p.run=r.id)
78
+ WHERE r.provider_started_at IS NOT NULL AND NOT EXISTS(SELECT 1 FROM run_process p WHERE p.run=r.id) AND NOT EXISTS(SELECT 1 FROM run_process_summary s WHERE s.run=r.id)
79
79
  UNION
80
80
  SELECT r.task_ref FROM run_process p INDEXED BY work_unsettled_custody JOIN run r ON r.id=p.run JOIN admitted a ON a.ref_id=r.task_ref
81
81
  WHERE p.exited_at IS NULL AND p.container_empty_at IS NULL
@@ -290,7 +290,7 @@ function registerValidators(store) {
290
290
  * there, which only the store can read (a held or unreadable workspace). */
291
291
  function custodyReadings(store) {
292
292
  const refs = store.handle.prepare(`SELECT r.task_ref FROM held_session h JOIN run r ON r.id=h.run WHERE h.ended_at IS NULL
293
- UNION SELECT r.task_ref FROM run r WHERE r.provider_started_at IS NOT NULL AND NOT EXISTS(SELECT 1 FROM run_process p WHERE p.run=r.id)
293
+ UNION SELECT r.task_ref FROM run r WHERE r.provider_started_at IS NOT NULL AND NOT EXISTS(SELECT 1 FROM run_process p WHERE p.run=r.id) AND NOT EXISTS(SELECT 1 FROM run_process_summary s WHERE s.run=r.id)
294
294
  UNION SELECT r.task_ref FROM run_process p JOIN run r ON r.id=p.run WHERE p.exited_at IS NULL AND p.container_empty_at IS NULL`).all().map(row => Number(row['task_ref']));
295
295
  const noted = store.handle.prepare(`SELECT r.task_ref,r.worktree FROM task t JOIN task_ref tr ON tr.external_id=t.id
296
296
  JOIN run r ON r.id=(SELECT MAX(id) FROM run WHERE task_ref=tr.id AND role IN ('builder','scout') AND finished_at IS NOT NULL)
@@ -1,6 +1,33 @@
1
+ /** Durable invalidation for authenticated workspace reads. This is cache
2
+ * metadata only: it never grants access or certifies task/process state.
3
+ *
4
+ * The revision is one service_cursor row. Every Toolroll connection is wrapped
5
+ * (trackWorkspaceWrites, from the Store) and moves it once per committed
6
+ * meaningful write: inside a transaction at its outer COMMIT (a rollback, or a
7
+ * savepoint rolled back to, moves nothing), and right after an autocommit
8
+ * statement. Quiet writes (heartbeats, cursors, receipts) move nothing; a beat
9
+ * after a liveness or lease gap is a revival and does. Readers in another
10
+ * process see the commit through PRAGMA data_version. No trigger is installed:
11
+ * the schema stays the schema (v114 dropped the 801 per-table triggers). */
12
+ import type { Database } from './store.js';
1
13
  import type { Store } from './store.js';
2
- /** The service_cursor row the revision triggers move (live-bus.ts listens for it). */
14
+ /** The service_cursor row every meaningful commit moves (live-bus.ts listens for it). */
3
15
  export declare const WORKSPACE_REVISION_KEY = "workspace-content:v1";
16
+ /** What one statement does to the workspace: a transaction boundary, a write to a table (quiet or not), or nothing. */
17
+ type Effect = {
18
+ kind: 'begin' | 'commit' | 'rollback' | 'none';
19
+ } | {
20
+ kind: 'savepoint' | 'rollback-to' | 'release';
21
+ name: string;
22
+ } | {
23
+ kind: 'write';
24
+ table: string;
25
+ quiet: boolean;
26
+ };
27
+ export declare function statementEffect(sql: string): Effect;
28
+ /** Wrap one connection so each committed meaningful write moves the workspace revision exactly once. Native
29
+ * extensions (scalar functions, isTransaction) pass through; a test spy on prepare/exec replaces only the facade's. */
30
+ export declare function trackWorkspaceWrites(db: Database): Database;
4
31
  export type WorkspaceRevision = {
5
32
  current(): string;
6
33
  expiresAt(now: Date): number;