toolroll 0.9.85 → 0.9.87

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.
Files changed (220) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/agent-fence.d.ts +6 -2
  3. package/dist/agent-fence.js +3 -2
  4. package/dist/agents-board.d.ts +12 -0
  5. package/dist/agents-board.js +26 -0
  6. package/dist/approval-rules-ui.d.ts +4 -1
  7. package/dist/approval-rules-ui.js +31 -15
  8. package/dist/assignment-ui.js +1 -1
  9. package/dist/assignment.js +4 -3
  10. package/dist/backup-ui.d.ts +5 -4
  11. package/dist/backup-ui.js +65 -32
  12. package/dist/browser/terminal.css +1 -1
  13. package/dist/browser/terminal.js +57 -54
  14. package/dist/browser/workspace.css +2 -2
  15. package/dist/browser/workspace.js +35 -68
  16. package/dist/browser-crew.js +1 -1
  17. package/dist/browser-workspace.d.ts +363 -177
  18. package/dist/browser-workspace.js +10 -9
  19. package/dist/chat-actions.d.ts +11 -1
  20. package/dist/chat-actions.js +53 -1
  21. package/dist/chat-channel.js +1 -0
  22. package/dist/chat-controls.d.ts +1 -1
  23. package/dist/chat-controls.js +1 -1
  24. package/dist/chat-quiet.js +3 -2
  25. package/dist/chat-settings-page.d.ts +29 -9
  26. package/dist/chat-settings-page.js +59 -28
  27. package/dist/check-levels-ui.d.ts +2 -1
  28. package/dist/check-levels-ui.js +67 -58
  29. package/dist/claim.d.ts +3 -0
  30. package/dist/claim.js +4 -1
  31. package/dist/contracts/chat-actions.d.ts +9 -0
  32. package/dist/contracts/chat-actions.js +3 -0
  33. package/dist/contracts/cli.d.ts +43 -0
  34. package/dist/contracts/cli.js +4 -0
  35. package/dist/contracts/console-api.d.ts +4 -1
  36. package/dist/contracts/console-api.js +4 -1
  37. package/dist/contracts/flow-propose.d.ts +4 -1
  38. package/dist/contracts/flow-propose.js +1 -1
  39. package/dist/contracts/flow.d.ts +41 -0
  40. package/dist/contracts/flow.js +4 -2
  41. package/dist/contracts/lead-tools.d.ts +21 -0
  42. package/dist/contracts/lead-tools.js +2 -0
  43. package/dist/contracts/store-json.d.ts +2 -0
  44. package/dist/contracts/trigger-payloads.d.ts +20 -0
  45. package/dist/contracts/trigger-payloads.js +4 -0
  46. package/dist/coordinator.js +1 -0
  47. package/dist/credentials-ui.d.ts +5 -3
  48. package/dist/credentials-ui.js +58 -31
  49. package/dist/deploy-gate.js +4 -0
  50. package/dist/design/kit.css +685 -0
  51. package/dist/design/kit.d.ts +208 -0
  52. package/dist/design/kit.js +177 -0
  53. package/dist/design/tokens.css +19 -1
  54. package/dist/discord-settings.js +8 -4
  55. package/dist/dispatch.d.ts +3 -0
  56. package/dist/dispatch.js +4 -1
  57. package/dist/export-ui.d.ts +6 -4
  58. package/dist/export-ui.js +18 -8
  59. package/dist/flow-browser.d.ts +103 -0
  60. package/dist/flow-browser.js +204 -0
  61. package/dist/flow-changed.d.ts +14 -0
  62. package/dist/flow-changed.js +53 -0
  63. package/dist/flow-code.d.ts +5 -1
  64. package/dist/flow-code.js +4 -1
  65. package/dist/flow-engine.js +16 -0
  66. package/dist/flow-gallery-ui.d.ts +41 -4
  67. package/dist/flow-gallery-ui.js +95 -63
  68. package/dist/flow-live.js +9 -1
  69. package/dist/flow-pull-request.js +15 -7
  70. package/dist/flow-share.js +3 -1
  71. package/dist/flow-starters-ui.d.ts +3 -9
  72. package/dist/flow-starters-ui.js +12 -23
  73. package/dist/flow-steps.d.ts +9 -1
  74. package/dist/flow-steps.js +55 -5
  75. package/dist/flow-triggers.js +55 -0
  76. package/dist/flow-unattended-words.d.ts +20 -0
  77. package/dist/flow-unattended-words.js +24 -0
  78. package/dist/flow-unattended.d.ts +9 -5
  79. package/dist/flow-unattended.js +45 -23
  80. package/dist/flows-cli.d.ts +19 -0
  81. package/dist/flows-cli.js +54 -5
  82. package/dist/flows-ui.d.ts +12 -6
  83. package/dist/flows-ui.js +152 -32
  84. package/dist/flows.js +5 -2
  85. package/dist/integrations-ui.d.ts +6 -4
  86. package/dist/integrations-ui.js +34 -40
  87. package/dist/integrations.d.ts +2 -2
  88. package/dist/integrations.js +4 -4
  89. package/dist/kits-ui.d.ts +8 -10
  90. package/dist/kits-ui.js +29 -33
  91. package/dist/kits.js +1 -1
  92. package/dist/knowledge-ui.js +76 -19
  93. package/dist/lead-tools.js +35 -6
  94. package/dist/lead-voice.js +16 -5
  95. package/dist/ledger-view.d.ts +2 -0
  96. package/dist/ledger-view.js +41 -38
  97. package/dist/models-ui.d.ts +8 -3
  98. package/dist/models-ui.js +82 -59
  99. package/dist/monitoring-ui.d.ts +4 -2
  100. package/dist/monitoring-ui.js +44 -13
  101. package/dist/needs-you.d.ts +1 -1
  102. package/dist/needs-you.js +8 -0
  103. package/dist/operate-remote-arguments.js +1 -0
  104. package/dist/operate-remote.js +1 -1
  105. package/dist/operate.d.ts +1 -1
  106. package/dist/operate.js +249 -6
  107. package/dist/passwordless.d.ts +1 -1
  108. package/dist/passwordless.js +1 -1
  109. package/dist/people-audit-ui.d.ts +2 -1
  110. package/dist/people-audit-ui.js +25 -23
  111. package/dist/policy-ui.d.ts +3 -2
  112. package/dist/policy-ui.js +27 -21
  113. package/dist/policy.d.ts +3 -0
  114. package/dist/policy.js +14 -1
  115. package/dist/project-delete-ui.d.ts +4 -2
  116. package/dist/project-delete-ui.js +135 -126
  117. package/dist/project-delete.js +7 -3
  118. package/dist/project-knowledge.d.ts +10 -10
  119. package/dist/publish.d.ts +11 -0
  120. package/dist/publish.js +16 -3
  121. package/dist/pull-request-flow.js +35 -4
  122. package/dist/pull-request-ui.d.ts +2 -1
  123. package/dist/pull-request-ui.js +39 -28
  124. package/dist/recipe-ui.d.ts +4 -2
  125. package/dist/recipe-ui.js +14 -4
  126. package/dist/release-adapters.d.ts +3 -0
  127. package/dist/release-adapters.js +22 -2
  128. package/dist/release-cli.js +81 -9
  129. package/dist/release.d.ts +21 -0
  130. package/dist/release.js +40 -4
  131. package/dist/releases.js +1 -1
  132. package/dist/request-budget-ui.d.ts +4 -2
  133. package/dist/request-budget-ui.js +30 -19
  134. package/dist/result-follow-ups.js +4 -0
  135. package/dist/retention-ui.d.ts +4 -3
  136. package/dist/retention-ui.js +15 -12
  137. package/dist/scope.d.ts +8 -0
  138. package/dist/scope.js +13 -0
  139. package/dist/serve.d.ts +0 -1
  140. package/dist/serve.js +51 -7
  141. package/dist/server/agents-computers.d.ts +9 -0
  142. package/dist/server/agents-computers.js +67 -0
  143. package/dist/server/chat.js +20 -18
  144. package/dist/server/chrome.d.ts +1 -1
  145. package/dist/server/chrome.js +19 -9
  146. package/dist/server/console.css +31 -2
  147. package/dist/server/flows.js +79 -36
  148. package/dist/server/home.d.ts +35 -0
  149. package/dist/server/home.js +104 -0
  150. package/dist/server/http.d.ts +3 -0
  151. package/dist/server/http.js +5 -0
  152. package/dist/server/kit-standalone.d.ts +22 -0
  153. package/dist/server/kit-standalone.js +69 -0
  154. package/dist/server/kit.d.ts +405 -0
  155. package/dist/server/kit.js +361 -0
  156. package/dist/server/pages.d.ts +2 -1
  157. package/dist/server/pages.js +213 -174
  158. package/dist/server/people-tokens.js +26 -85
  159. package/dist/server/render-history.d.ts +51 -0
  160. package/dist/server/render-history.js +228 -0
  161. package/dist/server/render-new-task.d.ts +29 -0
  162. package/dist/server/render-new-task.js +96 -0
  163. package/dist/server/render-next.d.ts +63 -0
  164. package/dist/server/render-next.js +109 -0
  165. package/dist/server/render-pages.d.ts +35 -190
  166. package/dist/server/render-pages.js +154 -563
  167. package/dist/server/render-people.d.ts +24 -3
  168. package/dist/server/render-people.js +93 -72
  169. package/dist/server/render-projects.d.ts +103 -0
  170. package/dist/server/render-projects.js +247 -0
  171. package/dist/server/render-results.d.ts +5 -24
  172. package/dist/server/render-results.js +48 -185
  173. package/dist/server/render-settings.d.ts +5 -74
  174. package/dist/server/render-settings.js +102 -493
  175. package/dist/server/render-tasks.d.ts +43 -5
  176. package/dist/server/render-tasks.js +131 -91
  177. package/dist/server/route-table.js +31 -5
  178. package/dist/server/settings.js +276 -255
  179. package/dist/server/task-views.d.ts +8 -0
  180. package/dist/server/task-views.js +90 -2
  181. package/dist/server/tasks.d.ts +18 -0
  182. package/dist/server/tasks.js +148 -41
  183. package/dist/server/terminals.js +1 -1
  184. package/dist/server/welcome.d.ts +22 -0
  185. package/dist/server/welcome.js +69 -0
  186. package/dist/settings-groups-ui.d.ts +279 -0
  187. package/dist/settings-groups-ui.js +615 -0
  188. package/dist/settings-kit.d.ts +42 -6
  189. package/dist/settings-kit.js +69 -7
  190. package/dist/settings-proofs.js +1 -0
  191. package/dist/setup-guide.d.ts +2 -2
  192. package/dist/skills-ui.js +59 -7
  193. package/dist/slack-settings.js +12 -5
  194. package/dist/spend-ui.d.ts +45 -26
  195. package/dist/spend-ui.js +124 -56
  196. package/dist/sso-ui.d.ts +5 -2
  197. package/dist/sso-ui.js +63 -31
  198. package/dist/storage-ui.d.ts +3 -2
  199. package/dist/storage-ui.js +56 -50
  200. package/dist/store.d.ts +182 -5
  201. package/dist/store.js +512 -38
  202. package/dist/surface.js +23 -3
  203. package/dist/task-archive.d.ts +10 -0
  204. package/dist/task-archive.js +16 -0
  205. package/dist/task-control.d.ts +1 -1
  206. package/dist/teams-settings.d.ts +2 -1
  207. package/dist/teams-settings.js +24 -41
  208. package/dist/telegram-settings.d.ts +0 -4
  209. package/dist/telegram-settings.js +52 -25
  210. package/dist/tidy.d.ts +130 -0
  211. package/dist/tidy.js +503 -0
  212. package/dist/toolroll-update-ui.d.ts +8 -4
  213. package/dist/toolroll-update-ui.js +55 -45
  214. package/dist/tools-ui.d.ts +5 -3
  215. package/dist/tools-ui.js +107 -42
  216. package/dist/work-index.d.ts +24 -0
  217. package/dist/work-index.js +56 -30
  218. package/dist/workspace-ui.d.ts +1 -1
  219. package/dist/workspace-ui.js +8 -4
  220. package/package.json +1 -1
package/dist/tidy.js ADDED
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Put away old tasks (docs/plans/task-tidy.md). Tidy finds tasks nobody needs to see any more, says why for each, and
3
+ * puts them away (v145 `task_archive`): old routine runs a newer run replaces, release tasks for versions already
4
+ * published or replaced, and "Checks failed" notices main has since fixed. A person can also put away finished tasks
5
+ * older than N days from the same preview. Deterministic and explainable: the same records give the same list, every
6
+ * item carries its rule and one plain reason, and the preview's digest binds exactly what a person saw.
7
+ *
8
+ * Never touched: a task with a live attempt, a task asked of a named person (`task ask`), a task with feedback or a
9
+ * steering note still pending, a task already Complete, and (automatically) a task a person filed or restored.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync, constants } from "node:fs";
13
+ import { dirname, isAbsolute, join } from "node:path";
14
+ import { archiveTaskFamily } from "./store.js";
15
+ import { workTidyRows } from "./work-index.js";
16
+ /** The preview's groups, in order. "finished" is a person's own choice only; tidy never applies it on its own. */
17
+ export const TIDY_GROUPS = [
18
+ { rule: "release", label: "Old releases" },
19
+ { rule: "routine", label: "Old routine runs" },
20
+ { rule: "ci", label: "Resolved CI notices" },
21
+ { rule: "finished", label: "Finished more than {days} days ago" },
22
+ ];
23
+ export const TIDY_DEFAULT_DAYS = 14;
24
+ /** At most this many tasks are put away automatically per pass. */
25
+ export const TIDY_PASS_LIMIT = 25;
26
+ const MAX_JOURNALS = 2000;
27
+ const MAX_JOURNAL_BYTES = 1 << 20;
28
+ const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
29
+ const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,64})?$/;
30
+ /** Reads `releases/*.json` under the plane's state folder (the database's folder), written only by `toolroll release`.
31
+ * A symlink, a non-file, an oversized or unreadable journal makes the whole answer untrusted (`problem`), so the
32
+ * release rule then puts nothing away: a journal it can't read might be the one that says a release is in flight. */
33
+ export function readReleaseFacts(stateDir) {
34
+ const dir = join(stateDir, "releases");
35
+ let names;
36
+ try {
37
+ const st = lstatSync(dir);
38
+ if (!st.isDirectory())
39
+ return { facts: [], problem: `${dir} isn't a folder` };
40
+ names = readdirSync(dir).filter(name => name.endsWith(".json")).sort();
41
+ }
42
+ catch (error) {
43
+ return error.code === "ENOENT" ? { facts: [], problem: null } : { facts: [], problem: `${dir} can't be read` };
44
+ }
45
+ if (names.length > MAX_JOURNALS)
46
+ return { facts: [], problem: `more than ${MAX_JOURNALS} release journals` };
47
+ const facts = [];
48
+ for (const name of names) {
49
+ const file = join(dir, name);
50
+ const read = readJournalFile(file);
51
+ if (read === null)
52
+ return { facts: [], problem: `${file} can't be read` };
53
+ const journal = read;
54
+ const task = journal["task"];
55
+ const taskId = typeof task?.["id"] === "string" ? task["id"] : null;
56
+ const version = typeof journal["packageVersion"] === "string" ? journal["packageVersion"] : null;
57
+ if (journal["version"] !== 1 || typeof journal["step"] !== "string" || taskId === null || !TASK_ID.test(taskId) || version === null || !VERSION.test(version)) {
58
+ return { facts: [], problem: `${file} isn't a release journal this version reads` };
59
+ }
60
+ const published = journal["published"];
61
+ const candidate = journal["candidate"]?.["sha"];
62
+ const product = `${typeof journal["github"] === "string" ? journal["github"].toLowerCase() : ""}\u0000${typeof journal["packageName"] === "string" ? journal["packageName"] : ""}`;
63
+ if (typeof candidate !== "string" || !/^[0-9a-f]{40}$/.test(candidate))
64
+ return { facts: [], problem: `${file} has no candidate commit` };
65
+ facts.push({ taskId, version, product, candidate, superseded: /\.superseded-[^/]*\.json$/.test(name), done: journal["step"] === "done",
66
+ published: published !== null && typeof published === "object", stopped: journal["stopped"] !== null && journal["stopped"] !== undefined });
67
+ }
68
+ return { facts, problem: null };
69
+ }
70
+ /** The release journals beside this database (its folder is the plane's state folder). An in-memory or relative
71
+ * database has no state folder, so the release rule stays off rather than reading whatever folder it runs in. */
72
+ export function releaseFactsFor(databaseFile) {
73
+ return isAbsolute(databaseFile) ? readReleaseFacts(dirname(databaseFile)) : { facts: [], problem: "this database has no state folder" };
74
+ }
75
+ /** One journal's JSON, never through a symlink, bounded; null when it can't be read. */
76
+ function readJournalFile(file) {
77
+ let fd = null;
78
+ try {
79
+ const st = lstatSync(file);
80
+ if (!st.isFile() || st.size > MAX_JOURNAL_BYTES)
81
+ return null;
82
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
83
+ const size = fstatSync(fd).size;
84
+ if (size > MAX_JOURNAL_BYTES)
85
+ return null;
86
+ const buffer = Buffer.alloc(size);
87
+ let at = 0;
88
+ while (at < size) {
89
+ const got = readSync(fd, buffer, at, size - at, at);
90
+ if (got <= 0)
91
+ break;
92
+ at += got;
93
+ }
94
+ return JSON.parse(buffer.subarray(0, at).toString("utf8"));
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ finally {
100
+ if (fd !== null)
101
+ closeSync(fd);
102
+ }
103
+ }
104
+ /** A journal's task is the release task Toolroll filed for it: its id is the gate id for that version and commit
105
+ * (release.ts gateTaskId, maybe with a fresh-approval suffix), the task exists, and its scope's candidate, when it has
106
+ * one, is the journal's commit. */
107
+ function releaseTaskMatches(store, fact) {
108
+ const base = `release-${fact.version.replace(/[^0-9A-Za-z.-]/g, "-")}-${fact.candidate.slice(0, 7)}`;
109
+ if (fact.taskId !== base && !(fact.taskId.startsWith(`${base}-`) && /^\d{1,3}$/.test(fact.taskId.slice(base.length + 1))))
110
+ return false;
111
+ const row = store.handle.prepare(`SELECT s.candidate FROM task t JOIN task_ref r ON r.backend = 'built-in' AND r.external_id = t.id
112
+ LEFT JOIN task_scope s ON s.task_id = t.id WHERE t.id = ?`).get(fact.taskId);
113
+ return row !== undefined && (row["candidate"] == null || row["candidate"] === fact.candidate);
114
+ }
115
+ /** -1, 0 or 1: numeric parts first, then a release above any of its pre-releases. */
116
+ export function compareVersions(a, b) {
117
+ const [ma, pa = ""] = a.split("-", 2);
118
+ const [mb, pb = ""] = b.split("-", 2);
119
+ const na = ma.split(".").map(Number), nb = mb.split(".").map(Number);
120
+ for (let i = 0; i < 3; i++)
121
+ if ((na[i] ?? 0) !== (nb[i] ?? 0))
122
+ return (na[i] ?? 0) < (nb[i] ?? 0) ? -1 : 1;
123
+ if (pa === pb)
124
+ return 0;
125
+ if (pa === "")
126
+ return 1;
127
+ if (pb === "")
128
+ return -1;
129
+ return pa < pb ? -1 : 1;
130
+ }
131
+ /** Everything that keeps a task out of tidy, read once. */
132
+ function exclusionsOf(store) {
133
+ const refs = (sql) => new Set(store.handle.prepare(sql).all().map(row => Number(row["ref"])));
134
+ const ids = (sql) => new Set(store.handle.prepare(sql).all().map(row => String(row["id"])));
135
+ return {
136
+ asked: refs("SELECT DISTINCT task_ref ref FROM task_act WHERE act = 'asked'"),
137
+ feedback: refs(`SELECT r.task_ref ref FROM diff_comment d JOIN run r ON r.id = d.run WHERE d.superseded_by IS NULL AND d.consumed_by IS NULL AND d.reviewer_run IS NULL
138
+ UNION SELECT task_ref ref FROM task_steer WHERE delivered_at IS NULL AND superseded_at IS NULL
139
+ UNION SELECT r.task_ref ref FROM review_request q JOIN run r ON r.id = q.run WHERE q.consumed_at IS NULL`),
140
+ proposals: ids(`SELECT DISTINCT json_extract(payload_json, '$.task') id FROM lead_proposal
141
+ WHERE state IN ('drafting','pending','confirming') AND json_valid(payload_json) AND json_type(payload_json, '$.task') = 'text'`),
142
+ restored: ids("SELECT DISTINCT task_id id FROM action_ledger WHERE action = 'task restored' AND task_id IS NOT NULL"),
143
+ };
144
+ }
145
+ /** Why this family is never put away by tidy, or null. `auto`: tidy on its own (a restored family stays). */
146
+ function excluded(row, ex, auto) {
147
+ if (row.archived || row.live || row.broken || row.code === "complete")
148
+ return true;
149
+ if (row.members.some(ref => ex.asked.has(ref) || ex.feedback.has(ref)))
150
+ return true;
151
+ if (row.memberIds.some(id => ex.proposals.has(id)))
152
+ return true;
153
+ return auto && row.memberIds.some(id => ex.restored.has(id));
154
+ }
155
+ /** Labels an automation's card files under (flow-triggers.ts makeCard), from before cards kept their kind (v126). */
156
+ const AUTOMATION_FILERS = new Set(["Schedule", "Plane review", "GitHub"]);
157
+ /** Whether this task was filed by the trigger's own automation, proven from Toolroll's records: a standing order's
158
+ * firing names its task once and forever (`flow_trigger_event.task`); otherwise the flow engine filed it for this very
159
+ * flow (`filed_via`), as the automation the card was made by (its kind, or an automation's label from before kinds).
160
+ * Unknown provenance is never automation. */
161
+ function automationFiled(store, taskId, flow, card, eventTask) {
162
+ if (eventTask === taskId)
163
+ return true;
164
+ const row = store.handle.prepare("SELECT filed_via, filed_by, filed_by_kind FROM task_ref WHERE backend = 'built-in' AND external_id = ?").get(taskId);
165
+ if (row === undefined || row["filed_via"] !== `flow:${flow}`)
166
+ return false;
167
+ if (row["filed_by_kind"] === "automation")
168
+ return card.createdKind === "automation" || card.createdKind === null;
169
+ return card.createdKind === null && row["filed_by_kind"] === "person" && typeof row["filed_by"] === "string" && row["filed_by"] === card.createdBy && AUTOMATION_FILERS.has(row["filed_by"]);
170
+ }
171
+ /** (a) Every schedule's own clock firings (a slot, a scripted slot, or a plane review's scheduled daily problem; never a
172
+ * person's Run now, never a skipped or held firing), each with the automation-filed tasks it made, grouped by trigger (a
173
+ * plane review: by trigger and problem). */
174
+ function routineFirings(store) {
175
+ const rows = store.handle.prepare(`SELECT e.trigger, e.key, e.at, e.on_time, e.state, e.task event_task, c.task card_task, c.primary_task, c.title,
176
+ c.created_by, c.created_kind, t.flow
177
+ FROM flow_trigger_event e JOIN flow_trigger t ON t.id = e.trigger LEFT JOIN flow_card c ON c.id = e.card
178
+ WHERE t.kind = 'schedule' AND (e.card IS NOT NULL OR e.task IS NOT NULL) AND (e.state IS NULL OR e.state = 'accepted')
179
+ ORDER BY e.trigger, e.at, e.rowid`).all();
180
+ const families = new Map();
181
+ for (const row of rows) {
182
+ const key = String(row["key"]);
183
+ if (row["created_kind"] === "person")
184
+ continue;
185
+ const plane = /^plane:(.+):\d{4}-\d{2}-\d{2}$/.exec(key);
186
+ // A plane review's key is the same for Run now; only the clock's own firing (on_time) is a routine run.
187
+ if (!/^(?:script:)?slot:/.test(key) && !(plane !== null && Number(row["on_time"]) === 1))
188
+ continue;
189
+ const family = `${Number(row["trigger"])}:${plane === null ? "" : plane[1]}`;
190
+ const eventTask = typeof row["event_task"] === "string" ? row["event_task"] : null;
191
+ const card = { createdBy: row["created_by"] == null ? null : String(row["created_by"]), createdKind: row["created_kind"] == null ? null : String(row["created_kind"]) };
192
+ const all = [...new Set([row["event_task"], row["card_task"], row["primary_task"]].filter((one) => typeof one === "string" && one !== ""))];
193
+ const tasks = all.filter(task => automationFiled(store, task, Number(row["flow"]), card, eventTask));
194
+ const list = families.get(family) ?? [];
195
+ // Every firing counts toward which is newest, even one whose tasks can't be proven: its tasks are kept, never taken.
196
+ list.push({ family, tasks, all, title: String(row["title"] ?? eventTask ?? "the newest run") });
197
+ families.set(family, list);
198
+ }
199
+ return families;
200
+ }
201
+ /** (c) Every "Checks failed on <branch>: <workflow>" notice a checks trigger made, with its commit, its workflow and the
202
+ * automation-filed tasks it made. */
203
+ function ciNotices(store) {
204
+ const rows = store.handle.prepare(`SELECT e.trigger, e.key, e.at, c.title, c.task, c.primary_task, c.created_by, c.created_kind, c.source_json, t.config_json, t.flow
205
+ FROM flow_trigger_event e JOIN flow_trigger t ON t.id = e.trigger JOIN flow_card c ON c.id = e.card
206
+ WHERE t.kind = 'github' AND e.key LIKE 'checks:%' AND (e.state IS NULL OR e.state = 'accepted') ORDER BY e.trigger, e.at, e.rowid`).all();
207
+ const notices = [];
208
+ for (const row of rows) {
209
+ if (row["created_kind"] === "person")
210
+ continue;
211
+ let config;
212
+ try {
213
+ config = JSON.parse(String(row["config_json"]));
214
+ }
215
+ catch {
216
+ continue;
217
+ }
218
+ if (config["watch"] !== "checks" || typeof config["repo"] !== "string" || typeof config["branch"] !== "string")
219
+ continue;
220
+ // Where the failure was, as the card recorded it when it was made (GitHub's own run link, never rewritten), not
221
+ // where the trigger points now: a trigger re-pointed later never lends its new repository's runs to old notices.
222
+ let made = null;
223
+ try {
224
+ made = JSON.parse(String(row["source_json"] ?? "null"));
225
+ }
226
+ catch {
227
+ made = null;
228
+ }
229
+ const at = typeof made?.url === "string" ? /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/actions\/runs\/\d+(?:\/|$)/.exec(made.url) : null;
230
+ if (at === null || at[1].toLowerCase() !== config["repo"].toLowerCase())
231
+ continue;
232
+ const runId = Number(/\/actions\/runs\/(\d+)/.exec(String(made.url))[1]);
233
+ const branch = config["branch"], prefix = `Checks failed on ${branch}: `, title = String(row["title"] ?? "");
234
+ const sha = String(row["key"]).slice("checks:".length);
235
+ if (!title.startsWith(prefix) || !/^[0-9a-f]{40}$/.test(sha))
236
+ continue;
237
+ const card = { createdBy: row["created_by"] == null ? null : String(row["created_by"]), createdKind: row["created_kind"] == null ? null : String(row["created_kind"]) };
238
+ const tasks = [...new Set([row["task"], row["primary_task"]].filter((one) => typeof one === "string" && one !== ""))]
239
+ .filter(task => automationFiled(store, task, Number(row["flow"]), card, null));
240
+ notices.push({ trigger: Number(row["trigger"]), workflow: title.slice(prefix.length), sha, at: String(row["at"]), tasks, repo: config["repo"], branch, run: runId });
241
+ }
242
+ return notices;
243
+ }
244
+ /** Every completed run the checks trigger saw on this repository's branch, oldest first. */
245
+ function observationsOf(store, repo, branch) {
246
+ return store.handle.prepare(`SELECT run_id, workflow, head_sha, conclusion, run_created_at FROM branch_check_run
247
+ WHERE lower(repo) = lower(?) AND branch = ? ORDER BY run_created_at, run_id`).all(repo, branch)
248
+ .map(row => ({ runId: Number(row["run_id"]), workflow: String(row["workflow"]), sha: String(row["head_sha"]), conclusion: row["conclusion"] === "success" ? "success" : "failure", createdAt: String(row["run_created_at"]) }));
249
+ }
250
+ /** The failing runs older "Checks failed" cards of this trigger name (their own GitHub run links) that aren't on record
251
+ * yet, newest first, at most `limit`: the checks trigger reads each from GitHub so tidy can tell where it ran. */
252
+ export function unprovenNoticeRuns(store, trigger, repo, limit = 5) {
253
+ const rows = store.handle.prepare(`SELECT c.source_json FROM flow_trigger_event e JOIN flow_card c ON c.id = e.card
254
+ WHERE e.trigger = ? AND e.key LIKE 'checks:%' ORDER BY e.at DESC, e.rowid DESC`).all(trigger);
255
+ const runs = [];
256
+ for (const row of rows) {
257
+ let url;
258
+ try {
259
+ url = JSON.parse(String(row["source_json"] ?? "null"))?.url;
260
+ }
261
+ catch {
262
+ url = null;
263
+ }
264
+ const found = typeof url === "string" ? /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/actions\/runs\/(\d+)(?:\/|$)/.exec(url) : null;
265
+ if (found === null || found[1].toLowerCase() !== repo.toLowerCase())
266
+ continue;
267
+ const id = Number(found[2]);
268
+ if (!Number.isSafeInteger(id) || store.handle.prepare("SELECT 1 FROM branch_check_run WHERE lower(repo) = lower(?) AND run_id = ?").get(repo, id) !== undefined)
269
+ continue;
270
+ runs.push(id);
271
+ if (runs.length >= limit)
272
+ break;
273
+ }
274
+ return runs;
275
+ }
276
+ /** A completed workflow run on the watched branch, as the checks trigger read it from GitHub (its poll through the
277
+ * person's own gh login, or its signed webhook): kept once per run. Only a push to the watched branch of the watched
278
+ * repository counts (a pull request from a fork whose branch is also called main does not). */
279
+ export function recordBranchCheckRun(store, watched, run, now) {
280
+ const workflow = run.name ?? "", sha = run.head_sha ?? "", created = Date.parse(run.created_at ?? "");
281
+ if (run.id === undefined || !Number.isSafeInteger(run.id) || run.id <= 0 || workflow === "" || workflow.length > 200 || !/^[0-9a-f]{40}$/.test(sha) || Number.isNaN(created))
282
+ return false;
283
+ if (run.conclusion !== "success" && run.conclusion !== "failure")
284
+ return false;
285
+ if (run.event !== "push" || run.head_branch !== watched.branch || (run.head_repository?.full_name ?? "").toLowerCase() !== watched.repo.toLowerCase())
286
+ return false;
287
+ const changed = store.handle.prepare(`INSERT OR IGNORE INTO branch_check_run (repo, run_id, branch, workflow, head_sha, conclusion, run_created_at, observed_at)
288
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(watched.repo, run.id, watched.branch, workflow, sha, run.conclusion, new Date(created).toISOString(), now.toISOString());
289
+ return Number(changed.changes) > 0;
290
+ }
291
+ /** What tidy would put away now, in group order, oldest first within a group. `days`: also offer finished tasks
292
+ * untouched that long (a person's preview only); null is tidy's own automatic rules. `access`: whose lists. */
293
+ export function tidyPreview(store, now, input) {
294
+ const auto = input.days === null;
295
+ const rows = workTidyRows(store, now, input.access ?? { principal: "operator", repos: null, includeUnplaced: true });
296
+ const byId = new Map();
297
+ for (const row of rows)
298
+ for (const id of row.memberIds)
299
+ byId.set(id, row);
300
+ const ex = exclusionsOf(store);
301
+ const taken = new Map();
302
+ // Every task each schedule's newest firing touched, whoever filed it: no family holding one is ever put away.
303
+ const firings = routineFirings(store);
304
+ const keepAll = new Set([...firings.values()].flatMap(list => list.at(-1).all));
305
+ // `proven`: for a rule's own producer, every version of the family must be one it can prove it made (a person's
306
+ // later revision of a scheduled run keeps the whole family out); null for a person's own choice (finished tasks).
307
+ const offer = (row, rule, reason, proven) => {
308
+ if (row === undefined || taken.has(row.rootId) || excluded(row, ex, auto))
309
+ return;
310
+ if (proven !== null && !row.memberIds.every(id => proven.has(id)))
311
+ return;
312
+ if (rule !== "release" && row.memberIds.some(id => keepAll.has(id)))
313
+ return;
314
+ taken.set(row.rootId, { rule, taskId: row.currentId, title: row.title, repo: row.repo, reason, updatedAt: row.updatedAt, members: [...row.members].sort((a, b) => a - b) });
315
+ };
316
+ // (b) Old releases: only when every journal reads, and only a task whose journals agree with its own records.
317
+ if (input.releases.problem === null) {
318
+ const facts = input.releases.facts.filter(one => releaseTaskMatches(store, one));
319
+ // The newest published version of each package (its GitHub repository and npm name).
320
+ const latestOf = new Map();
321
+ for (const one of facts)
322
+ if (one.published && (latestOf.get(one.product) === undefined || compareVersions(one.version, latestOf.get(one.product)) > 0))
323
+ latestOf.set(one.product, one.version);
324
+ const byTask = new Map();
325
+ for (const fact of input.releases.facts)
326
+ byTask.set(fact.taskId, [...(byTask.get(fact.taskId) ?? []), fact]);
327
+ const journalNamed = new Set(byTask.keys());
328
+ for (const [taskId, named] of byTask) {
329
+ // Every journal naming it must check out, for one package and one version.
330
+ if (named.some(one => !facts.includes(one)) || new Set(named.map(one => `${one.product}\u0000${one.version}`)).size !== 1)
331
+ continue;
332
+ const current = named.filter(one => !one.superseded);
333
+ // In flight: its current journal is neither done nor stopped (running, or waiting for an approval).
334
+ if (current.some(one => !one.done && !one.stopped))
335
+ continue;
336
+ const version = named[0].version, latest = latestOf.get(named[0].product) ?? null;
337
+ const reason = named.some(one => one.published) ? `Release ${version} is published.`
338
+ : current.length === 0 ? `A newer attempt of release ${version} replaced it.`
339
+ : latest !== null && compareVersions(version, latest) < 0 ? `Release ${latest} is already published.` : null;
340
+ if (reason !== null)
341
+ offer(byId.get(taskId), "release", reason, journalNamed);
342
+ }
343
+ }
344
+ // (a) Old routine runs: everything but each schedule's newest firing.
345
+ const routineMade = new Set([...firings.values()].flatMap(list => list.flatMap(one => one.tasks)));
346
+ for (const list of firings.values()) {
347
+ const newest = list.at(-1);
348
+ for (const firing of list.slice(0, -1)) {
349
+ for (const task of firing.tasks)
350
+ offer(byId.get(task), "routine", `A newer run, “${newest.title}”, replaces it.`, routineMade);
351
+ }
352
+ }
353
+ // (c) Resolved CI notices: every workflow that failed on its commit was fixed by a later commit's green run, or a
354
+ // newer notice carries that workflow's failure while the branch stays red.
355
+ // A notice counts only once its own failing run is on record as a push to the watched repository and branch (the
356
+ // checks trigger reads it): a pull request from a fork whose branch is also called main never does.
357
+ const seen = new Map();
358
+ const observedFor = (notice) => {
359
+ const key = `${notice.repo.toLowerCase()}\u0000${notice.branch}`;
360
+ const observed = seen.get(key) ?? observationsOf(store, notice.repo, notice.branch);
361
+ seen.set(key, observed);
362
+ return observed;
363
+ };
364
+ const notices = ciNotices(store).filter(notice => observedFor(notice).some(one => one.runId === notice.run && one.conclusion === "failure" && one.sha === notice.sha));
365
+ const ciMade = new Set(notices.flatMap(one => one.tasks));
366
+ for (const notice of notices) {
367
+ const observed = observedFor(notice);
368
+ // What failed on its commit: the workflow it names, and any other the trigger saw fail on that commit.
369
+ const failed = new Map([[notice.workflow, notice.at]]);
370
+ for (const one of observed)
371
+ if (one.sha === notice.sha && one.conclusion === "failure")
372
+ failed.set(one.workflow, failed.has(one.workflow) && failed.get(one.workflow) < one.createdAt ? failed.get(one.workflow) : one.createdAt);
373
+ const words = [];
374
+ let settled = true;
375
+ for (const [workflow, since] of failed) {
376
+ const pass = observed.find(one => one.workflow === workflow && one.conclusion === "success" && one.sha !== notice.sha && one.createdAt > since);
377
+ if (pass !== undefined) {
378
+ words.push(`“${workflow}” passed on ${notice.branch} at ${pass.sha.slice(0, 7)}`);
379
+ continue;
380
+ }
381
+ const newer = notices.find(other => other !== notice && other.trigger === notice.trigger && other.repo.toLowerCase() === notice.repo.toLowerCase() && other.branch === notice.branch
382
+ && other.at > notice.at && other.sha !== notice.sha
383
+ && (other.workflow === workflow || observed.some(one => one.sha === other.sha && one.workflow === workflow && one.conclusion === "failure")));
384
+ if (newer !== undefined) {
385
+ words.push(`a newer “${workflow}” failure on ${notice.branch} replaces it`);
386
+ continue;
387
+ }
388
+ settled = false;
389
+ }
390
+ if (!settled)
391
+ continue;
392
+ const reason = `${words.join("; ")}.`.replace(/^./, first => first.toUpperCase());
393
+ for (const task of notice.tasks)
394
+ offer(byId.get(task), "ci", reason, ciMade);
395
+ }
396
+ // Finished more than N days ago (a person's preview only): done, failed or stopped, untouched since.
397
+ if (input.days !== null) {
398
+ const before = new Date(now.getTime() - input.days * 86_400_000).toISOString();
399
+ for (const row of rows) {
400
+ if ((row.state === "done" || row.state === "failed" || row.state === "cancelled") && row.updatedAt !== "" && row.updatedAt < before) {
401
+ offer(row, "finished", `Finished ${row.updatedAt.slice(0, 10)}, untouched for more than ${input.days} days.`, null);
402
+ }
403
+ }
404
+ }
405
+ const order = (rule) => TIDY_GROUPS.findIndex(one => one.rule === rule);
406
+ return [...taken.values()].sort((a, b) => order(a.rule) - order(b.rule) || a.updatedAt.localeCompare(b.updatedAt) || a.taskId.localeCompare(b.taskId));
407
+ }
408
+ /** The preview's identity: exactly which tasks under which rules, for how many days. A person's confirmation names it. */
409
+ export function tidyDigest(items, days) {
410
+ // Each family exactly: its task, every version that goes with it, its project, its rule and the reason shown.
411
+ const set = items.map(one => [one.taskId, one.members.join(","), one.repo ?? "", one.rule, one.reason]).sort((a, b) => a[0].localeCompare(b[0]));
412
+ return createHash("sha256").update(JSON.stringify({ days, set }), "utf8").digest("hex").slice(0, 32);
413
+ }
414
+ /** The preview in its groups (empty ones left out), with each group's label. */
415
+ export function tidyGroups(items, days) {
416
+ return TIDY_GROUPS.map(group => ({ rule: group.rule, label: group.label.replace("{days}", String(days)), items: items.filter(one => one.rule === group.rule) }))
417
+ .filter(group => group.items.length > 0);
418
+ }
419
+ /** A day count from a person (1–365), or the default. */
420
+ export function tidyDays(raw) {
421
+ if (raw === null || raw === undefined || raw === "")
422
+ return TIDY_DEFAULT_DAYS;
423
+ if (!/^\d{1,3}$/.test(raw))
424
+ return null;
425
+ const days = Number(raw);
426
+ return days >= 1 && days <= 365 ? days : null;
427
+ }
428
+ /** A person puts away exactly what they previewed (or the ticked part of it), in one write transaction that recomputes
429
+ * the preview and refuses when its digest moved or a ticked task isn't in it. */
430
+ export function applyTidy(store, now, input) {
431
+ return store.transact(() => {
432
+ const items = tidyPreview(store, now, { releases: input.releases, days: input.days, ...(input.access === undefined ? {} : { access: input.access }) });
433
+ const digest = tidyDigest(items, input.days);
434
+ if (input.digest !== digest)
435
+ return { ok: false, reason: "changed", message: "The list changed since you looked. Check it again.", items, digest };
436
+ const shown = new Map(items.map(one => [one.taskId, one]));
437
+ const only = input.only === null ? null : [...new Set(input.only)];
438
+ if (only !== null && only.some(id => !shown.has(id)))
439
+ return { ok: false, reason: "outside", message: "Only tasks in the list can be put away. Check it again.", items, digest };
440
+ const targets = only === null ? items : only.map(id => shown.get(id));
441
+ if (targets.length === 0)
442
+ return { ok: false, reason: "empty", message: "Nothing was chosen to put away.", items, digest };
443
+ const archived = [], skipped = [];
444
+ for (const item of targets) {
445
+ const done = archiveTaskFamily(store, item.taskId, { by: input.by, byKind: "person", rule: item.rule, reason: item.reason }, now, { expect: item.members, guard: () => approverStandsNow(store, input.by, item.repo) });
446
+ if (done.ok)
447
+ archived.push(item);
448
+ else
449
+ skipped.push({ taskId: item.taskId, message: done.message });
450
+ }
451
+ return { ok: true, archived, skipped };
452
+ });
453
+ }
454
+ /** Tidy on its own (the worker's reconcile pass): its automatic rules only, at most `limit` tasks, in one write
455
+ * transaction. Idempotent: what is put away is skipped next time, and a restored task is never put away again. */
456
+ export function runTidy(store, now, releases, limit = TIDY_PASS_LIMIT, access) {
457
+ return store.transact(() => {
458
+ const archived = [];
459
+ for (const item of tidyPreview(store, now, { releases, days: null, ...(access === undefined ? {} : { access }) }).slice(0, limit)) {
460
+ const done = archiveTaskFamily(store, item.taskId, { by: "tidy", byKind: "tidy", rule: item.rule, reason: item.reason }, now, { expect: item.members });
461
+ if (done.ok)
462
+ archived.push(item);
463
+ }
464
+ return archived;
465
+ });
466
+ }
467
+ /** Whether this person may still put away or restore work in this project, read under the write lock (check and write
468
+ * together): an active approver who can see it. Null when they may; otherwise why not. */
469
+ export function approverStandsNow(store, name, repo) {
470
+ const account = store.accountOf(name);
471
+ if (account === null || account.revokedAt !== null || account.role !== "approver" || !store.accountCanAccess(name, repo))
472
+ return `${name} can't put away or restore tasks in this project.`;
473
+ return null;
474
+ }
475
+ /** The repository of a task's family, from its first version (all versions share it). */
476
+ export function familyRepoOf(store, refs) {
477
+ return refs.length === 0 ? null : store.refById(refs[0])?.repo ?? null;
478
+ }
479
+ /** "Archived by tidy: <reason>" or "Archived by <person>: <reason>": the one sentence every surface shows. */
480
+ export function archivedWords(archived) {
481
+ return `Archived by ${archived.byKind === "tidy" ? "tidy" : archived.by}: ${archived.reason}`;
482
+ }
483
+ /** The release journals beside the store's own database file (none for an in-memory one). */
484
+ export function releaseFactsOf(store) {
485
+ const file = store.databaseFile();
486
+ return file === null ? { facts: [], problem: "this database has no state folder" } : releaseFactsFor(file);
487
+ }
488
+ /** The lists one person sees, for tidy: every project for an unrestricted account, otherwise exactly theirs. The CLI's
489
+ * preview (`--as <name>`) and its apply read the same scope, so the same records give the same digest. */
490
+ export function accessOf(store, name) {
491
+ const account = store.accountOf(name);
492
+ if (account === null || account.revokedAt !== null)
493
+ return { principal: "operator", repos: [], includeUnplaced: false };
494
+ return account.projects === null ? { principal: "operator", repos: null, includeUnplaced: true }
495
+ : { principal: "operator", repos: [...account.projects], includeUnplaced: store.accountCanAccess(name, null) };
496
+ }
497
+ /** Narrows `access` to the caller's own lens (a remote person's project limit): never wider. No lens: unchanged. */
498
+ export function withinLens(access, lens) {
499
+ if (lens === null || access.principal !== "operator")
500
+ return access;
501
+ const repos = access.repos === null ? [...lens] : access.repos.filter(repo => lens.includes(repo));
502
+ return { ...access, repos, includeUnplaced: false };
503
+ }
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Settings → Updates: the installed version, one of three ways to update
3
3
  * (behind the operator's password), the update's steps live while it runs,
4
- * and a one-time "What's new" card afterwards. The work itself is the
4
+ * and a one-time "What's new" section afterwards. The work itself is the
5
5
  * `toolroll update` command (toolroll-update.ts), started as its own job.
6
+ * Built from the revamp kit; the Settings frame adds the title.
6
7
  */
7
8
  import { type Html } from "./html.js";
8
9
  import type { InstallMethod } from "./install-method.js";
9
10
  import { type RuntimeUpdateJournal } from "./toolroll-update.js";
10
- export declare const UPDATES_CSS: string;
11
+ /** The page is kit pieces now; nothing of the old look is left. Kept as an export for the shared stylesheet. */
12
+ export declare const UPDATES_CSS = "";
11
13
  export type UpdatesView = {
12
14
  current: string;
13
15
  /** The newest release, why it is unknown, or not asked because update checks are off. */
@@ -32,8 +34,10 @@ export type UpdatesView = {
32
34
  csrf: string;
33
35
  };
34
36
  /** The steps, each done, current, failed or waiting. Also the live region's fragment. */
35
- export declare function updateStepsHtml(j: RuntimeUpdateJournal, running: boolean): Html;
36
- export declare function updatesHtml(view: UpdatesView, notice: {
37
+ export declare function updateStepsHtml(j: RuntimeUpdateJournal, running: boolean, options?: {
38
+ title?: boolean;
39
+ }): Html;
40
+ export declare function updatesHtml(view: UpdatesView, said: {
37
41
  said?: string | null;
38
42
  problem?: string | null;
39
43
  }): Html;