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/CHANGELOG.md +12 -0
- package/dist/backup.d.ts +8 -0
- package/dist/backup.js +10 -1
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +1 -1
- package/dist/browser-workspace.d.ts +1 -0
- package/dist/database-reclaim.d.ts +4 -0
- package/dist/database-reclaim.js +13 -0
- package/dist/demo.d.ts +1 -1
- package/dist/demo.js +1 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/live-bus.d.ts +3 -3
- package/dist/live-bus.js +3 -3
- package/dist/monitoring.js +32 -8
- package/dist/notification-delivery.d.ts +19 -0
- package/dist/notification-delivery.js +37 -0
- package/dist/operate-remote-arguments.js +1 -1
- package/dist/operate-remote.js +2 -2
- package/dist/operate.js +49 -119
- package/dist/process-recovery-settlement.js +1 -0
- package/dist/retention.js +6 -5
- package/dist/serve.js +1 -1
- package/dist/server/settings.js +3 -1
- package/dist/server/shared.d.ts +1 -0
- package/dist/server/shared.js +2 -0
- package/dist/store.d.ts +38 -43
- package/dist/store.js +255 -134
- package/dist/surface.js +1 -2
- package/dist/toolroll-update.d.ts +14 -3
- package/dist/toolroll-update.js +105 -14
- package/dist/webhooks.d.ts +10 -22
- package/dist/webhooks.js +43 -81
- package/dist/work-index.js +2 -2
- package/dist/workspace-revision.d.ts +28 -1
- package/dist/workspace-revision.js +276 -49
- package/package.json +1 -3
package/dist/toolroll-update.js
CHANGED
|
@@ -315,28 +315,115 @@ export function durableRename(temp, target) {
|
|
|
315
315
|
if (process.platform !== "win32")
|
|
316
316
|
fsyncPath(dirname(target));
|
|
317
317
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
325
|
-
.map(row =>
|
|
326
|
-
const
|
|
327
|
-
|
|
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
|
|
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
|
|
339
|
-
|
|
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
|
-
|
|
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();
|
package/dist/webhooks.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
15
14
|
import { join } from "node:path";
|
|
16
15
|
import { envValue } from "./names.js";
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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 =>
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
250
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
}
|
package/dist/work-index.js
CHANGED
|
@@ -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
|
|
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;
|