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/store.js CHANGED
@@ -9,6 +9,8 @@ import { isAcpProfile, isAcpProvider } from "./acp-agents.js";
9
9
  import { ACP_CONFORMANCE_VERSION, launchFromJson } from "./acp-attest.js";
10
10
  import { reclaimDatabase } from "./database-reclaim.js";
11
11
  import { BASELINE_SCHEMA, BASELINE_SCHEMA_VERSION } from "./store-baseline.js";
12
+ import { refArchived, taskArchived } from "./task-archive.js";
13
+ export { refArchived, taskArchived } from "./task-archive.js";
12
14
  import { CHAT_PROVIDERS, CHAT_SCHEMA } from "./contracts/chat-tables.js";
13
15
  import {} from "./chat-core.js";
14
16
  import { ChatMessages, TELEGRAM_HOLD_REASONS, TELEGRAM_SKIPPED_ELSEWHERE, TELEGRAM_SKIPPED_OTHER_CHAT, TELEGRAM_SKIPPED_QUIET, TELEGRAM_UNSETTLED } from "./chat-messages.js";
@@ -94,6 +96,8 @@ import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installL
94
96
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
95
97
  import { CONSOLE_INSTANCE_SCHEMA } from "./console-instance.js";
96
98
  import { endUnattendedGrants, FLOW_UNATTENDED_SCHEMA, grantStandsSql } from "./flow-unattended.js";
99
+ import { flowChanged } from "./flow-changed.js";
100
+ import { FLOW_BROWSER_SCHEMA } from "./flow-browser.js";
97
101
  import { RECIPE_SCHEMA } from "./recipes.js";
98
102
  import { POLICY_SCHEMA, agentRefusal, approvalRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
99
103
  import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
@@ -1092,7 +1096,15 @@ const REGISTERED_AT_FILING = "again, as it was filed";
1092
1096
  // project's intake choice and last answer, and each request taken, with the task it became.
1093
1097
  // v143 adds, and changes no saved row: optional exact-screenshot context on result feedback
1094
1098
  // (diff_comment.image_json, V143_ADDED_COLUMNS; saved notes read NULL and keep their file/line target).
1095
- export const SCHEMA_VERSION = 143;
1099
+ // v144 adds, and changes no saved row: a person's approval of browser use in a flow's script zones (flow_browser_grant;
1100
+ // flow-browser.ts), the one sandbox rule a project check gets for Chromium; saved flows have none and get nothing.
1101
+ // v145 adds, and changes no saved row (docs/plans/task-tidy.md): put-away tasks (task_archive, one row per archived task
1102
+ // version; no row is every saved task: not archived), the completed workflow runs on a watched branch the checks
1103
+ // trigger saw (branch_check_run), and the reservations that hold work back from being put away (merge_in_flight,
1104
+ // deploy_in_flight, flow_step_task; V145_SCHEMA, new tables only).
1105
+ // v146 adds, and changes no saved row: each pull request check's own timing as GitHub last reported it for the head the
1106
+ // plane observed (ci_check), and each release step's start, end and outcome (release_step; V146_SCHEMA, new tables only).
1107
+ export const SCHEMA_VERSION = 146;
1096
1108
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
1097
1109
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
1098
1110
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -1225,7 +1237,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
1225
1237
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
1226
1238
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
1227
1239
  */
1228
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143]);
1240
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146]);
1229
1241
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
1230
1242
  export const V119_ADDED_COLUMNS = Object.freeze({
1231
1243
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -1365,6 +1377,92 @@ export const V140_ADDED_COLUMNS = Object.freeze({
1365
1377
  export const V143_ADDED_COLUMNS = Object.freeze({
1366
1378
  diff_comment: { image_json: { definition: "TEXT", saved: "NULL" } },
1367
1379
  });
1380
+ /** v145 (docs/plans/task-tidy.md): `task_archive` is a put-away task version. A row hides it from every list and count
1381
+ * and refuses every door that would dispatch, claim or approve it; restore deletes the row. Its history, results,
1382
+ * approvals and ledger stay exactly as they were. `merge_in_flight` is a direct merge between its last check and GitHub's
1383
+ * answer, owned by its token: a task can't be put away under it (a row older than 15 minutes is a crash, and no
1384
+ * longer counts). `deploy_in_flight` is a deployment whose candidate passed its gate (30 minutes, refreshed by every
1385
+ * phase that proves it again). `flow_step_task` is the tasks a flow step was admitted for, kept whatever the card does
1386
+ * next, so a step still running holds them. `branch_check_run` keeps each completed GitHub workflow run (pushed to
1387
+ * the watched branch of the watched repository) the "checks failed" trigger saw, green or red, so tidy can tell a
1388
+ * failure notice was fixed by a later commit. */
1389
+ export const V145_SCHEMA = `
1390
+ CREATE TABLE IF NOT EXISTS task_archive (
1391
+ task_ref INTEGER PRIMARY KEY REFERENCES task_ref(id),
1392
+ by TEXT NOT NULL,
1393
+ by_kind TEXT NOT NULL CHECK (by_kind IN ('person','tidy')),
1394
+ rule TEXT,
1395
+ reason TEXT NOT NULL,
1396
+ at TEXT NOT NULL
1397
+ );
1398
+ CREATE TABLE IF NOT EXISTS branch_check_run (
1399
+ repo TEXT NOT NULL,
1400
+ run_id INTEGER NOT NULL,
1401
+ branch TEXT NOT NULL,
1402
+ workflow TEXT NOT NULL,
1403
+ head_sha TEXT NOT NULL,
1404
+ conclusion TEXT NOT NULL CHECK (conclusion IN ('success','failure')),
1405
+ run_created_at TEXT NOT NULL,
1406
+ observed_at TEXT NOT NULL,
1407
+ PRIMARY KEY (repo, run_id)
1408
+ );
1409
+ CREATE INDEX IF NOT EXISTS branch_check_run_by_workflow ON branch_check_run (repo, branch, workflow, run_created_at);
1410
+ CREATE TABLE IF NOT EXISTS merge_in_flight (
1411
+ publication INTEGER PRIMARY KEY REFERENCES publication(id),
1412
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id),
1413
+ token TEXT NOT NULL,
1414
+ by TEXT NOT NULL,
1415
+ at TEXT NOT NULL
1416
+ );
1417
+ CREATE TABLE IF NOT EXISTS deploy_in_flight (
1418
+ task_ref INTEGER PRIMARY KEY REFERENCES task_ref(id),
1419
+ run INTEGER NOT NULL,
1420
+ at TEXT NOT NULL
1421
+ );
1422
+ CREATE TABLE IF NOT EXISTS flow_step_task (
1423
+ card INTEGER NOT NULL,
1424
+ entry INTEGER NOT NULL,
1425
+ task_id TEXT NOT NULL,
1426
+ PRIMARY KEY (card, entry, task_id)
1427
+ );
1428
+ `;
1429
+ /** v146: `ci_check` is one pull request check (a GitHub check run or commit status) as the plane's winning observation of
1430
+ * that head read it: written only with the observation that won its generation, replacing that head's rows wholesale.
1431
+ * `release_step` is one step of a release (release.ts RELEASE_STEPS) as `toolroll release` moved through it: when it
1432
+ * started, when it ended and how; recording it never changes the release. Both are read-only history for the Ship and
1433
+ * Release views and their CLI twins. */
1434
+ export const V146_SCHEMA = `
1435
+ CREATE TABLE IF NOT EXISTS ci_check (
1436
+ github_repo TEXT NOT NULL,
1437
+ pr_number INTEGER NOT NULL,
1438
+ head_sha TEXT NOT NULL,
1439
+ -- A check's identity: its kind (check run or commit status), its workflow and its name, so two workflows' checks
1440
+ -- with the same name stay two rows (a failing one never hides behind a passing namesake).
1441
+ ident TEXT NOT NULL,
1442
+ name TEXT NOT NULL,
1443
+ workflow TEXT,
1444
+ state TEXT NOT NULL CHECK (state IN ('queued','running','passed','failed','skipped','cancelled','neutral')),
1445
+ started_at TEXT,
1446
+ completed_at TEXT,
1447
+ url TEXT,
1448
+ observed_at TEXT NOT NULL,
1449
+ PRIMARY KEY (github_repo, pr_number, head_sha, ident)
1450
+ );
1451
+ CREATE TABLE IF NOT EXISTS release_step (
1452
+ task_id TEXT NOT NULL,
1453
+ tag TEXT NOT NULL,
1454
+ version TEXT NOT NULL,
1455
+ pr INTEGER,
1456
+ step TEXT NOT NULL,
1457
+ state TEXT NOT NULL CHECK (state IN ('running','done','failed','waiting')),
1458
+ started_at TEXT NOT NULL,
1459
+ ended_at TEXT,
1460
+ detail TEXT,
1461
+ updated_at TEXT NOT NULL,
1462
+ PRIMARY KEY (task_id, tag, step)
1463
+ );
1464
+ CREATE INDEX IF NOT EXISTS release_step_by_task ON release_step (task_id, updated_at);
1465
+ `;
1368
1466
  /**
1369
1467
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
1370
1468
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -4622,6 +4720,12 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
4622
4720
  for (const [column, { definition }] of Object.entries(columns))
4623
4721
  addColumn(db, table, column, definition);
4624
4722
  }
4723
+ // v144: approvals of browser use in flows' script zones (a new table only).
4724
+ db.exec(FLOW_BROWSER_SCHEMA);
4725
+ // v145: put-away tasks, green checks per branch and the reservations that hold work back (new tables only).
4726
+ db.exec(V145_SCHEMA);
4727
+ // v146: pull request checks' own timing and release steps (new tables only).
4728
+ db.exec(V146_SCHEMA);
4625
4729
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
4626
4730
  // nothing to reclaim.
4627
4731
  if (!fresh)
@@ -6361,6 +6465,7 @@ export class Store {
6361
6465
  FROM task JOIN task_ref ON task_ref.backend = ? AND task_ref.external_id = task.id
6362
6466
  WHERE task.state = 'queued'
6363
6467
  AND (? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)
6468
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)
6364
6469
  ORDER BY task.priority DESC, task.created_at, task_ref.id
6365
6470
  LIMIT 200`)
6366
6471
  .all(stamp, BUILT_IN, repo, repo)
@@ -6385,7 +6490,8 @@ export class Store {
6385
6490
  .prepare(`SELECT task.priority AS priority, task.created_at AS created_at, task_ref.id AS ref,
6386
6491
  task_ref.repo AS repo, task_ref.assigned_runner AS assigned
6387
6492
  FROM task JOIN task_ref ON task_ref.backend = ? AND task_ref.external_id = task.id
6388
- WHERE task.id = ? AND task.state = 'queued'`)
6493
+ WHERE task.id = ? AND task.state = 'queued'
6494
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)`)
6389
6495
  .get(BUILT_IN, taskId);
6390
6496
  if (me === undefined)
6391
6497
  return null;
@@ -6397,7 +6503,8 @@ export class Store {
6397
6503
  OR (task.priority = ? AND task.created_at = ? AND task_ref.id < ?)
6398
6504
  THEN 1 ELSE 0 END) AS ahead
6399
6505
  FROM task JOIN task_ref ON task_ref.backend = ? AND task_ref.external_id = task.id
6400
- WHERE task.state = 'queued' AND (task_ref.assigned_runner IS ?) AND (task_ref.repo IS ?)`)
6506
+ WHERE task.state = 'queued' AND (task_ref.assigned_runner IS ?) AND (task_ref.repo IS ?)
6507
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)`)
6401
6508
  .get(Number(me["priority"]), Number(me["priority"]), String(me["created_at"]), Number(me["priority"]), String(me["created_at"]), Number(me["ref"]), BUILT_IN, me["assigned"], me["repo"]);
6402
6509
  return {
6403
6510
  position: Number(row?.["ahead"] ?? 0) + 1,
@@ -6547,6 +6654,8 @@ export class Store {
6547
6654
  JOIN task_ref ON task_ref.backend = ? AND task_ref.external_id = task.id
6548
6655
  WHERE task.state = 'queued'
6549
6656
  ${reservation}
6657
+ -- A put-away task (v145) never dispatches.
6658
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)
6550
6659
  AND NOT EXISTS (
6551
6660
  SELECT 1 FROM hold
6552
6661
  WHERE hold.task_ref = task_ref.id
@@ -7328,6 +7437,9 @@ export class Store {
7328
7437
  // road it came by.
7329
7438
  if (basis === undefined && machineActing())
7330
7439
  return false;
7440
+ // A PUT-AWAY TASK (v145, task-tidy.md) is never approved, by any road: restore it first.
7441
+ if (taskArchived(this, taskId))
7442
+ return false;
7331
7443
  // A RELEASE WAITING FOR CI (Codex review round 1, finding 4): filed while its pull request's checks still ran,
7332
7444
  // only the automatic road (which proves CI itself) seals it until the plane read exact-head CI passing.
7333
7445
  if (basis?.kind !== "auto-check" && basis?.kind !== "auto-green" && releaseCiHoldOf(this, taskId) !== null)
@@ -10587,7 +10699,7 @@ export class Store {
10587
10699
  versions.sort((a, b) => a.id === root.id ? -1 : b.id === root.id ? 1 : a.createdAt.localeCompare(b.createdAt) || a.refId - b.refId);
10588
10700
  const current = versions.at(-1);
10589
10701
  return { root, current, versions,
10590
- otherActive: versions.filter(one => one.id !== current.id && (one.state === "queued" || one.state === "running")),
10702
+ otherActive: versions.filter(one => one.id !== current.id && (one.state === "queued" || one.state === "running") && !refArchived(this, one.refId)),
10591
10703
  problem: Number(rows[0]["broken"]) === 0 ? null : "Task history is unavailable or incomplete. This task is shown separately." };
10592
10704
  }
10593
10705
  /** Group and filter BEFORE the family limit; hydrate only that page's
@@ -10597,6 +10709,8 @@ export class Store {
10597
10709
  const order = options.order === "updated" ? "updated_at" : "created_at";
10598
10710
  const rows = this.db.prepare(`${projection.sql}, selected AS MATERIALIZED (
10599
10711
  SELECT * FROM ordered WHERE version_rank = 1
10712
+ -- A put-away family (v145) is out of every list, before the page is cut.
10713
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = ordered.ref_id)
10600
10714
  ${options.states === undefined ? "" : `AND state IN (${options.states.map(() => "?").join(",")})`}
10601
10715
  ORDER BY ${order} DESC, ref_id DESC LIMIT ?
10602
10716
  ) SELECT task.*, members.repo, members.ref_id, members.root_ref, members.broken
@@ -10687,7 +10801,8 @@ export class Store {
10687
10801
  COUNT(CASE WHEN live_families.root_ref IS NOT NULL THEN 1 END) AS running,
10688
10802
  COUNT(CASE WHEN live_families.root_ref IS NULL AND ordered.state = 'queued' THEN 1 END) AS queued,
10689
10803
  COUNT(CASE WHEN live_families.root_ref IS NULL AND ordered.state = 'done' AND ordered.updated_at >= ? THEN 1 END) AS doneRecently
10690
- FROM ordered LEFT JOIN live_families ON live_families.root_ref = ordered.root_ref WHERE version_rank = 1`)
10804
+ FROM ordered LEFT JOIN live_families ON live_families.root_ref = ordered.root_ref WHERE version_rank = 1
10805
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = ordered.ref_id)`)
10691
10806
  .get(...projection.params, JSON.stringify(liveTasks), new Date(now.getTime() - 24 * 60 * 60_000).toISOString());
10692
10807
  return { running: Number(row["running"]), queued: Number(row["queued"]), doneRecently: Number(row["doneRecently"]) };
10693
10808
  }
@@ -11121,6 +11236,9 @@ export class Store {
11121
11236
  if (ref === undefined)
11122
11237
  return { ok: false, reason: "unknown-task" };
11123
11238
  const taskRef = Number(ref["id"]);
11239
+ // A put-away task (v145) is restored before it runs again.
11240
+ if (refArchived(this, taskRef))
11241
+ return { ok: false, reason: "archived" };
11124
11242
  const stamp = now.toISOString();
11125
11243
  if (this.hasLiveClaim(taskRef, now))
11126
11244
  return { ok: false, reason: "claimed" };
@@ -12732,10 +12850,12 @@ export class Store {
12732
12850
  : source !== undefined && "project" in source && source.project !== "" ? "project"
12733
12851
  : source !== undefined && "installation" in source ? "installation" : "unknown";
12734
12852
  const project = scope === "task" ? String(ref["repo"]) : scope === "project" && source !== undefined && "project" in source ? source.project : null;
12853
+ // A put-away task (v145) nudges nobody: no notification about it is enqueued, whoever produced it. The check is
12854
+ // part of the insert itself, so a put away that commits first always wins.
12735
12855
  const { changes, lastInsertRowid } = this.db
12736
12856
  .prepare(`INSERT OR IGNORE INTO notification (dedupe_key, kind, subject, body, created_at, push_class, link, provenance_scope, project, task_ref, task_id, source_run, recipient)
12737
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
12738
- .run(notification.dedupeKey, notification.kind, notification.subject, notification.body, now.toISOString(), notification.pushClass ?? null, notification.link ?? null, scope, project, ref === undefined ? null : taskRef, ref === undefined ? null : String(ref["external_id"]), run?.id ?? null, notification.recipient ?? null);
12857
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = ?)`)
12858
+ .run(notification.dedupeKey, notification.kind, notification.subject, notification.body, now.toISOString(), notification.pushClass ?? null, notification.link ?? null, scope, project, ref === undefined ? null : taskRef, ref === undefined ? null : String(ref["external_id"]), run?.id ?? null, notification.recipient ?? null, ref === undefined ? null : Number(ref["id"]));
12739
12859
  // Whose act made this fact: pings follow it (see pingAllowed).
12740
12860
  const actor = currentActor();
12741
12861
  if (Number(changes) > 0 && actor !== null) {
@@ -14227,6 +14347,7 @@ export class Store {
14227
14347
  JOIN task_ref ON task_ref.backend = ? AND task_ref.external_id = task.id
14228
14348
  WHERE (? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)
14229
14349
  AND (? IS NULL OR task.state = ?)
14350
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)
14230
14351
  ORDER BY task.created_at DESC, task.id DESC LIMIT ?
14231
14352
  )
14232
14353
  UNION
@@ -14395,8 +14516,10 @@ export class Store {
14395
14516
  resolvePlanRevision(id, outcome, resolvedBy, now) {
14396
14517
  const { changes } = this.db
14397
14518
  .prepare(`UPDATE plan_revision SET status = ?, resolved_at = ?, resolved_by = ?
14398
- WHERE id = ? AND status = 'blocked'`)
14399
- .run(outcome, now.toISOString(), resolvedBy, id);
14519
+ WHERE id = ? AND status = 'blocked'
14520
+ -- A put-away task's (v145) plan change is never applied; rejecting it still is.
14521
+ AND (? = 'rejected' OR NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = plan_revision.task_ref))`)
14522
+ .run(outcome, now.toISOString(), resolvedBy, id, outcome);
14400
14523
  return Number(changes) > 0;
14401
14524
  }
14402
14525
  /** Stamp the exact plan revision and authority snapshot a run's brief was
@@ -15626,6 +15749,12 @@ export class Store {
15626
15749
  if (row === undefined || String(row["state"]) !== "claimed" || Number(row["generation"]) !== generation) {
15627
15750
  return { ok: false, outcome: "raced", why: "the claim moved" };
15628
15751
  }
15752
+ // A put-away task (v145) merges nothing: the intent waits for a person, who restores it first.
15753
+ const archivedTask = this.db.prepare("SELECT 1 FROM publication p JOIN task_archive a ON a.task_ref = p.task_ref WHERE p.id = ?").get(Number(row["publication"]));
15754
+ if (archivedTask !== undefined) {
15755
+ this.settleMergeIntent(id, generation, "waiting-human", now, { error: "the task is put away", from: "claimed", bumpGeneration: true });
15756
+ return { ok: false, outcome: "waiting-human", why: "the task is put away" };
15757
+ }
15629
15758
  const supersede = (why) => {
15630
15759
  this.settleMergeIntent(id, generation, "superseded", now, { error: why, from: "claimed" });
15631
15760
  return { ok: false, outcome: "superseded", why };
@@ -16040,9 +16169,12 @@ export class Store {
16040
16169
  }
16041
16170
  /** Save a new drawing of the flow only over the revision the editor saw (two editors never overwrite each other silently). */
16042
16171
  saveFlow(id, change, now) {
16043
- const { changes } = this.db.prepare("UPDATE flow SET name = ?, definition_json = ?, revision = revision + 1, updated_by = ?, updated_at = ? WHERE id = ? AND revision = ? AND state = 'active'")
16044
- .run(change.name, flowDefinitionForStore(change.definitionJson), change.by, now.toISOString(), id, change.sawRevision);
16045
- return Number(changes) === 1;
16172
+ // The one road for a changed flow (flow-changed.ts): what a person approved for the flow as it was ends with it.
16173
+ return flowChanged(this, { flow: id }, change.by, now, () => {
16174
+ const { changes } = this.db.prepare("UPDATE flow SET name = ?, definition_json = ?, revision = revision + 1, updated_by = ?, updated_at = ? WHERE id = ? AND revision = ? AND state = 'active'")
16175
+ .run(change.name, flowDefinitionForStore(change.definitionJson), change.by, now.toISOString(), id, change.sawRevision);
16176
+ return Number(changes) === 1;
16177
+ });
16046
16178
  }
16047
16179
  archiveFlow(id, by, now) {
16048
16180
  const { changes } = this.db.prepare("UPDATE flow SET state = 'archived', updated_by = ?, updated_at = ? WHERE id = ? AND state = 'active'").run(by, now.toISOString(), id);
@@ -16465,26 +16597,35 @@ export class Store {
16465
16597
  return this.transact(() => {
16466
16598
  const current = this.flowScript(script.repo, script.name);
16467
16599
  const version = (current?.version ?? Number(this.db.prepare("SELECT COALESCE(MAX(version), 0) AS v FROM flow_script WHERE repo = ? AND name = ?").get(script.repo, script.name)?.["v"] ?? 0)) + 1;
16468
- if (current !== null)
16469
- this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE id = ?").run(current.id);
16470
- // A "runs on its own" grant signed this script's code: a new version ends it (flow-unattended.ts).
16471
- endUnattendedGrants(this, { script: { repo: script.repo, name: script.name } }, script.by, "script changed", now);
16472
- this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
16473
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(script.repo, script.name, script.about, script.body, script.timeoutMinutes, version, script.digest, script.by, now.toISOString(), script.language ?? "shell", script.file ?? null, script.held ?? null);
16474
- return version;
16600
+ // A new version ends what was approved for the flows that run this script (flow-changed.ts): "runs on its own"
16601
+ // grants that signed its code, and browser use that sealed it.
16602
+ return flowChanged(this, { script: { repo: script.repo, name: script.name } }, script.by, now, () => {
16603
+ if (current !== null)
16604
+ this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE id = ?").run(current.id);
16605
+ this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
16606
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(script.repo, script.name, script.about, script.body, script.timeoutMinutes, version, script.digest, script.by, now.toISOString(), script.language ?? "shell", script.file ?? null, script.held ?? null);
16607
+ return version;
16608
+ }, () => true);
16475
16609
  });
16476
16610
  }
16477
- /** A person approves a held script (one an imported flow brought): from now on it runs. False when it wasn't held. */
16478
- approveFlowScript(repo, name) {
16479
- const { changes } = this.db.prepare("UPDATE flow_script SET held = NULL WHERE repo = ? AND name = ? AND state = 'active' AND held IS NOT NULL").run(repo, name);
16480
- return Number(changes) === 1;
16611
+ /** A person approves a held script (one an imported flow brought): from now on it runs. The approval covers exactly
16612
+ * the version and content the person read (`seen`): the check and the release of the hold are one statement in one
16613
+ * transaction, so a script removed and brought back under the same name is never approved unseen. */
16614
+ approveFlowScript(repo, name, seen) {
16615
+ return this.transact(() => {
16616
+ const current = this.flowScript(repo, name);
16617
+ if (current === null || current.held === null)
16618
+ return "not-held";
16619
+ if (current.version !== seen.version || current.digest !== seen.digest)
16620
+ return "changed";
16621
+ const { changes } = this.db.prepare("UPDATE flow_script SET held = NULL WHERE id = ? AND version = ? AND digest = ? AND state = 'active' AND held IS NOT NULL").run(current.id, seen.version, seen.digest);
16622
+ return Number(changes) === 1 ? "approved" : "changed";
16623
+ });
16481
16624
  }
16482
16625
  removeFlowScript(repo, name, by = "flow", now = new Date()) {
16483
- return this.transact(() => {
16626
+ // A grant that signed this script's code covers nothing without it: it ends, as does browser use that sealed it (flow-changed.ts).
16627
+ return flowChanged(this, { script: { repo, name } }, by, now, () => {
16484
16628
  const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
16485
- // A grant that signed this script's code covers nothing without it: it ends (flow-unattended.ts).
16486
- if (Number(changes) === 1)
16487
- endUnattendedGrants(this, { script: { repo, name } }, by, "script changed", now);
16488
16629
  return Number(changes) === 1;
16489
16630
  });
16490
16631
  }
@@ -17384,9 +17525,10 @@ export class Store {
17384
17525
  const page = Math.max(1, Math.min(Math.floor(limit), 50));
17385
17526
  // Roll-up admission binds BEFORE the LIMIT (Codex roll-up review,
17386
17527
  // finding 2): a repo outside the ceiling must not consume the page.
17387
- const admission = admitted === null
17528
+ // A put-away task (v145, task-tidy.md) is left out here, as from every list and count.
17529
+ const admission = (admitted === null
17388
17530
  ? "(? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)"
17389
- : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`;
17531
+ : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`) + " AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)";
17390
17532
  const params = admitted === null ? [repo, repo] : admitted;
17391
17533
  return this.db
17392
17534
  .prepare(`SELECT task.id AS task_id, task.title, task_scope.goal, task_scope.digest, task_scope.proposed_at, task_ref.repo AS task_repo
@@ -17416,9 +17558,10 @@ export class Store {
17416
17558
  */
17417
17559
  listRequeueablesScoped(repo, now, limit = 10, admitted = null) {
17418
17560
  const page = Math.max(1, Math.min(Math.floor(limit), 50));
17419
- const admission = admitted === null
17561
+ // A put-away task (v145, task-tidy.md) is left out here, as from every list and count.
17562
+ const admission = (admitted === null
17420
17563
  ? "(? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)"
17421
- : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`;
17564
+ : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`) + " AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)";
17422
17565
  const params = admitted === null ? [repo, repo] : admitted;
17423
17566
  return this.db
17424
17567
  .prepare(`WITH open_incidents AS (
@@ -17482,6 +17625,8 @@ export class Store {
17482
17625
  JOIN task_ref AS blocker_ref ON blocker_ref.backend = ? AND blocker_ref.external_id = task_edge.blocker
17483
17626
  WHERE ${admissionOf("dependent_ref")}
17484
17627
  AND ${admissionOf("blocker_ref")}
17628
+ -- Put-away tasks (v145) need no repair card, on either side.
17629
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref IN (dependent_ref.id, blocker_ref.id))
17485
17630
  GROUP BY task_edge.blocker, dependent_ref.repo, blocker_ref.repo
17486
17631
  ORDER BY task_edge.blocker LIMIT ?`)
17487
17632
  .all(BUILT_IN, BUILT_IN, ...paramsOf(), ...paramsOf(), page)
@@ -17514,6 +17659,7 @@ export class Store {
17514
17659
  WHERE task.state = 'done'
17515
17660
  AND (? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)
17516
17661
  ${admission}
17662
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)
17517
17663
  )
17518
17664
  SELECT completed.*, run.id AS run_id, run.outcome, run.handoff, run.cost_usd, run.provider, run.auth_mode, run.started_at, run.finished_at,
17519
17665
  publication.state AS pub_state, publication.pr_number, publication.pr_url,
@@ -17620,9 +17766,10 @@ export class Store {
17620
17766
  // finding 11): in the roll-up, rows outside the ceiling must not
17621
17767
  // consume the page and crowd out admitted cards. Serve still filters
17622
17768
  // per-row afterward — this is the bound, that is the law.
17623
- const admission = admitted === null
17769
+ // A put-away task (v145, task-tidy.md) is left out here, as from every list and count.
17770
+ const admission = (admitted === null
17624
17771
  ? "(? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)"
17625
- : `(task_ref.repo IS NULL OR task_ref.repo IN (${admitted.map(() => "?").join(",")}))`;
17772
+ : `(task_ref.repo IS NULL OR task_ref.repo IN (${admitted.map(() => "?").join(",")}))`) + " AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)";
17626
17773
  const admissionParams = admitted === null ? [repo, repo] : admitted;
17627
17774
  const columns = `
17628
17775
  SELECT task.id, task.title, task.state, task.updated_at, task.priority, task_ref.strikes, task_ref.repo AS task_repo,
@@ -17826,14 +17973,15 @@ export class Store {
17826
17973
  SUM(CASE WHEN task.state = 'queued' THEN 1 ELSE 0 END) AS queued,
17827
17974
  SUM(CASE WHEN task.state = 'running' THEN 1 ELSE 0 END) AS running
17828
17975
  FROM task JOIN task_ref ON task_ref.backend = 'built-in' AND task_ref.external_id = task.id
17829
- WHERE task_ref.repo = ?`)
17976
+ WHERE task_ref.repo = ? AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)`)
17830
17977
  .get(repo);
17831
17978
  const since = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
17832
17979
  const done = this.db
17833
17980
  .prepare(`SELECT COUNT(*) AS n FROM run
17834
17981
  JOIN task_ref ON task_ref.id = run.task_ref
17835
17982
  WHERE task_ref.repo = ? AND run.role IN ('builder','scout')
17836
- AND run.outcome IN ('built','no-change') AND run.finished_at IS NOT NULL AND run.finished_at >= ?`)
17983
+ AND run.outcome IN ('built','no-change') AND run.finished_at IS NOT NULL AND run.finished_at >= ?
17984
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)`)
17837
17985
  .get(repo, since);
17838
17986
  return { waiting: waiting, queued: Number(q.queued ?? 0), running: Number(q.running ?? 0), doneRecently: Number(done.n) };
17839
17987
  }
@@ -17842,9 +17990,10 @@ export class Store {
17842
17990
  // review, finding 11): with repo NULL the old predicate admitted every
17843
17991
  // stored project, ceiling or not. An empty admitted list reduces to
17844
17992
  // unplaced rows only — never IN ().
17845
- const admission = admitted === null
17993
+ // A put-away task (v145, task-tidy.md) is left out here, as from every list and count.
17994
+ const admission = (admitted === null
17846
17995
  ? "(? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)"
17847
- : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`;
17996
+ : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`) + " AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)";
17848
17997
  const params = () => (admitted === null ? [repo, repo] : [...admitted]);
17849
17998
  const row = this.db
17850
17999
  .prepare(`SELECT COUNT(*) AS n FROM (
@@ -18697,6 +18846,8 @@ export class Store {
18697
18846
  */
18698
18847
  resumeRunStop(args, now) {
18699
18848
  return this.transact(() => {
18849
+ if (refArchived(this, args.taskRef))
18850
+ return { ok: false, reason: "archived", detail: "this task is put away — restore it first" };
18700
18851
  const stop = this.stopOf(args.runId);
18701
18852
  const run = this.getRun(args.runId);
18702
18853
  if (stop === null || run === null)
@@ -21669,3 +21820,326 @@ export function dropFeedbackImage(store, runId, commentId, actor, unusable, now)
21669
21820
  return { ok: true, id };
21670
21821
  });
21671
21822
  }
21823
+ /** The put-away record of this task, or null when it is not put away. */
21824
+ export function archiveOf(store, taskId) {
21825
+ const row = store.handle.prepare(`SELECT a.*, r.external_id FROM task_archive a JOIN task_ref r ON r.id = a.task_ref
21826
+ WHERE r.backend = 'built-in' AND r.external_id = ?`).get(taskId);
21827
+ return row === undefined ? null : { taskRef: Number(row["task_ref"]), taskId: String(row["external_id"]), by: String(row["by"]),
21828
+ byKind: row["by_kind"] === "tidy" ? "tidy" : "person", rule: row["rule"] == null ? null : String(row["rule"]), reason: String(row["reason"]), at: String(row["at"]) };
21829
+ }
21830
+ /** Every version of a task's family, by the same proven lineage the task page reads (`taskFamilyOf`: same project, each
21831
+ * revision bound to its parent's own run by its brief). A family whose lineage doesn't prove is just this task. */
21832
+ export function taskFamilyRefs(store, taskId) {
21833
+ const family = store.taskFamilyOf(taskId, null, true);
21834
+ return family === null ? [] : family.versions.map(one => ({ id: one.refId, taskId: one.id })).sort((a, b) => a.id - b.id);
21835
+ }
21836
+ /** The first family version with something still going on, or null: running, a live newest claim, an unfinished run,
21837
+ * a process Toolroll hasn't seen exit, a build whose processes were never recorded, a stop still settling, a follow-up
21838
+ * check asked for and not finished, a flow step running for its card, a release in flight (a live release approval
21839
+ * that names it, or a deploy or merge reserved, running or unknown), or a pull request merging (an intent claimed or
21840
+ * firing, or a person's merge between its last check and GitHub's answer). */
21841
+ export function liveFamilyAttempt(store, refs, now) {
21842
+ if (refs.length === 0)
21843
+ return null;
21844
+ const row = store.handle.prepare(`SELECT r.external_id FROM task_ref r JOIN task t ON t.id = r.external_id
21845
+ WHERE r.id IN (SELECT value FROM json_each(?)) AND (t.state = 'running'
21846
+ OR EXISTS (SELECT 1 FROM claim c WHERE c.task_ref = r.id AND c.released_at IS NULL AND c.expires_at > ?
21847
+ AND c.lease_generation = (SELECT MAX(n.lease_generation) FROM claim n WHERE n.task_ref = r.id))
21848
+ OR EXISTS (SELECT 1 FROM run x WHERE x.task_ref = r.id AND x.outcome IS NULL)
21849
+ OR EXISTS (SELECT 1 FROM run_process p JOIN run x ON x.id = p.run WHERE x.task_ref = r.id AND p.exited_at IS NULL AND p.container_empty_at IS NULL)
21850
+ OR EXISTS (SELECT 1 FROM run x WHERE x.task_ref = r.id AND x.provider_started_at IS NOT NULL
21851
+ AND NOT EXISTS (SELECT 1 FROM run_process p WHERE p.run = x.id) AND NOT EXISTS (SELECT 1 FROM run_process_summary s WHERE s.run = x.id))
21852
+ OR EXISTS (SELECT 1 FROM run_stop s WHERE s.task_ref = r.id AND s.resumed_at IS NULL AND s.settled_at IS NULL)
21853
+ OR EXISTS (SELECT 1 FROM action_ledger q JOIN run x ON x.id = q.run_id WHERE x.task_ref = r.id AND q.action = 'checks requested'
21854
+ AND NOT EXISTS (SELECT 1 FROM action_ledger f WHERE f.run_id = q.run_id AND f.action = 'checks finished' AND f.outcome LIKE q.id || ':%'))
21855
+ OR EXISTS (SELECT 1 FROM flow_card c JOIN flow_step_run s ON s.card = c.id
21856
+ WHERE s.state = 'running' AND (c.task = r.external_id OR c.primary_task = r.external_id))
21857
+ OR EXISTS (SELECT 1 FROM flow_step_task t JOIN flow_step_run s ON s.card = t.card AND s.entry = t.entry
21858
+ WHERE s.state = 'running' AND t.task_id = r.external_id)
21859
+ OR EXISTS (SELECT 1 FROM deploy_in_flight d WHERE d.task_ref = r.id AND d.at > ?)
21860
+ OR EXISTS (SELECT 1 FROM release_authority a WHERE a.state IN ('open','deploying','merge-reserved') AND (a.anchor_task = r.external_id OR a.tip_task = r.external_id))
21861
+ OR EXISTS (SELECT 1 FROM release_operation o WHERE o.task_id = r.external_id AND o.state IN ('reserved','running','unknown'))
21862
+ OR EXISTS (SELECT 1 FROM merge_intent i JOIN publication p ON p.id = i.publication WHERE p.task_ref = r.id AND i.state IN ('claimed','firing'))
21863
+ OR EXISTS (SELECT 1 FROM merge_in_flight m WHERE m.task_ref = r.id AND m.at > ?))
21864
+ LIMIT 1`).get(JSON.stringify(refs), now.toISOString(), new Date(now.getTime() - 30 * 60_000).toISOString(), new Date(now.getTime() - 15 * 60_000).toISOString());
21865
+ return row === undefined ? null : String(row["external_id"]);
21866
+ }
21867
+ /** Puts a task away (v145): every version of its family, in one write transaction that re-checks it has no live
21868
+ * attempt, that its versions are exactly `expect` when the caller showed a person a family (a preview), and that
21869
+ * `guard` (the caller's own rule, re-read under the lock) still allows it. Its state, history, results, approvals and
21870
+ * ledger are untouched; its open notifications are resolved; one ledger row records who and why. */
21871
+ export function archiveTaskFamily(store, taskId, by, now, options = {}) {
21872
+ return store.transact(() => {
21873
+ const family = taskFamilyRefs(store, taskId);
21874
+ if (family.length === 0)
21875
+ return { ok: false, reason: "unknown-task", message: `No task ${taskId}.` };
21876
+ const refs = family.map(one => one.id);
21877
+ if (options.expect !== undefined && [...options.expect].sort((a, b) => a - b).join(",") !== refs.join(",")) {
21878
+ return { ok: false, reason: "blocked", message: `${taskId}'s versions changed since the list was shown.` };
21879
+ }
21880
+ // Already put away, every version: nothing to do. A family a person revived with a new revision puts away the rest.
21881
+ const remaining = refs.filter(ref => !refArchived(store, ref));
21882
+ if (remaining.length === 0)
21883
+ return { ok: false, reason: "archived", message: `${taskId} is already put away.` };
21884
+ const live = liveFamilyAttempt(store, refs, now);
21885
+ if (live !== null)
21886
+ return { ok: false, reason: "live", message: `${taskId} still has work going on. Stop it or let it finish, then put it away.` };
21887
+ const blocked = options.guard?.(refs) ?? null;
21888
+ if (blocked !== null)
21889
+ return { ok: false, reason: "blocked", message: blocked };
21890
+ const reason = by.reason.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 300) || "Put away";
21891
+ const at = now.toISOString();
21892
+ const insert = store.handle.prepare("INSERT INTO task_archive (task_ref, by, by_kind, rule, reason, at) VALUES (?, ?, ?, ?, ?, ?)");
21893
+ for (const ref of remaining)
21894
+ insert.run(ref, by.by, by.byKind, by.rule, reason, at);
21895
+ store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE resolved_at IS NULL AND task_ref IN (SELECT value FROM json_each(?))")
21896
+ .run(at, JSON.stringify(refs));
21897
+ // The family's flow cards hold while it is put away, so what they queued for people is settled too.
21898
+ store.handle.prepare(`UPDATE notification SET resolved_at = ? WHERE resolved_at IS NULL AND EXISTS (SELECT 1 FROM flow_card c
21899
+ WHERE (c.task IN (SELECT value FROM json_each(?)) OR c.primary_task IN (SELECT value FROM json_each(?)))
21900
+ AND (notification.dedupe_key LIKE 'flow:' || c.id || ':%' OR notification.dedupe_key LIKE 'flow-card:' || c.id || ':%'))`)
21901
+ .run(at, JSON.stringify(family.map(one => one.taskId)), JSON.stringify(family.map(one => one.taskId)));
21902
+ const repo = store.refById(refs[0])?.repo ?? null;
21903
+ store.recordAction({ at, actor: by.byKind === "tidy" ? "tidy" : `operator:${by.by}`, repo, taskId, runId: null, action: "task archived",
21904
+ outcome: by.rule ?? "by hand", source: "work", detail: `${reason}${refs.length > 1 ? ` (${refs.length} versions)` : ""}` });
21905
+ return { ok: true, refs };
21906
+ });
21907
+ }
21908
+ /** Brings a put-away task back (v145): every version of its family, with a ledger row. Tidy never puts a restored
21909
+ * task away again (tidy.ts reads the `task restored` row). */
21910
+ export function restoreTaskFamily(store, taskId, by, now, guard) {
21911
+ return store.transact(() => {
21912
+ const family = taskFamilyRefs(store, taskId);
21913
+ if (family.length === 0)
21914
+ return { ok: false, reason: "unknown-task", message: `No task ${taskId}.` };
21915
+ const refs = family.map(one => one.id).filter(ref => refArchived(store, ref));
21916
+ if (refs.length === 0)
21917
+ return { ok: false, reason: "not-archived", message: `${taskId} isn't put away.` };
21918
+ const blocked = guard?.(refs) ?? null;
21919
+ if (blocked !== null)
21920
+ return { ok: false, reason: "blocked", message: blocked };
21921
+ store.handle.prepare("DELETE FROM task_archive WHERE task_ref IN (SELECT value FROM json_each(?))").run(JSON.stringify(refs));
21922
+ const at = now.toISOString();
21923
+ store.recordAction({ at, actor: `operator:${by}`, repo: store.refById(refs[0])?.repo ?? null, taskId, runId: null, action: "task restored",
21924
+ outcome: "recorded", source: "work", detail: refs.length > 1 ? `${refs.length} versions` : null });
21925
+ return { ok: true, refs };
21926
+ });
21927
+ }
21928
+ /**
21929
+ * What a LIVE invite grants, for its own join page (Wave E, "Your access"): the role, who invited, the projects and
21930
+ * when it expires. Read without spending an attempt and under exactly inviteIsLive's predicate, so every dead shape
21931
+ * reads as null and still gets the one indistinguishable page (D6). Only a holder of the live token sees it.
21932
+ */
21933
+ export function liveInviteTerms(store, tokenValue, now) {
21934
+ const row = store.handle
21935
+ .prepare("SELECT role, minted_by, expires_at, projects_json FROM invite WHERE token_hash = ? AND revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ? AND attempts < 10")
21936
+ .get(createHash("sha256").update(tokenValue, "utf8").digest("hex"), now.toISOString());
21937
+ if (row === undefined)
21938
+ return null;
21939
+ return { role: String(row["role"]) === "approver" ? "approver" : "viewer", mintedBy: String(row["minted_by"]), expiresAt: String(row["expires_at"]), projects: readProjectAccess(row["projects_json"]) };
21940
+ }
21941
+ /** At most this many checks are kept for one head; GitHub's rollup is far smaller in practice. */
21942
+ export const CI_CHECKS_MAX = 200;
21943
+ const CI_CHECK_TEXT_MAX = 200;
21944
+ const CI_CHECK_URL_MAX = 2048;
21945
+ const CI_FAILED_CONCLUSIONS = new Set(["FAILURE", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
21946
+ const CI_PENDING = new Set(["QUEUED", "WAITING", "PENDING", "REQUESTED"]);
21947
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
21948
+ /** A timestamp GitHub gave, as an exact ISO instant, or null (GitHub's zero time "0001-01-01T00:00:00Z" is no time). */
21949
+ function ciInstant(value) {
21950
+ if (typeof value !== "string" || value.length > 40 || !ISO_INSTANT.test(value))
21951
+ return null;
21952
+ const at = Date.parse(value);
21953
+ if (!Number.isFinite(at) || at < Date.UTC(2000, 0, 1))
21954
+ return null;
21955
+ return new Date(at).toISOString();
21956
+ }
21957
+ /** The same ordering in SQL, for a column holding a check state. */
21958
+ const CI_SEVERITY_SQL = (column) => `CASE ${column} ${Object.entries({ failed: 6, cancelled: 5, running: 4, queued: 3, neutral: 2, skipped: 1, passed: 0 }).map(([state, n]) => `WHEN '${state}' THEN ${n}`).join(" ")} ELSE 0 END`;
21959
+ /** How bad a check state is: when one check identity appears twice, the worse stands. */
21960
+ const CI_SEVERITY = { failed: 6, cancelled: 5, running: 4, queued: 3, neutral: 2, skipped: 1, passed: 0 };
21961
+ /** A check's identity: its kind, its workflow and its name. */
21962
+ function ciCheckIdent(kind, row) {
21963
+ // Unit separators, never NUL (SQLite text ends at one): names and workflows had every control character removed.
21964
+ return `${kind}\u001f${row.workflow ?? ""}\u001f${row.name}`;
21965
+ }
21966
+ /** Short, printable text, or null. Every control (C0, DEL, C1) and format character (bidi overrides and isolates,
21967
+ * zero-width marks) is removed, so a check name can't move the terminal's cursor or reorder what `task ship` prints.
21968
+ * Check names never need a zero-width joiner, so emoji sequences lose theirs: that is the accepted cost. */
21969
+ function ciText(value) {
21970
+ if (typeof value !== "string")
21971
+ return null;
21972
+ const text = value.replace(/[\p{Cc}\p{Cf}]/gu, "").trim();
21973
+ return text === "" || text.length > CI_CHECK_TEXT_MAX ? null : text;
21974
+ }
21975
+ /** Only an https link to GitHub itself is kept; anything else reads null. */
21976
+ function ciUrl(value) {
21977
+ if (typeof value !== "string" || value.length > CI_CHECK_URL_MAX)
21978
+ return null;
21979
+ try {
21980
+ const url = new URL(value);
21981
+ return url.protocol === "https:" && url.username === "" && url.password === "" && url.port === "" && url.hostname === "github.com" ? url.toString() : null;
21982
+ }
21983
+ catch {
21984
+ return null;
21985
+ }
21986
+ }
21987
+ /**
21988
+ * GitHub's statusCheckRollup (gh pr view --json statusCheckRollup) as check rows: CheckRun items (name, workflowName,
21989
+ * status, conclusion, startedAt, completedAt, detailsUrl) and StatusContext items (context, state, startedAt, targetUrl).
21990
+ * Strict: an item that is not one of those shapes, has no usable name, or names a state it doesn't know is dropped; one
21991
+ * row per identity (kind, workflow and name), and when one identity appears twice (a re-run) the worse state stands;
21992
+ * at most CI_CHECKS_MAX.
21993
+ */
21994
+ export function parseCiRollup(rollup) {
21995
+ if (!Array.isArray(rollup))
21996
+ return [];
21997
+ const rows = [];
21998
+ const seen = new Map();
21999
+ for (const item of rollup.slice(0, CI_CHECKS_MAX * 2)) {
22000
+ if (rows.length >= CI_CHECKS_MAX)
22001
+ break;
22002
+ if (typeof item !== "object" || item === null || Array.isArray(item))
22003
+ continue;
22004
+ const raw = item;
22005
+ const kind = raw["__typename"];
22006
+ let row = null;
22007
+ if (kind === "CheckRun" || (kind === undefined && typeof raw["status"] === "string" && raw["context"] === undefined)) {
22008
+ const name = ciText(raw["name"]);
22009
+ const status = typeof raw["status"] === "string" ? raw["status"].toUpperCase() : "";
22010
+ const conclusion = typeof raw["conclusion"] === "string" ? raw["conclusion"].toUpperCase() : "";
22011
+ const state = status === "IN_PROGRESS" ? "running"
22012
+ : CI_PENDING.has(status) ? "queued"
22013
+ : status !== "COMPLETED" ? null
22014
+ : conclusion === "SUCCESS" ? "passed"
22015
+ : CI_FAILED_CONCLUSIONS.has(conclusion) ? "failed"
22016
+ : conclusion === "CANCELLED" ? "cancelled"
22017
+ : conclusion === "SKIPPED" ? "skipped"
22018
+ : conclusion === "NEUTRAL" || conclusion === "STALE" ? "neutral"
22019
+ : null;
22020
+ if (name === null || state === null)
22021
+ continue;
22022
+ const completedAt = state === "running" || state === "queued" ? null : ciInstant(raw["completedAt"]);
22023
+ row = { name, workflow: ciText(raw["workflowName"]), state, startedAt: state === "queued" ? null : ciInstant(raw["startedAt"]), completedAt, url: ciUrl(raw["detailsUrl"]), kind: "run" };
22024
+ }
22025
+ else if (kind === "StatusContext" || (kind === undefined && typeof raw["context"] === "string")) {
22026
+ const name = ciText(raw["context"]);
22027
+ const value = typeof raw["state"] === "string" ? raw["state"].toUpperCase() : "";
22028
+ const state = value === "SUCCESS" ? "passed" : value === "FAILURE" || value === "ERROR" ? "failed"
22029
+ : value === "PENDING" || value === "EXPECTED" ? "running" : null;
22030
+ if (name === null || state === null)
22031
+ continue;
22032
+ row = { name, workflow: null, state, startedAt: ciInstant(raw["startedAt"] ?? raw["createdAt"]), completedAt: null, url: ciUrl(raw["targetUrl"]), kind: "status" };
22033
+ }
22034
+ if (row === null)
22035
+ continue;
22036
+ // A completion before its start is GitHub's clock disagreeing with itself: keep the state, drop the end.
22037
+ if (row.startedAt !== null && row.completedAt !== null && row.completedAt < row.startedAt)
22038
+ row = { ...row, completedAt: null };
22039
+ const ident = ciCheckIdent(row.kind ?? "run", row);
22040
+ const at = seen.get(ident);
22041
+ if (at !== undefined) {
22042
+ if (CI_SEVERITY[row.state] > CI_SEVERITY[rows[at].state])
22043
+ rows[at] = row;
22044
+ continue;
22045
+ }
22046
+ seen.set(ident, rows.length);
22047
+ rows.push(row);
22048
+ }
22049
+ return rows;
22050
+ }
22051
+ /**
22052
+ * Replace one head's checks wholesale (v146). Call ONLY with an observation that won its generation (publish.ts settles
22053
+ * and records in one transaction): a losing, stale answer never writes here.
22054
+ */
22055
+ export function recordCiChecks(store, githubRepo, prNumber, headSha, rows, now) {
22056
+ store.transact(() => {
22057
+ // One head per pull request is kept: an older head's rows go with the new observation.
22058
+ store.handle.prepare("DELETE FROM ci_check WHERE github_repo = ? AND pr_number = ?").run(githubRepo, prNumber);
22059
+ const insert = store.handle.prepare(`INSERT INTO ci_check (github_repo, pr_number, head_sha, ident, name, workflow, state, started_at, completed_at, url, observed_at)
22060
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
22061
+ ON CONFLICT (github_repo, pr_number, head_sha, ident) DO UPDATE SET state = excluded.state, started_at = excluded.started_at,
22062
+ completed_at = excluded.completed_at, url = excluded.url WHERE ${CI_SEVERITY_SQL("excluded.state")} > ${CI_SEVERITY_SQL("ci_check.state")}`);
22063
+ const at = now.toISOString();
22064
+ // Rows a caller built itself carry no kind: a row with a workflow is a check run, one without may be a status; the
22065
+ // identity keeps the two apart either way.
22066
+ for (const row of rows.slice(0, CI_CHECKS_MAX))
22067
+ insert.run(githubRepo, prNumber, headSha, ciCheckIdent(row.kind ?? (row.workflow === null ? "status" : "run"), row), row.name, row.workflow, row.state, row.startedAt, row.completedAt, row.url, at);
22068
+ });
22069
+ }
22070
+ /** A pull request's checks for the head the plane's last winning observation recorded, or null when none was. */
22071
+ export function ciChecksFor(store, githubRepo, prNumber) {
22072
+ const observation = store.handle.prepare("SELECT head_sha, state, observed_at FROM ci_observation WHERE github_repo = ? AND pr_number = ?").get(githubRepo, prNumber);
22073
+ if (observation === undefined || String(observation["head_sha"]) === "" || String(observation["observed_at"]) === "")
22074
+ return null;
22075
+ const headSha = String(observation["head_sha"]);
22076
+ const rows = store.handle.prepare(`SELECT ident, name, workflow, state, started_at, completed_at, url, observed_at FROM ci_check
22077
+ WHERE github_repo = ? AND pr_number = ? AND head_sha = ? ORDER BY COALESCE(started_at, '9999'), name`).all(githubRepo, prNumber, headSha);
22078
+ const text = (value) => value === null || value === undefined ? null : String(value);
22079
+ return {
22080
+ githubRepo, prNumber, headSha, observedAt: String(observation["observed_at"]),
22081
+ observed: ["passing", "failing", "running"].find(one => one === String(observation["state"])) ?? "none",
22082
+ checks: rows.map(row => ({ name: String(row["name"]), workflow: text(row["workflow"]), state: String(row["state"]),
22083
+ startedAt: text(row["started_at"]), completedAt: text(row["completed_at"]), url: text(row["url"]),
22084
+ kind: String(row["ident"]).startsWith("status\u001f") ? "status" : "run" })),
22085
+ };
22086
+ }
22087
+ /** A task's pull request checks: its family's newest publication that has a pull request number, and that pull
22088
+ * request's checks (ciChecksFor). Null: no pull request, or none observed yet. */
22089
+ export function shipChecksForTask(store, taskId) {
22090
+ const refs = taskFamilyRefs(store, taskId).map(one => one.id);
22091
+ if (refs.length === 0)
22092
+ return null;
22093
+ const row = store.handle.prepare(`SELECT github_repo, pr_number FROM publication WHERE task_ref IN (SELECT value FROM json_each(?))
22094
+ AND pr_number IS NOT NULL ORDER BY id DESC LIMIT 1`).get(JSON.stringify(refs));
22095
+ return row === undefined ? null : ciChecksFor(store, String(row["github_repo"]), Number(row["pr_number"]));
22096
+ }
22097
+ const RELEASE_STEP_NAMES = new Set(["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew"]);
22098
+ /**
22099
+ * Record a release step (v146): `running` or `waiting` opens it (its first start is kept), `done` or `failed` ends it.
22100
+ * A step that runs again after a stop starts its row again. A failed write throws here; the release's adapter swallows
22101
+ * it, so recording never changes a release.
22102
+ */
22103
+ export function recordReleaseStep(store, entry) {
22104
+ if (!RELEASE_STEP_NAMES.has(entry.step) || entry.taskId === "" || entry.tag === "")
22105
+ return;
22106
+ const at = entry.at.toISOString();
22107
+ // A stop message can carry a remote tool's words: no control or format character reaches `release status`.
22108
+ const detail = entry.detail == null ? null : entry.detail.replace(/[\p{Cc}\p{Cf}]+/gu, " ").trim().slice(0, 1000);
22109
+ const ended = entry.state === "done" || entry.state === "failed";
22110
+ store.handle.prepare(`INSERT INTO release_step (task_id, tag, version, pr, step, state, started_at, ended_at, detail, updated_at)
22111
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
22112
+ ON CONFLICT (task_id, tag, step) DO UPDATE SET
22113
+ state = excluded.state, version = excluded.version, pr = excluded.pr, updated_at = excluded.updated_at,
22114
+ started_at = CASE WHEN release_step.ended_at IS NOT NULL AND excluded.ended_at IS NULL THEN excluded.started_at ELSE release_step.started_at END,
22115
+ ended_at = excluded.ended_at,
22116
+ detail = CASE WHEN excluded.detail IS NOT NULL OR excluded.ended_at IS NULL THEN excluded.detail ELSE release_step.detail END`)
22117
+ .run(entry.taskId, entry.tag, entry.version, entry.pr, entry.step, entry.state, at, ended ? at : null, detail, at);
22118
+ }
22119
+ /** A release's steps, for the release task: the newest tag recorded for it, its steps in the order they ran, and the
22120
+ * step it stopped at when the last thing recorded was a failure. Null: no step recorded. */
22121
+ export function releaseProgressFor(store, taskId) {
22122
+ const newest = store.handle.prepare("SELECT tag FROM release_step WHERE task_id = ? ORDER BY updated_at DESC, rowid DESC LIMIT 1").get(taskId);
22123
+ if (newest === undefined)
22124
+ return null;
22125
+ const tag = String(newest["tag"]);
22126
+ const rows = store.handle.prepare("SELECT * FROM release_step WHERE task_id = ? AND tag = ? ORDER BY started_at, rowid").all(taskId, tag);
22127
+ const steps = rows.map(row => ({ step: String(row["step"]), state: String(row["state"]), startedAt: String(row["started_at"]),
22128
+ endedAt: row["ended_at"] === null ? null : String(row["ended_at"]), detail: row["detail"] === null ? null : String(row["detail"]) }));
22129
+ const last = [...rows].sort((a, b) => String(a["updated_at"]).localeCompare(String(b["updated_at"]))).at(-1);
22130
+ const stopped = String(last["state"]) === "failed" ? { step: String(last["step"]), message: last["detail"] === null ? "" : String(last["detail"]), at: String(last["updated_at"]) } : null;
22131
+ const pr = last["pr"] === null ? null : Number(last["pr"]);
22132
+ return { tag, version: String(last["version"]), pr, startedAt: steps[0].startedAt, updatedAt: String(last["updated_at"]), steps, stopped };
22133
+ }
22134
+ /** The release task whose steps were recorded most recently (v146), or null when none was. */
22135
+ export function latestReleaseStepTask(store) {
22136
+ const row = store.handle.prepare("SELECT task_id FROM release_step ORDER BY updated_at DESC, rowid DESC LIMIT 1").get();
22137
+ return row === undefined ? null : String(row["task_id"]);
22138
+ }
22139
+ /** Seconds a step or check took (to its end, or to `now` while it runs); null without a start. */
22140
+ export function elapsedSeconds(startedAt, endedAt, now) {
22141
+ if (startedAt === null)
22142
+ return null;
22143
+ const start = Date.parse(startedAt), end = endedAt === null ? now.getTime() : Date.parse(endedAt);
22144
+ return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1000)) : null;
22145
+ }