toolroll 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/dist/agent-onboard.d.ts +7 -0
- package/dist/agent-onboard.js +23 -5
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -20
- package/dist/browser-workspace.d.ts +10 -0
- package/dist/chat-delivery.js +51 -3
- package/dist/chat-quiet.d.ts +28 -0
- package/dist/chat-quiet.js +161 -0
- package/dist/checkout-cleanup.d.ts +84 -0
- package/dist/checkout-cleanup.js +245 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -0
- package/dist/digest-times.d.ts +3 -0
- package/dist/digest-times.js +7 -0
- package/dist/dispose.js +2 -0
- package/dist/export.js +1 -1
- package/dist/first-run.d.ts +4 -1
- package/dist/first-run.js +2 -1
- package/dist/integrations-ui.d.ts +13 -0
- package/dist/integrations-ui.js +65 -0
- package/dist/integrations.d.ts +113 -0
- package/dist/integrations.js +526 -0
- package/dist/mcp.js +26 -0
- package/dist/operate.d.ts +5 -6
- package/dist/operate.js +254 -25
- package/dist/project-delete.js +1 -0
- package/dist/prompt.d.ts +7 -2
- package/dist/prompt.js +36 -7
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +9 -2
- package/dist/pull-request-flow.d.ts +140 -0
- package/dist/pull-request-flow.js +440 -0
- package/dist/pull-request-schema.d.ts +5 -0
- package/dist/pull-request-schema.js +21 -0
- package/dist/pull-request-ui.d.ts +23 -0
- package/dist/pull-request-ui.js +55 -0
- package/dist/pulls.d.ts +5 -0
- package/dist/pulls.js +11 -0
- package/dist/serve.d.ts +6 -0
- package/dist/serve.js +323 -26
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage-ui.d.ts +10 -0
- package/dist/storage-ui.js +67 -0
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +62 -7
- package/dist/store.d.ts +66 -5
- package/dist/store.js +160 -19
- package/dist/surface.js +2 -0
- package/dist/telegram-progress.d.ts +1 -0
- package/dist/telegram-progress.js +1 -1
- package/dist/telegram.js +95 -15
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +1 -1
- package/dist/toolroll-update.d.ts +26 -6
- package/dist/toolroll-update.js +188 -50
- package/dist/worktree.d.ts +24 -10
- package/dist/worktree.js +84 -44
- package/package.json +1 -1
package/dist/setup-guide.d.ts
CHANGED
|
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
|
|
|
15
15
|
} | null;
|
|
16
16
|
/** Browser installation may write only this known file inside its project. */
|
|
17
17
|
export declare function previewProjectInstructions(repo: string): {
|
|
18
|
-
content?: never;
|
|
19
18
|
ok: false;
|
|
20
19
|
message: string;
|
|
21
20
|
plan?: never;
|
|
21
|
+
content?: never;
|
|
22
22
|
fingerprint?: never;
|
|
23
23
|
installed?: never;
|
|
24
24
|
} | {
|
|
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
|
|
|
30
30
|
installed: boolean;
|
|
31
31
|
};
|
|
32
32
|
export declare function addProjectInstructions(repo: string, fingerprint: string): {
|
|
33
|
-
content?: never;
|
|
34
33
|
ok: false;
|
|
35
34
|
message: string;
|
|
36
35
|
plan?: never;
|
|
36
|
+
content?: never;
|
|
37
37
|
fingerprint?: never;
|
|
38
38
|
installed?: never;
|
|
39
39
|
} | import("./skills.js").InstallResult;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type CheckoutPlan } from "./checkout-cleanup.js";
|
|
2
|
+
export declare const STORAGE_CSS: string;
|
|
3
|
+
export type StorageView = {
|
|
4
|
+
plan: CheckoutPlan;
|
|
5
|
+
csrf: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function storageHtml(view: StorageView, notice: {
|
|
8
|
+
said?: string | null;
|
|
9
|
+
problem?: string | null;
|
|
10
|
+
}): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings → Storage: how much space task checkouts use, what a clean-up would free now, a Clean up button whose
|
|
3
|
+
* preview (what goes, what stays and why, with paths) sits behind the password, and when a finished task's clean
|
|
4
|
+
* checkout goes by itself. An instance operator's page.
|
|
5
|
+
*/
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
import { previewDigest, whyWords } from "./checkout-cleanup.js";
|
|
9
|
+
import { CLEANUP_CHOICES, bytesWords } from "./storage.js";
|
|
10
|
+
const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
11
|
+
const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
|
|
12
|
+
export const STORAGE_CSS = `.storage{max-width:720px;min-width:0;overflow-wrap:anywhere}.storage h2{font-size:1.0625rem;margin:24px 0 4px}` +
|
|
13
|
+
`.storage .storage-state{display:grid;gap:12px}.storage .storage-state h2{margin:0}.storage .storage-state p{margin:0}` +
|
|
14
|
+
`.storage details.clean-up,.storage details.discard{border:0;padding:0;margin:0;background:transparent}` +
|
|
15
|
+
`.storage details.clean-up>summary{list-style:none;display:inline-flex;align-items:center;min-height:2.125rem;padding:.35rem .75rem;border-radius:calc(var(--radius) - 3px);` +
|
|
16
|
+
`background:var(--primary);color:var(--primary-foreground);font-weight:600;font-size:.8125rem;cursor:pointer;white-space:nowrap}` +
|
|
17
|
+
`.storage details.clean-up>summary::-webkit-details-marker{display:none}.storage details.clean-up[open]>summary{background:var(--card);color:var(--foreground);border:1px solid var(--input)}` +
|
|
18
|
+
`.storage .preview{display:grid;gap:12px;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)}.storage .preview h3{font-size:.875rem;margin:0}` +
|
|
19
|
+
`.storage ul.checkouts{list-style:none;padding:0;margin:0}` +
|
|
20
|
+
`.storage ul.checkouts li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 12px;padding:8px 0;border-top:1px solid var(--border);font-size:.8125rem}` +
|
|
21
|
+
`.storage ul.checkouts .name{font-weight:500;min-width:0}.storage ul.checkouts .size{font-variant-numeric:tabular-nums;white-space:nowrap}` +
|
|
22
|
+
`.storage ul.checkouts .path{grid-column:1/-1;font-family:var(--font-mono, ui-monospace, monospace);font-size:.6875rem;color:var(--muted-foreground);word-break:break-all}` +
|
|
23
|
+
`.storage ul.checkouts .why{grid-column:1/-1}` +
|
|
24
|
+
`.storage details.discard{grid-column:1/-1}.storage details.discard>summary{cursor:pointer;color:var(--destructive);min-height:2.25rem;display:inline-flex;align-items:center}` +
|
|
25
|
+
`.storage form{display:grid;gap:10px;margin:0}.storage label{display:grid;gap:4px;font-size:.8125rem;font-weight:500;max-width:20rem}` +
|
|
26
|
+
`.storage select,.storage input[type=password]{width:100%;box-sizing:border-box;min-height:2.125rem}.storage form button{justify-self:start;white-space:nowrap}` +
|
|
27
|
+
`.storage form.setting button[type=submit],.storage .preview form.remove button[type=submit]{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary);font-weight:600}` +
|
|
28
|
+
`@media (max-width:900px){.storage details.clean-up>summary,.storage form button{min-height:44px}.storage label{max-width:none}.storage select,.storage input[type=password]{min-height:44px;font-size:16px}.storage .preview form.remove button{width:100%}}`;
|
|
29
|
+
function item(one, csrf) {
|
|
30
|
+
const discard = one.why === "has changes"
|
|
31
|
+
? `<details class="discard"><summary>Discard</summary><form method="post" action="/settings/storage/discard"><input type="hidden" name="csrf" value="${e(csrf)}"><input type="hidden" name="path" value="${e(one.path)}">` +
|
|
32
|
+
`<p class="meta">Throws away this checkout and its uncommitted changes. Its branch stays.</p>` +
|
|
33
|
+
`<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button type="submit" class="danger">Discard changes</button></form></details>`
|
|
34
|
+
: "";
|
|
35
|
+
// The task it was for, then where it is (the home folder as ~), then why it stays.
|
|
36
|
+
const home = homedir();
|
|
37
|
+
const where = home !== "" && one.path.startsWith(`${home}/`) ? `~${one.path.slice(home.length)}` : one.path;
|
|
38
|
+
return `<li data-checkout="${e(one.path)}"><span class="name">${e(one.taskId ?? basename(one.path))}</span><span class="size">${e(bytesWords(one.bytes))}</span>` +
|
|
39
|
+
`<span class="path">${e(where)}</span>` +
|
|
40
|
+
(one.why === null ? "" : `<span class="why">${e(whyWords(one)[0].toUpperCase() + whyWords(one).slice(1))}</span>`) + discard + `</li>`;
|
|
41
|
+
}
|
|
42
|
+
export function storageHtml(view, notice) {
|
|
43
|
+
const { plan, csrf } = view;
|
|
44
|
+
const note = notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
|
|
45
|
+
const hidden = `<input type="hidden" name="csrf" value="${e(csrf)}">`;
|
|
46
|
+
const facts = [plural(plan.count, "checkout"), `${plan.waitingReview} waiting for review`, `${plan.withChanges} kept for their changes`].join(" · ");
|
|
47
|
+
const frees = plan.go.length === 0 ? "Nothing to clean up now." : `Cleaning up now frees about ${bytesWords(plan.freeBytes)}.`;
|
|
48
|
+
const remove = plan.go.length === 0 ? "" :
|
|
49
|
+
`<form method="post" action="/settings/storage/clean" class="remove">${hidden}<input type="hidden" name="preview" value="${e(previewDigest(plan))}">` +
|
|
50
|
+
`<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
|
|
51
|
+
`<button type="submit">Remove ${plural(plan.go.length, "checkout")}</button></form>`;
|
|
52
|
+
const preview = `<details class="clean-up" data-clean-up${notice.problem?.startsWith("Enter your Toolroll password to clean") ? " open" : ""}><summary>Clean up</summary><div class="preview">` +
|
|
53
|
+
(plan.go.length === 0 ? `<p>Nothing to remove now.</p>` :
|
|
54
|
+
`<h3>Removes ${plural(plan.go.length, "checkout")}, about ${e(bytesWords(plan.freeBytes))}</h3><p class="meta">Their branches stay, so nothing committed is lost.</p>` +
|
|
55
|
+
`<ul class="checkouts" data-goes>${plan.go.map(one => item(one, csrf)).join("")}</ul>`) +
|
|
56
|
+
(plan.stay.length === 0 ? "" : `<h3>Stays (${plan.stay.length})</h3><ul class="checkouts" data-stays>${plan.stay.map(one => item(one, csrf)).join("")}</ul>`) +
|
|
57
|
+
remove + `</div></details>`;
|
|
58
|
+
const options = CLEANUP_CHOICES.map(one => `<option value="${one.value}"${one.value === plan.cleanup ? " selected" : ""}>${e(one.label)}</option>`).join("");
|
|
59
|
+
return `<section class="storage">${note}` +
|
|
60
|
+
`<div class="card storage-state"><h2 data-checkout-bytes="${plan.totalBytes}">Checkouts use ${e(bytesWords(plan.totalBytes))}</h2><p class="meta">${e(facts)}</p>` +
|
|
61
|
+
`<p data-clean-bytes="${plan.freeBytes}">${e(frees)}</p>${preview}</div>` +
|
|
62
|
+
`<h2>Automatic cleanup</h2>` +
|
|
63
|
+
`<form method="post" action="/settings/storage" class="setting">${hidden}` +
|
|
64
|
+
`<label>Remove a finished task's clean checkout<select name="cleanup">${options}</select></label>` +
|
|
65
|
+
`<p class="meta">Its branch always stays. Checkouts with changes, commits on no branch, in use, or waiting for review are kept.</p>` +
|
|
66
|
+
`<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">Save</button></form></section>`;
|
|
67
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import type { Store } from "./store.js";
|
|
2
|
+
/** Settings → Storage: when a finished task's clean checkout is removed. One row, or none (the default). */
|
|
3
|
+
export declare const CHECKOUT_CLEANUP_SCHEMA = "\nCREATE TABLE IF NOT EXISTS checkout_cleanup_setting (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n cleanup TEXT NOT NULL CHECK (cleanup IN ('finished', '2d', '7d', 'never')),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n";
|
|
4
|
+
export type CheckoutCleanup = "finished" | "2d" | "7d" | "never";
|
|
5
|
+
export declare const DEFAULT_CLEANUP: CheckoutCleanup;
|
|
6
|
+
export declare const CLEANUP_CHOICES: readonly {
|
|
7
|
+
value: CheckoutCleanup;
|
|
8
|
+
label: string;
|
|
9
|
+
days: number | null;
|
|
10
|
+
}[];
|
|
11
|
+
/** `finished` (or complete, cancelled), `2d`, `7d` (or week, 1w), `never` (or off); undefined when it's none of those. */
|
|
12
|
+
export declare function parseCleanup(text: string): CheckoutCleanup | undefined;
|
|
13
|
+
export declare function cleanupWords(cleanup: CheckoutCleanup): string;
|
|
14
|
+
/** Bytes each path holds on disk: one `du` for them all (fast on a 300 MB checkout), a walk where it can't answer. */
|
|
15
|
+
export declare function diskBytes(paths: readonly string[]): Map<string, number>;
|
|
2
16
|
export type StorageLine = {
|
|
3
17
|
what: string;
|
|
4
18
|
bytes: number;
|
|
@@ -7,7 +21,7 @@ export type StorageLine = {
|
|
|
7
21
|
};
|
|
8
22
|
/** Bytes a tree holds on disk (allocated blocks; links not followed). */
|
|
9
23
|
export declare function treeBytes(path: string): number;
|
|
10
|
-
export declare function storageReport(store: Store, databaseFile: string,
|
|
24
|
+
export declare function storageReport(store: Store, databaseFile: string, checkoutNote?: string): {
|
|
11
25
|
folder: string;
|
|
12
26
|
total: number;
|
|
13
27
|
lines: StorageLine[];
|
package/dist/storage.js
CHANGED
|
@@ -2,11 +2,67 @@
|
|
|
2
2
|
* Where Toolroll's disk goes (`toolroll storage`): the folder
|
|
3
3
|
* beside the database, by kind, and what storage retention takes care of.
|
|
4
4
|
* Build checkouts and staged releases are the big ones; the worker removes a
|
|
5
|
-
* finished task's clean checkout
|
|
6
|
-
* keeps its last few releases.
|
|
5
|
+
* finished task's clean checkout as the checkout cleanup setting says
|
|
6
|
+
* (checkout-cleanup.ts), and a deploy keeps its last few releases.
|
|
7
7
|
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
8
9
|
import { lstatSync, readdirSync } from "node:fs";
|
|
9
10
|
import { basename, dirname, join } from "node:path";
|
|
11
|
+
/** Settings → Storage: when a finished task's clean checkout is removed. One row, or none (the default). */
|
|
12
|
+
export const CHECKOUT_CLEANUP_SCHEMA = `
|
|
13
|
+
CREATE TABLE IF NOT EXISTS checkout_cleanup_setting (
|
|
14
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
15
|
+
cleanup TEXT NOT NULL CHECK (cleanup IN ('finished', '2d', '7d', 'never')),
|
|
16
|
+
updated_by TEXT NOT NULL,
|
|
17
|
+
updated_at TEXT NOT NULL
|
|
18
|
+
);
|
|
19
|
+
`;
|
|
20
|
+
export const DEFAULT_CLEANUP = "finished";
|
|
21
|
+
export const CLEANUP_CHOICES = [
|
|
22
|
+
{ value: "finished", label: "When its task is complete or cancelled", days: 0 },
|
|
23
|
+
{ value: "2d", label: "2 days after that", days: 2 },
|
|
24
|
+
{ value: "7d", label: "A week after that", days: 7 },
|
|
25
|
+
{ value: "never", label: "Never (only when you clean up)", days: null },
|
|
26
|
+
];
|
|
27
|
+
/** `finished` (or complete, cancelled), `2d`, `7d` (or week, 1w), `never` (or off); undefined when it's none of those. */
|
|
28
|
+
export function parseCleanup(text) {
|
|
29
|
+
const value = text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
30
|
+
if (["finished", "complete", "completed", "cancelled", "done", "0", "0d"].includes(value))
|
|
31
|
+
return "finished";
|
|
32
|
+
if (["2d", "2", "2 days", "2days"].includes(value))
|
|
33
|
+
return "2d";
|
|
34
|
+
if (["7d", "7", "7 days", "7days", "week", "a week", "1w"].includes(value))
|
|
35
|
+
return "7d";
|
|
36
|
+
if (["never", "off"].includes(value))
|
|
37
|
+
return "never";
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
export function cleanupWords(cleanup) {
|
|
41
|
+
return cleanup === "finished" ? "when its task is complete or cancelled"
|
|
42
|
+
: cleanup === "never" ? "never (only when you clean up)"
|
|
43
|
+
: `${cleanup === "2d" ? "2 days" : "a week"} after its task is complete or cancelled`;
|
|
44
|
+
}
|
|
45
|
+
/** Bytes each path holds on disk: one `du` for them all (fast on a 300 MB checkout), a walk where it can't answer. */
|
|
46
|
+
export function diskBytes(paths) {
|
|
47
|
+
const sizes = new Map();
|
|
48
|
+
if (paths.length === 0)
|
|
49
|
+
return sizes;
|
|
50
|
+
if (process.platform !== "win32") {
|
|
51
|
+
for (let at = 0; at < paths.length; at += 200) {
|
|
52
|
+
const batch = paths.slice(at, at + 200);
|
|
53
|
+
const du = spawnSync("du", ["-sk", ...batch], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
54
|
+
for (const line of (du.stdout ?? "").split("\n")) {
|
|
55
|
+
const match = /^(\d+)\t(.+)$/.exec(line);
|
|
56
|
+
if (match !== null && batch.includes(match[2]))
|
|
57
|
+
sizes.set(match[2], Number(match[1]) * 1024);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const path of paths)
|
|
62
|
+
if (!sizes.has(path))
|
|
63
|
+
sizes.set(path, treeBytes(path));
|
|
64
|
+
return sizes;
|
|
65
|
+
}
|
|
10
66
|
/** Bytes a tree holds on disk (allocated blocks; links not followed). */
|
|
11
67
|
export function treeBytes(path) {
|
|
12
68
|
let total = 0;
|
|
@@ -35,7 +91,7 @@ export function treeBytes(path) {
|
|
|
35
91
|
}
|
|
36
92
|
return total;
|
|
37
93
|
}
|
|
38
|
-
export function storageReport(store, databaseFile,
|
|
94
|
+
export function storageReport(store, databaseFile, checkoutNote) {
|
|
39
95
|
const folder = dirname(databaseFile);
|
|
40
96
|
const base = basename(databaseFile);
|
|
41
97
|
let names = [];
|
|
@@ -45,7 +101,8 @@ export function storageReport(store, databaseFile, now, keepMs) {
|
|
|
45
101
|
catch {
|
|
46
102
|
names = [];
|
|
47
103
|
}
|
|
48
|
-
const
|
|
104
|
+
const measured = diskBytes(names.map(name => join(folder, name)));
|
|
105
|
+
const sizes = new Map(names.map(name => [name, measured.get(join(folder, name)) ?? 0]));
|
|
49
106
|
const take = (match) => {
|
|
50
107
|
let bytes = 0;
|
|
51
108
|
for (const [name, size] of sizes)
|
|
@@ -56,7 +113,6 @@ export function storageReport(store, databaseFile, now, keepMs) {
|
|
|
56
113
|
return bytes;
|
|
57
114
|
};
|
|
58
115
|
const checkouts = store.listWorktrees();
|
|
59
|
-
const due = checkouts.filter(row => row.releasedAt !== null && row.runner === null && now.getTime() - Date.parse(row.releasedAt) >= keepMs).length;
|
|
60
116
|
let staged = 0;
|
|
61
117
|
try {
|
|
62
118
|
staged = readdirSync(join(folder, "staged-upgrades")).length;
|
|
@@ -66,8 +122,7 @@ export function storageReport(store, databaseFile, now, keepMs) {
|
|
|
66
122
|
}
|
|
67
123
|
const lines = [
|
|
68
124
|
{ what: "Database", bytes: take(name => name.startsWith(base)) },
|
|
69
|
-
{ what: "Build checkouts", bytes: take(name => name === "worktrees"), count: checkouts.length,
|
|
70
|
-
note: due === 0 ? "none due for removal" : `${due} let go over ${Math.round(keepMs / 86_400_000)} days ago: removed on the next pass if their task is finished and they're clean` },
|
|
125
|
+
{ what: "Build checkouts", bytes: take(name => name === "worktrees"), count: checkouts.length, ...(checkoutNote === undefined ? {} : { note: checkoutNote }) },
|
|
71
126
|
{ what: "Staged releases", bytes: take(name => name === "staged-upgrades"), count: staged, note: "a deploy keeps the release it installed, the one before and the newest few" },
|
|
72
127
|
{ what: "Evidence", bytes: take(name => name === "evidence"), note: "the audit record of every run; kept" },
|
|
73
128
|
{ what: "Backups", bytes: take(name => name === "backups") },
|
package/dist/store.d.ts
CHANGED
|
@@ -16,8 +16,13 @@ import type { LimitReading, LimitWindow } from "./provider-limits.js";
|
|
|
16
16
|
import { underCeiling, type OrgPolicy, type SavedPolicy } from "./policy.js";
|
|
17
17
|
import { type Billing, type Budget, type BudgetAgent, type BudgetHold, type BudgetScope, type BudgetState, type SpendItem } from "./spend.js";
|
|
18
18
|
import { type RetentionKind, type RetentionPeriods } from "./retention.js";
|
|
19
|
+
import { type CheckoutCleanup } from "./storage.js";
|
|
19
20
|
import { type LedgerChainReport } from "./ledger-chain.js";
|
|
20
21
|
import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer } from "./approval-policy.js";
|
|
22
|
+
/** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
|
|
23
|
+
* nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
|
|
24
|
+
* failure across checks, so a row can say both. `account` is a display name only, never a credential. */
|
|
25
|
+
export declare const INTEGRATIONS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS integration_check (\n key TEXT PRIMARY KEY,\n outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'failed', 'absent')),\n account TEXT,\n detail TEXT,\n problem TEXT,\n checked_at TEXT NOT NULL,\n ok_at TEXT,\n error TEXT,\n error_at TEXT\n);\n";
|
|
21
26
|
export type BackupSettings = {
|
|
22
27
|
enabled: boolean;
|
|
23
28
|
everyHours: number;
|
|
@@ -943,6 +948,26 @@ export type TelegramDelivery = Notification & {
|
|
|
943
948
|
destination: string;
|
|
944
949
|
claimGeneration: number;
|
|
945
950
|
};
|
|
951
|
+
/** How chats reach one person (quiet chat): only when they are needed, or every step. */
|
|
952
|
+
export type NotificationMode = "quiet" | "all";
|
|
953
|
+
export type NotificationPreference = {
|
|
954
|
+
mode: NotificationMode;
|
|
955
|
+
digestAt: string | null;
|
|
956
|
+
digestOn: string | null;
|
|
957
|
+
updatedAt: string | null;
|
|
958
|
+
};
|
|
959
|
+
/** Tasks filed this close together share one chat message. */
|
|
960
|
+
export declare const QUIET_GROUP_MS = 60000;
|
|
961
|
+
export declare function isDigestTime(value: string): boolean;
|
|
962
|
+
/** The one chat message a destination keeps for a task, or for several tasks filed together. */
|
|
963
|
+
export type ChatCard = {
|
|
964
|
+
id: number;
|
|
965
|
+
destination: string;
|
|
966
|
+
anchorAt: string;
|
|
967
|
+
message: string | null;
|
|
968
|
+
digest: string | null;
|
|
969
|
+
tasks: number[];
|
|
970
|
+
};
|
|
946
971
|
/**
|
|
947
972
|
* What one recovery walk over a gone runner's work settled (P0.1a): the run
|
|
948
973
|
* ids it finished as failed/interrupted and the built-in task ids it returned
|
|
@@ -1543,6 +1568,11 @@ export type PublicationGrant = {
|
|
|
1543
1568
|
merge?: boolean;
|
|
1544
1569
|
mergeMethod?: "squash" | "merge" | "rebase" | null;
|
|
1545
1570
|
mergeDeleteBranch?: boolean;
|
|
1571
|
+
/** When a pull request is owed: every finished build ("build", the original grant), or only when a person
|
|
1572
|
+
* chooses "Complete and open a pull request" ("complete", the one-step project setup). */
|
|
1573
|
+
publishOn?: "build" | "complete";
|
|
1574
|
+
/** The project's optional "Merge when checks pass", off by default (complete-mode grants only). */
|
|
1575
|
+
mergeWhenGreen?: boolean;
|
|
1546
1576
|
};
|
|
1547
1577
|
/** One run's road to a PR, durable at every phase. */
|
|
1548
1578
|
export type Publication = {
|
|
@@ -2645,11 +2675,6 @@ export declare class Store {
|
|
|
2645
2675
|
getWorktree(path: string): WorktreeRow | null;
|
|
2646
2676
|
/** Drop a row whose directory is gone; a lease over nothing only refuses work. */
|
|
2647
2677
|
forgetWorktree(path: string): void;
|
|
2648
|
-
/** The branches storage retention keeps a repository's checkouts of: every task not done or cancelled (a failed
|
|
2649
|
-
* one included: it can be requeued), and unplaced ones (they're dispatched anywhere); and a done task whose result
|
|
2650
|
-
* was marked complete (the completion names its family's root; its run names the version), or a release candidate
|
|
2651
|
-
* done, within `resultMs` (a deploy installs the build in its checkout). Each task's own branch and every branch its runs used. */
|
|
2652
|
-
keptBranches(repo: string, now: Date, resultMs: number): string[];
|
|
2653
2678
|
/**
|
|
2654
2679
|
* Approve one repo's setup, revoking any predecessor in the same
|
|
2655
2680
|
* transaction — exactly one live setup per repo, enforced by the partial
|
|
@@ -5068,6 +5093,10 @@ export declare class Store {
|
|
|
5068
5093
|
retentionPeriods(): RetentionPeriods;
|
|
5069
5094
|
/** Set how long one kind is kept (null: forever); the ledger keeps before → after. */
|
|
5070
5095
|
setRetentionPeriod(kind: RetentionKind, days: number | null, by: string, now: Date): RetentionPeriods;
|
|
5096
|
+
/** Settings → Storage: when a finished task's clean checkout is removed (the default until someone chooses). */
|
|
5097
|
+
checkoutCleanup(): CheckoutCleanup;
|
|
5098
|
+
/** Choose when a finished task's clean checkout is removed; the ledger keeps before → after. */
|
|
5099
|
+
setCheckoutCleanup(cleanup: CheckoutCleanup, by: string, now: Date): CheckoutCleanup;
|
|
5071
5100
|
/** v105: keep a provider's latest reading of its plan's usage windows (a window it no longer reports is dropped). */
|
|
5072
5101
|
recordProviderLimits(reading: LimitReading, now: Date): void;
|
|
5073
5102
|
/** The latest reading of every provider's plan windows, shortest window first. */
|
|
@@ -5876,6 +5905,8 @@ export declare class Store {
|
|
|
5876
5905
|
merge?: boolean;
|
|
5877
5906
|
mergeMethod?: "squash" | "merge" | "rebase" | null;
|
|
5878
5907
|
mergeDeleteBranch?: boolean;
|
|
5908
|
+
publishOn?: "build" | "complete";
|
|
5909
|
+
mergeWhenGreen?: boolean;
|
|
5879
5910
|
}, now: Date): void;
|
|
5880
5911
|
/**
|
|
5881
5912
|
* Reserve the next observation generation for a PR BEFORE the network
|
|
@@ -7256,6 +7287,8 @@ export declare class Store {
|
|
|
7256
7287
|
/** A confirmed network message remains history even if authority changed
|
|
7257
7288
|
* while it was in flight. It only binds the OLD destination, never new work. */
|
|
7258
7289
|
recordTelegramMessage(row: TelegramDelivery, binding: TelegramBinding, messageId: string, now: Date): void;
|
|
7290
|
+
/** The newest confirmed message this notification became at one destination, if any. */
|
|
7291
|
+
telegramMessageOf(notification: number, destination: string): string | null;
|
|
7259
7292
|
finalizeTelegramDelivery(row: TelegramDelivery, binding: TelegramBinding, owner: string, outcome: {
|
|
7260
7293
|
ok: true;
|
|
7261
7294
|
receipt: string | null;
|
|
@@ -7287,6 +7320,34 @@ export declare class Store {
|
|
|
7287
7320
|
/** Set (or clear, with null) the cadence. Turning it on starts the
|
|
7288
7321
|
* window NOW — nothing held before the choice is dumped at once. */
|
|
7289
7322
|
setTelegramDigest(everyMs: number | null, by: string, now: Date): void;
|
|
7323
|
+
/** How chats reach this person: only when they are needed unless they chose every step. */
|
|
7324
|
+
notificationPreference(account: string): NotificationPreference;
|
|
7325
|
+
/** Change one person's choice; a digest time is a local HH:MM, or null for no evening digest. */
|
|
7326
|
+
setNotificationPreference(account: string, change: {
|
|
7327
|
+
mode?: NotificationMode;
|
|
7328
|
+
digestAt?: string | null;
|
|
7329
|
+
}, by: string, now: Date): NotificationPreference;
|
|
7330
|
+
/** Everyone who asked for an evening digest, with the local day theirs was last considered. */
|
|
7331
|
+
eveningDigestPeople(): {
|
|
7332
|
+
account: string;
|
|
7333
|
+
digestAt: string;
|
|
7334
|
+
digestOn: string | null;
|
|
7335
|
+
}[];
|
|
7336
|
+
/** Claim one local day's digest for one person: true once, false if it was already considered. */
|
|
7337
|
+
claimEveningDigestDay(account: string, day: string): boolean;
|
|
7338
|
+
/** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
|
|
7339
|
+
* a minute of it (up to eight tasks), or starts its own. */
|
|
7340
|
+
chatCardFor(destination: string, taskRef: number, filedAt: string, now: Date): ChatCard;
|
|
7341
|
+
chatCard(id: number): ChatCard | null;
|
|
7342
|
+
/** The newest lifecycle fact a task has recorded — what its card says before any attempt starts. */
|
|
7343
|
+
latestTaskFact(taskRef: number): {
|
|
7344
|
+
kind: string;
|
|
7345
|
+
createdAt: string;
|
|
7346
|
+
} | null;
|
|
7347
|
+
/** Task-bound facts recorded since an instant, newest last (bounded): what an evening digest reads. */
|
|
7348
|
+
taskFactsSince(since: string, limit?: number): Notification[];
|
|
7349
|
+
/** The message now showing this card, and what it shows. */
|
|
7350
|
+
setChatCardMessage(id: number, message: string, digest: string): void;
|
|
7290
7351
|
markTelegramDigestSent(now: Date): void;
|
|
7291
7352
|
/** Every batched row finalized delivered AND the anchor moved, in ONE
|
|
7292
7353
|
* transaction (v4 review, finding 5): a digest is either recorded whole
|
package/dist/store.js
CHANGED
|
@@ -67,9 +67,11 @@ import { POLICY_SCHEMA, agentRefusal, approvalRefusal, attendedRefusal, policyPa
|
|
|
67
67
|
import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
|
|
68
68
|
import { SPEND_SCHEMA, billingOf, budgetStates, canPrice, claudeMachineBilling, countsToward, filersOf, monthOf, priceWork, seenBilling as seenBillingOf, spendItems, teammateFilers, usd } from "./spend.js";
|
|
69
69
|
import { FOREVER, RETENTION_SCHEMA, periodWords } from "./retention.js";
|
|
70
|
+
import { CHECKOUT_CLEANUP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storage.js";
|
|
70
71
|
import { IN_RANGE, LEDGER_CHAIN_SCHEMA, safeWhole, sealLedger, verifyLedgerChain } from "./ledger-chain.js";
|
|
71
72
|
import { NO_RULES, filerFor, isProtectedWork, protectedChanges, rulesWords } from "./approval-policy.js";
|
|
72
|
-
import {
|
|
73
|
+
import { configBase, envValue, namedPath } from "./names.js";
|
|
74
|
+
import { PULL_REQUEST_SCHEMA } from "./pull-request-schema.js";
|
|
73
75
|
// v60 fenced older readers before the chat action cards; v61 adds notification
|
|
74
76
|
// provenance and Telegram destination receipts, and readers below v61 refuse it.
|
|
75
77
|
// v62 adds the durable Telegram conversation queue and proposal-card tokens,
|
|
@@ -130,6 +132,54 @@ CREATE TABLE IF NOT EXISTS monitoring_status (
|
|
|
130
132
|
held_until TEXT
|
|
131
133
|
);
|
|
132
134
|
`;
|
|
135
|
+
/** Quiet chat: each person's choice of how chats reach them — only when they are needed (the default, no row),
|
|
136
|
+
* every step, and an optional evening digest at a local HH:MM. `digest_on` is the local day the last evening
|
|
137
|
+
* digest was considered, so each day is judged once. A task's card is the one chat message a destination keeps
|
|
138
|
+
* for it (or for several tasks filed within a minute): `message` is the Telegram message id or the chat app's
|
|
139
|
+
* part id, `digest` the text last shown, so an unchanged card is never re-sent. */
|
|
140
|
+
const QUIET_CHAT_SCHEMA = `
|
|
141
|
+
CREATE TABLE IF NOT EXISTS notification_preference (
|
|
142
|
+
account TEXT PRIMARY KEY,
|
|
143
|
+
mode TEXT NOT NULL DEFAULT 'quiet' CHECK (mode IN ('quiet', 'all')),
|
|
144
|
+
digest_at TEXT CHECK (digest_at IS NULL OR (length(digest_at) = 5 AND digest_at GLOB '[0-2][0-9]:[0-5][0-9]')),
|
|
145
|
+
digest_on TEXT,
|
|
146
|
+
updated_at TEXT NOT NULL,
|
|
147
|
+
updated_by TEXT NOT NULL
|
|
148
|
+
);
|
|
149
|
+
CREATE TABLE IF NOT EXISTS chat_card (
|
|
150
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
151
|
+
destination TEXT NOT NULL,
|
|
152
|
+
anchor_at TEXT NOT NULL,
|
|
153
|
+
message TEXT,
|
|
154
|
+
digest TEXT,
|
|
155
|
+
created_at TEXT NOT NULL
|
|
156
|
+
);
|
|
157
|
+
CREATE INDEX IF NOT EXISTS chat_card_destination ON chat_card(destination, anchor_at);
|
|
158
|
+
CREATE TABLE IF NOT EXISTS chat_card_task (
|
|
159
|
+
destination TEXT NOT NULL,
|
|
160
|
+
task_ref INTEGER NOT NULL,
|
|
161
|
+
card INTEGER NOT NULL REFERENCES chat_card(id),
|
|
162
|
+
joined_at TEXT NOT NULL,
|
|
163
|
+
PRIMARY KEY (destination, task_ref)
|
|
164
|
+
);
|
|
165
|
+
CREATE INDEX IF NOT EXISTS chat_card_task_card ON chat_card_task(card);
|
|
166
|
+
`;
|
|
167
|
+
/** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
|
|
168
|
+
* nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
|
|
169
|
+
* failure across checks, so a row can say both. `account` is a display name only, never a credential. */
|
|
170
|
+
export const INTEGRATIONS_SCHEMA = `
|
|
171
|
+
CREATE TABLE IF NOT EXISTS integration_check (
|
|
172
|
+
key TEXT PRIMARY KEY,
|
|
173
|
+
outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'failed', 'absent')),
|
|
174
|
+
account TEXT,
|
|
175
|
+
detail TEXT,
|
|
176
|
+
problem TEXT,
|
|
177
|
+
checked_at TEXT NOT NULL,
|
|
178
|
+
ok_at TEXT,
|
|
179
|
+
error TEXT,
|
|
180
|
+
error_at TEXT
|
|
181
|
+
);
|
|
182
|
+
`;
|
|
133
183
|
/** Sprint 8: database backups. One settings row (off, or every so many hours, keeping the newest N in a folder; no
|
|
134
184
|
* folder means `backups` beside the database), every backup's outcome, and a lease the console's backup loop holds
|
|
135
185
|
* and renews while it runs: one process backs up at a time, and a restore sees that the console is running. */
|
|
@@ -581,6 +631,11 @@ const LIFECYCLE_HOLD_WORDS = {
|
|
|
581
631
|
revision: "a plan revision awaiting approval",
|
|
582
632
|
stop: "a stopped attempt that has not been resumed",
|
|
583
633
|
};
|
|
634
|
+
/** Tasks filed this close together share one chat message. */
|
|
635
|
+
export const QUIET_GROUP_MS = 60_000;
|
|
636
|
+
export function isDigestTime(value) {
|
|
637
|
+
return /^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/.test(value);
|
|
638
|
+
}
|
|
584
639
|
/** The bounded activity vocabulary. Machine-authored — a model's prose never becomes one of these. */
|
|
585
640
|
export const RUN_PHASES = [
|
|
586
641
|
"agent-running",
|
|
@@ -3689,16 +3744,19 @@ function initializeStore(db, file) {
|
|
|
3689
3744
|
db.exec(APPROVAL_SCHEMA);
|
|
3690
3745
|
db.exec(LEDGER_CHAIN_SCHEMA);
|
|
3691
3746
|
db.exec(MONITORING_SCHEMA);
|
|
3747
|
+
db.exec(INTEGRATIONS_SCHEMA);
|
|
3692
3748
|
db.exec(SPEND_SCHEMA);
|
|
3693
3749
|
// Sign-in pauses: one row per incident of a provider's sign-in no longer working.
|
|
3694
3750
|
db.exec(PROVIDER_AUTH_SCHEMA);
|
|
3695
3751
|
// The sign-in pause the dispatch gate last left this task waiting on (null: none), for the work index.
|
|
3696
3752
|
addColumn(db, "task_ref", "auth_wait_pause", "INTEGER REFERENCES provider_auth_pause(id)");
|
|
3697
3753
|
db.exec(RETENTION_SCHEMA);
|
|
3754
|
+
db.exec(CHECKOUT_CLEANUP_SCHEMA);
|
|
3698
3755
|
db.exec(BACKUP_SCHEMA);
|
|
3699
3756
|
// Sprint 8: the organisation policy (one row, or none: nothing restricted).
|
|
3700
3757
|
db.exec(POLICY_SCHEMA);
|
|
3701
3758
|
db.exec(RUN_CHECK_SCHEMA);
|
|
3759
|
+
db.exec(QUIET_CHAT_SCHEMA);
|
|
3702
3760
|
mergeCheckTables(db);
|
|
3703
3761
|
addColumn(db, "monitoring_status", "target", "TEXT");
|
|
3704
3762
|
// v105: how a teammate's turn was billed (this computer's Claude sign-in, as last seen).
|
|
@@ -3707,6 +3765,11 @@ function initializeStore(db, file) {
|
|
|
3707
3765
|
addColumn(db, "run_spend", "billing", "TEXT NOT NULL DEFAULT 'subscription' CHECK (billing IN ('subscription', 'api-key'))");
|
|
3708
3766
|
addColumn(db, "run_spend", "billing_fixed", "INTEGER NOT NULL DEFAULT 0");
|
|
3709
3767
|
migrate(db, preflight === null ? null : Math.abs(preflight));
|
|
3768
|
+
// Complete → pull request → merge: when a grant publishes, the project's "Merge when checks pass", and each
|
|
3769
|
+
// followed pull request's CI revisions and merge record (no version bump: additive only).
|
|
3770
|
+
addColumn(db, "publication_grant", "publish_on", "TEXT NOT NULL DEFAULT 'build' CHECK (publish_on IN ('build', 'complete'))");
|
|
3771
|
+
addColumn(db, "publication_grant", "merge_when_green", "INTEGER NOT NULL DEFAULT 0 CHECK (merge_when_green IN (0, 1))");
|
|
3772
|
+
db.exec(PULL_REQUEST_SCHEMA);
|
|
3710
3773
|
addColumn(db, "flow_card", "source_json", "TEXT");
|
|
3711
3774
|
addColumn(db, "flow_card", "owner", "TEXT");
|
|
3712
3775
|
addColumn(db, "flow", "owner", "TEXT");
|
|
@@ -9805,21 +9868,6 @@ export class Store {
|
|
|
9805
9868
|
forgetWorktree(path) {
|
|
9806
9869
|
this.db.prepare("DELETE FROM worktree WHERE path = ?").run(path);
|
|
9807
9870
|
}
|
|
9808
|
-
/** The branches storage retention keeps a repository's checkouts of: every task not done or cancelled (a failed
|
|
9809
|
-
* one included: it can be requeued), and unplaced ones (they're dispatched anywhere); and a done task whose result
|
|
9810
|
-
* was marked complete (the completion names its family's root; its run names the version), or a release candidate
|
|
9811
|
-
* done, within `resultMs` (a deploy installs the build in its checkout). Each task's own branch and every branch its runs used. */
|
|
9812
|
-
keptBranches(repo, now, resultMs) {
|
|
9813
|
-
const since = new Date(now.getTime() - resultMs).toISOString();
|
|
9814
|
-
const tasks = `SELECT r.id AS ref, r.external_id AS id FROM task_ref r JOIN task t ON t.id = r.external_id LEFT JOIN task_scope s ON s.task_id = t.id
|
|
9815
|
-
WHERE (r.repo = ? OR r.repo IS NULL) AND (t.state NOT IN ('done', 'cancelled') OR (t.state = 'done' AND (
|
|
9816
|
-
(s.candidate IS NOT NULL AND t.updated_at >= ?) OR EXISTS (SELECT 1 FROM action_ledger l JOIN run ON run.id = l.run_id
|
|
9817
|
-
WHERE run.task_ref = r.id AND l.action = 'assignment handoff checked' AND l.at >= ?))))`;
|
|
9818
|
-
const own = this.db.prepare(tasks).all(repo, since, since).flatMap(row => BRANCH_PREFIXES.map(prefix => `${prefix}${String(row["id"])}`));
|
|
9819
|
-
const used = this.db.prepare(`SELECT DISTINCT run.branch AS branch FROM run WHERE run.branch IS NOT NULL AND run.task_ref IN (SELECT ref FROM (${tasks}))`).all(repo, since, since)
|
|
9820
|
-
.map(row => String(row["branch"]));
|
|
9821
|
-
return [...new Set([...own, ...used])];
|
|
9822
|
-
}
|
|
9823
9871
|
// ---- worktree setup (M5.7) ---------------------------------------------
|
|
9824
9872
|
/**
|
|
9825
9873
|
* Approve one repo's setup, revoking any predecessor in the same
|
|
@@ -16243,6 +16291,21 @@ export class Store {
|
|
|
16243
16291
|
return this.retentionPeriods();
|
|
16244
16292
|
});
|
|
16245
16293
|
}
|
|
16294
|
+
/** Settings → Storage: when a finished task's clean checkout is removed (the default until someone chooses). */
|
|
16295
|
+
checkoutCleanup() {
|
|
16296
|
+
const row = this.db.prepare("SELECT cleanup FROM checkout_cleanup_setting WHERE id = 1").get();
|
|
16297
|
+
return row === undefined ? DEFAULT_CLEANUP : String(row["cleanup"]);
|
|
16298
|
+
}
|
|
16299
|
+
/** Choose when a finished task's clean checkout is removed; the ledger keeps before → after. */
|
|
16300
|
+
setCheckoutCleanup(cleanup, by, now) {
|
|
16301
|
+
return this.transact(() => {
|
|
16302
|
+
const before = this.checkoutCleanup();
|
|
16303
|
+
this.db.prepare("INSERT INTO checkout_cleanup_setting (id, cleanup, updated_by, updated_at) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET cleanup = excluded.cleanup, updated_by = excluded.updated_by, updated_at = excluded.updated_at")
|
|
16304
|
+
.run(cleanup, by, now.toISOString());
|
|
16305
|
+
this.recordPolicy(by, null, "checkout cleanup changed", cleanupWords(before), cleanupWords(cleanup), now);
|
|
16306
|
+
return this.checkoutCleanup();
|
|
16307
|
+
});
|
|
16308
|
+
}
|
|
16246
16309
|
/** v105: keep a provider's latest reading of its plan's usage windows (a window it no longer reports is dropped). */
|
|
16247
16310
|
recordProviderLimits(reading, now) {
|
|
16248
16311
|
this.transact(() => {
|
|
@@ -18106,9 +18169,9 @@ export class Store {
|
|
|
18106
18169
|
this.db
|
|
18107
18170
|
.prepare(`INSERT INTO publication_grant
|
|
18108
18171
|
(repo, github_repo, remote, head_prefix, base, capabilities, selector, draft, granted_by, granted_at,
|
|
18109
|
-
merge, merge_method, merge_delete_branch)
|
|
18110
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
18111
|
-
.run(grant.repo, grant.githubRepo, grant.remote, grant.headPrefix, grant.base, JSON.stringify(grant.capabilities), grant.selector, grant.draft ? 1 : 0, grant.grantedBy, now.toISOString(), grant.merge === true ? 1 : 0, grant.mergeMethod ?? null, grant.mergeDeleteBranch === true ? 1 : 0);
|
|
18172
|
+
merge, merge_method, merge_delete_branch, publish_on, merge_when_green)
|
|
18173
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
18174
|
+
.run(grant.repo, grant.githubRepo, grant.remote, grant.headPrefix, grant.base, JSON.stringify(grant.capabilities), grant.selector, grant.draft ? 1 : 0, grant.grantedBy, now.toISOString(), grant.merge === true ? 1 : 0, grant.mergeMethod ?? null, grant.mergeDeleteBranch === true ? 1 : 0, grant.publishOn ?? "build", grant.mergeWhenGreen === true ? 1 : 0);
|
|
18112
18175
|
});
|
|
18113
18176
|
}
|
|
18114
18177
|
// ---- the merge grant's machinery (v21) ----------------------------------
|
|
@@ -21520,6 +21583,11 @@ export class Store {
|
|
|
21520
21583
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
21521
21584
|
.run(binding.id, binding.botId, binding.chatId, messageId, row.id, row.destination, row.project, row.taskRef, row.taskId, row.run, now.toISOString());
|
|
21522
21585
|
}
|
|
21586
|
+
/** The newest confirmed message this notification became at one destination, if any. */
|
|
21587
|
+
telegramMessageOf(notification, destination) {
|
|
21588
|
+
const row = this.db.prepare("SELECT message_id FROM telegram_outbound_message WHERE notification = ? AND destination = ? ORDER BY CAST(message_id AS INTEGER) DESC LIMIT 1").get(notification, destination);
|
|
21589
|
+
return row === undefined ? null : String(row["message_id"]);
|
|
21590
|
+
}
|
|
21523
21591
|
finalizeTelegramDelivery(row, binding, owner, outcome, now) {
|
|
21524
21592
|
return this.transact(() => {
|
|
21525
21593
|
if (!this.telegramClaimHeld(row, owner, now) || row.destination !== this.telegramDestination(binding))
|
|
@@ -21613,6 +21681,77 @@ export class Store {
|
|
|
21613
21681
|
.prepare("UPDATE telegram_digest SET every_ms = ?, set_by = ?, set_at = ?, last_sent_at = CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(last_sent_at, ?) END WHERE id = 1")
|
|
21614
21682
|
.run(everyMs, by, now.toISOString(), everyMs, now.toISOString());
|
|
21615
21683
|
}
|
|
21684
|
+
// ---- quiet chat: each person's notification choice and each task's one message ----
|
|
21685
|
+
/** How chats reach this person: only when they are needed unless they chose every step. */
|
|
21686
|
+
notificationPreference(account) {
|
|
21687
|
+
const row = this.db.prepare("SELECT mode, digest_at, digest_on, updated_at FROM notification_preference WHERE account = ?").get(account);
|
|
21688
|
+
return row === undefined ? { mode: "quiet", digestAt: null, digestOn: null, updatedAt: null }
|
|
21689
|
+
: { mode: row["mode"] === "all" ? "all" : "quiet", digestAt: row["digest_at"] == null ? null : String(row["digest_at"]),
|
|
21690
|
+
digestOn: row["digest_on"] == null ? null : String(row["digest_on"]), updatedAt: String(row["updated_at"]) };
|
|
21691
|
+
}
|
|
21692
|
+
/** Change one person's choice; a digest time is a local HH:MM, or null for no evening digest. */
|
|
21693
|
+
setNotificationPreference(account, change, by, now) {
|
|
21694
|
+
if (change.digestAt != null && !isDigestTime(change.digestAt))
|
|
21695
|
+
throw new Error("digest time must be HH:MM");
|
|
21696
|
+
const current = this.notificationPreference(account);
|
|
21697
|
+
const mode = change.mode ?? current.mode;
|
|
21698
|
+
const digestAt = change.digestAt === undefined ? current.digestAt : change.digestAt;
|
|
21699
|
+
this.db.prepare(`INSERT INTO notification_preference (account, mode, digest_at, digest_on, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?)
|
|
21700
|
+
ON CONFLICT (account) DO UPDATE SET mode = excluded.mode, digest_at = excluded.digest_at, updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
|
|
21701
|
+
.run(account, mode, digestAt, current.digestOn, now.toISOString(), by);
|
|
21702
|
+
return this.notificationPreference(account);
|
|
21703
|
+
}
|
|
21704
|
+
/** Everyone who asked for an evening digest, with the local day theirs was last considered. */
|
|
21705
|
+
eveningDigestPeople() {
|
|
21706
|
+
return this.db.prepare("SELECT account, digest_at, digest_on FROM notification_preference WHERE digest_at IS NOT NULL ORDER BY account").all()
|
|
21707
|
+
.map(row => ({ account: String(row["account"]), digestAt: String(row["digest_at"]), digestOn: row["digest_on"] == null ? null : String(row["digest_on"]) }));
|
|
21708
|
+
}
|
|
21709
|
+
/** Claim one local day's digest for one person: true once, false if it was already considered. */
|
|
21710
|
+
claimEveningDigestDay(account, day) {
|
|
21711
|
+
return Number(this.db.prepare("UPDATE notification_preference SET digest_on = ? WHERE account = ? AND digest_at IS NOT NULL AND (digest_on IS NULL OR digest_on < ?)")
|
|
21712
|
+
.run(day, account, day).changes) === 1;
|
|
21713
|
+
}
|
|
21714
|
+
/** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
|
|
21715
|
+
* a minute of it (up to eight tasks), or starts its own. */
|
|
21716
|
+
chatCardFor(destination, taskRef, filedAt, now) {
|
|
21717
|
+
return this.transact(() => {
|
|
21718
|
+
const known = this.db.prepare("SELECT card FROM chat_card_task WHERE destination = ? AND task_ref = ?").get(destination, taskRef);
|
|
21719
|
+
if (known !== undefined)
|
|
21720
|
+
return this.chatCard(Number(known["card"]));
|
|
21721
|
+
const at = Date.parse(filedAt);
|
|
21722
|
+
const low = new Date(at - QUIET_GROUP_MS).toISOString(), high = new Date(at + QUIET_GROUP_MS).toISOString();
|
|
21723
|
+
const together = Number.isFinite(at) ? this.db.prepare(`SELECT id FROM chat_card c WHERE destination = ? AND anchor_at BETWEEN ? AND ?
|
|
21724
|
+
AND (SELECT COUNT(*) FROM chat_card_task t WHERE t.card = c.id) < 8 ORDER BY id DESC LIMIT 1`).get(destination, low, high) : undefined;
|
|
21725
|
+
const card = together !== undefined ? Number(together["id"])
|
|
21726
|
+
: Number(this.db.prepare("INSERT INTO chat_card (destination, anchor_at, created_at) VALUES (?, ?, ?)")
|
|
21727
|
+
.run(destination, Number.isFinite(at) ? new Date(at).toISOString() : now.toISOString(), now.toISOString()).lastInsertRowid);
|
|
21728
|
+
this.db.prepare("INSERT INTO chat_card_task (destination, task_ref, card, joined_at) VALUES (?, ?, ?, ?)").run(destination, taskRef, card, now.toISOString());
|
|
21729
|
+
return this.chatCard(card);
|
|
21730
|
+
});
|
|
21731
|
+
}
|
|
21732
|
+
chatCard(id) {
|
|
21733
|
+
const row = this.db.prepare("SELECT * FROM chat_card WHERE id = ?").get(id);
|
|
21734
|
+
if (row === undefined)
|
|
21735
|
+
return null;
|
|
21736
|
+
const tasks = this.db.prepare("SELECT task_ref FROM chat_card_task WHERE card = ? ORDER BY joined_at, task_ref").all(id).map(one => Number(one["task_ref"]));
|
|
21737
|
+
return { id, destination: String(row["destination"]), anchorAt: String(row["anchor_at"]), message: row["message"] == null ? null : String(row["message"]),
|
|
21738
|
+
digest: row["digest"] == null ? null : String(row["digest"]), tasks };
|
|
21739
|
+
}
|
|
21740
|
+
/** The newest lifecycle fact a task has recorded — what its card says before any attempt starts. */
|
|
21741
|
+
latestTaskFact(taskRef) {
|
|
21742
|
+
const row = this.db.prepare("SELECT kind, created_at FROM notification WHERE task_ref = ? AND substr(dedupe_key, 1, ?) = ? ORDER BY id DESC LIMIT 1")
|
|
21743
|
+
.get(taskRef, LIFECYCLE_KEY_PREFIX.length, LIFECYCLE_KEY_PREFIX);
|
|
21744
|
+
return row === undefined ? null : { kind: String(row["kind"]), createdAt: String(row["created_at"]) };
|
|
21745
|
+
}
|
|
21746
|
+
/** Task-bound facts recorded since an instant, newest last (bounded): what an evening digest reads. */
|
|
21747
|
+
taskFactsSince(since, limit = 2000) {
|
|
21748
|
+
return this.db.prepare("SELECT * FROM notification WHERE created_at >= ? AND task_ref IS NOT NULL AND project IS NOT NULL ORDER BY id DESC LIMIT ?")
|
|
21749
|
+
.all(since, limit).map(readNotification).reverse();
|
|
21750
|
+
}
|
|
21751
|
+
/** The message now showing this card, and what it shows. */
|
|
21752
|
+
setChatCardMessage(id, message, digest) {
|
|
21753
|
+
this.db.prepare("UPDATE chat_card SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
|
|
21754
|
+
}
|
|
21616
21755
|
markTelegramDigestSent(now) {
|
|
21617
21756
|
this.db.prepare("UPDATE telegram_digest SET last_sent_at = ? WHERE id = 1").run(now.toISOString());
|
|
21618
21757
|
}
|
|
@@ -22428,6 +22567,8 @@ function readPublicationGrant(row) {
|
|
|
22428
22567
|
? null
|
|
22429
22568
|
: String(row["merge_method"]),
|
|
22430
22569
|
mergeDeleteBranch: Number(row["merge_delete_branch"] ?? 0) === 1,
|
|
22570
|
+
publishOn: row["publish_on"] === "complete" ? "complete" : "build",
|
|
22571
|
+
mergeWhenGreen: Number(row["merge_when_green"] ?? 0) === 1,
|
|
22431
22572
|
};
|
|
22432
22573
|
}
|
|
22433
22574
|
function readPublication(row) {
|