toolroll 0.9.86 → 0.9.88

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 (173) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/acp-check.d.ts +9 -0
  3. package/dist/acp-check.js +40 -28
  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/backup-ui.d.ts +5 -4
  10. package/dist/backup-ui.js +65 -32
  11. package/dist/browser/terminal.css +1 -1
  12. package/dist/browser/terminal.js +57 -54
  13. package/dist/browser/workspace.css +2 -2
  14. package/dist/browser/workspace.js +35 -70
  15. package/dist/browser-crew.js +1 -1
  16. package/dist/browser-workspace.d.ts +329 -183
  17. package/dist/browser-workspace.js +10 -9
  18. package/dist/chat-controls.d.ts +1 -1
  19. package/dist/chat-controls.js +1 -1
  20. package/dist/chat-settings-page.d.ts +29 -9
  21. package/dist/chat-settings-page.js +59 -28
  22. package/dist/check-levels-ui.d.ts +2 -1
  23. package/dist/check-levels-ui.js +67 -58
  24. package/dist/contracts/cli.d.ts +43 -0
  25. package/dist/contracts/cli.js +4 -0
  26. package/dist/contracts/console-api.d.ts +1 -1
  27. package/dist/contracts/console-api.js +1 -1
  28. package/dist/credentials-ui.d.ts +5 -3
  29. package/dist/credentials-ui.js +58 -31
  30. package/dist/design/kit.css +722 -0
  31. package/dist/design/kit.d.ts +208 -0
  32. package/dist/design/kit.js +177 -0
  33. package/dist/design/tokens.css +31 -1
  34. package/dist/discord-settings.js +8 -4
  35. package/dist/export-ui.d.ts +6 -4
  36. package/dist/export-ui.js +18 -8
  37. package/dist/flow-changed.d.ts +14 -0
  38. package/dist/flow-changed.js +53 -0
  39. package/dist/flow-gallery-ui.d.ts +41 -4
  40. package/dist/flow-gallery-ui.js +95 -63
  41. package/dist/flow-starters-ui.d.ts +3 -9
  42. package/dist/flow-starters-ui.js +12 -23
  43. package/dist/flow-unattended-words.d.ts +20 -0
  44. package/dist/flow-unattended-words.js +24 -0
  45. package/dist/flow-unattended.d.ts +9 -5
  46. package/dist/flow-unattended.js +45 -23
  47. package/dist/flows-cli.js +7 -3
  48. package/dist/flows-ui.d.ts +12 -6
  49. package/dist/flows-ui.js +141 -30
  50. package/dist/integrations-ui.d.ts +6 -4
  51. package/dist/integrations-ui.js +34 -40
  52. package/dist/integrations.d.ts +2 -2
  53. package/dist/integrations.js +4 -4
  54. package/dist/kits-ui.d.ts +8 -10
  55. package/dist/kits-ui.js +29 -33
  56. package/dist/kits.js +1 -1
  57. package/dist/knowledge-ui.js +76 -19
  58. package/dist/ledger-view.d.ts +2 -0
  59. package/dist/ledger-view.js +41 -38
  60. package/dist/models-ui.d.ts +8 -3
  61. package/dist/models-ui.js +82 -59
  62. package/dist/monitoring-ui.d.ts +4 -2
  63. package/dist/monitoring-ui.js +44 -13
  64. package/dist/needs-you.d.ts +1 -1
  65. package/dist/needs-you.js +8 -0
  66. package/dist/operate-remote-arguments.js +1 -0
  67. package/dist/operate-remote.js +1 -1
  68. package/dist/operate.d.ts +1 -1
  69. package/dist/operate.js +38 -4
  70. package/dist/passwordless.d.ts +1 -1
  71. package/dist/passwordless.js +1 -1
  72. package/dist/people-audit-ui.d.ts +2 -1
  73. package/dist/people-audit-ui.js +25 -23
  74. package/dist/policy-ui.d.ts +3 -2
  75. package/dist/policy-ui.js +27 -21
  76. package/dist/policy.d.ts +3 -0
  77. package/dist/policy.js +14 -1
  78. package/dist/project-delete-ui.d.ts +4 -2
  79. package/dist/project-delete-ui.js +135 -126
  80. package/dist/project-knowledge.d.ts +10 -10
  81. package/dist/publish.d.ts +11 -0
  82. package/dist/publish.js +16 -3
  83. package/dist/pull-request-ui.d.ts +2 -1
  84. package/dist/pull-request-ui.js +39 -28
  85. package/dist/recipe-ui.d.ts +4 -2
  86. package/dist/recipe-ui.js +14 -4
  87. package/dist/release-adapters.d.ts +3 -0
  88. package/dist/release-adapters.js +22 -2
  89. package/dist/release-cli.js +81 -9
  90. package/dist/release.d.ts +23 -0
  91. package/dist/release.js +46 -4
  92. package/dist/releases.js +1 -1
  93. package/dist/request-budget-ui.d.ts +4 -2
  94. package/dist/request-budget-ui.js +30 -19
  95. package/dist/retention-ui.d.ts +4 -3
  96. package/dist/retention-ui.js +15 -12
  97. package/dist/scope.d.ts +3 -0
  98. package/dist/scope.js +6 -0
  99. package/dist/serve.d.ts +0 -1
  100. package/dist/serve.js +51 -7
  101. package/dist/server/agents-computers.d.ts +9 -0
  102. package/dist/server/agents-computers.js +67 -0
  103. package/dist/server/chat.js +16 -16
  104. package/dist/server/chrome.d.ts +1 -1
  105. package/dist/server/chrome.js +19 -9
  106. package/dist/server/console.css +24 -12
  107. package/dist/server/flows.js +64 -36
  108. package/dist/server/home.d.ts +35 -0
  109. package/dist/server/home.js +104 -0
  110. package/dist/server/http.d.ts +3 -0
  111. package/dist/server/http.js +5 -0
  112. package/dist/server/kit-standalone.d.ts +22 -0
  113. package/dist/server/kit-standalone.js +70 -0
  114. package/dist/server/kit.d.ts +419 -0
  115. package/dist/server/kit.js +369 -0
  116. package/dist/server/pages.d.ts +2 -1
  117. package/dist/server/pages.js +213 -174
  118. package/dist/server/people-tokens.js +26 -85
  119. package/dist/server/render-history.d.ts +51 -0
  120. package/dist/server/render-history.js +228 -0
  121. package/dist/server/render-new-task.d.ts +29 -0
  122. package/dist/server/render-new-task.js +96 -0
  123. package/dist/server/render-next.d.ts +63 -0
  124. package/dist/server/render-next.js +109 -0
  125. package/dist/server/render-pages.d.ts +22 -190
  126. package/dist/server/render-pages.js +123 -575
  127. package/dist/server/render-people.d.ts +24 -3
  128. package/dist/server/render-people.js +93 -72
  129. package/dist/server/render-projects.d.ts +103 -0
  130. package/dist/server/render-projects.js +247 -0
  131. package/dist/server/render-results.d.ts +5 -24
  132. package/dist/server/render-results.js +48 -185
  133. package/dist/server/render-settings.d.ts +5 -74
  134. package/dist/server/render-settings.js +102 -493
  135. package/dist/server/render-tasks.d.ts +35 -5
  136. package/dist/server/render-tasks.js +114 -85
  137. package/dist/server/route-table.js +20 -5
  138. package/dist/server/settings.js +276 -255
  139. package/dist/server/task-views.d.ts +8 -0
  140. package/dist/server/task-views.js +85 -2
  141. package/dist/server/tasks.d.ts +8 -0
  142. package/dist/server/tasks.js +95 -40
  143. package/dist/server/terminals.js +1 -1
  144. package/dist/server/welcome.d.ts +22 -0
  145. package/dist/server/welcome.js +69 -0
  146. package/dist/settings-groups-ui.d.ts +279 -0
  147. package/dist/settings-groups-ui.js +615 -0
  148. package/dist/settings-kit.d.ts +42 -6
  149. package/dist/settings-kit.js +69 -7
  150. package/dist/setup-guide.d.ts +2 -2
  151. package/dist/skills-ui.js +59 -7
  152. package/dist/slack-settings.js +12 -5
  153. package/dist/spend-ui.d.ts +45 -26
  154. package/dist/spend-ui.js +124 -56
  155. package/dist/sso-ui.d.ts +5 -2
  156. package/dist/sso-ui.js +63 -31
  157. package/dist/storage-ui.d.ts +3 -2
  158. package/dist/storage-ui.js +56 -50
  159. package/dist/store.d.ts +112 -3
  160. package/dist/store.js +292 -20
  161. package/dist/surface.js +5 -1
  162. package/dist/teams-settings.d.ts +2 -1
  163. package/dist/teams-settings.js +24 -41
  164. package/dist/telegram-settings.d.ts +0 -4
  165. package/dist/telegram-settings.js +52 -25
  166. package/dist/toolroll-update-ui.d.ts +8 -4
  167. package/dist/toolroll-update-ui.js +55 -45
  168. package/dist/tools-ui.d.ts +5 -3
  169. package/dist/tools-ui.js +107 -42
  170. package/dist/work-index.js +11 -7
  171. package/dist/workspace-ui.d.ts +1 -1
  172. package/dist/workspace-ui.js +7 -4
  173. package/package.json +1 -1
package/dist/store.js CHANGED
@@ -96,6 +96,7 @@ import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installL
96
96
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
97
97
  import { CONSOLE_INSTANCE_SCHEMA } from "./console-instance.js";
98
98
  import { endUnattendedGrants, FLOW_UNATTENDED_SCHEMA, grantStandsSql } from "./flow-unattended.js";
99
+ import { flowChanged } from "./flow-changed.js";
99
100
  import { FLOW_BROWSER_SCHEMA } from "./flow-browser.js";
100
101
  import { RECIPE_SCHEMA } from "./recipes.js";
101
102
  import { POLICY_SCHEMA, agentRefusal, approvalRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
@@ -1101,7 +1102,9 @@ const REGISTERED_AT_FILING = "again, as it was filed";
1101
1102
  // version; no row is every saved task: not archived), the completed workflow runs on a watched branch the checks
1102
1103
  // trigger saw (branch_check_run), and the reservations that hold work back from being put away (merge_in_flight,
1103
1104
  // deploy_in_flight, flow_step_task; V145_SCHEMA, new tables only).
1104
- export const SCHEMA_VERSION = 145;
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;
1105
1108
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
1106
1109
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
1107
1110
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -1234,7 +1237,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
1234
1237
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
1235
1238
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
1236
1239
  */
1237
- 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]);
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]);
1238
1241
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
1239
1242
  export const V119_ADDED_COLUMNS = Object.freeze({
1240
1243
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -1423,6 +1426,43 @@ CREATE TABLE IF NOT EXISTS flow_step_task (
1423
1426
  PRIMARY KEY (card, entry, task_id)
1424
1427
  );
1425
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
+ `;
1426
1466
  /**
1427
1467
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
1428
1468
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -4684,6 +4724,8 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
4684
4724
  db.exec(FLOW_BROWSER_SCHEMA);
4685
4725
  // v145: put-away tasks, green checks per branch and the reservations that hold work back (new tables only).
4686
4726
  db.exec(V145_SCHEMA);
4727
+ // v146: pull request checks' own timing and release steps (new tables only).
4728
+ db.exec(V146_SCHEMA);
4687
4729
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
4688
4730
  // nothing to reclaim.
4689
4731
  if (!fresh)
@@ -16127,9 +16169,12 @@ export class Store {
16127
16169
  }
16128
16170
  /** Save a new drawing of the flow only over the revision the editor saw (two editors never overwrite each other silently). */
16129
16171
  saveFlow(id, change, now) {
16130
- 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'")
16131
- .run(change.name, flowDefinitionForStore(change.definitionJson), change.by, now.toISOString(), id, change.sawRevision);
16132
- 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
+ });
16133
16178
  }
16134
16179
  archiveFlow(id, by, now) {
16135
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);
@@ -16552,26 +16597,35 @@ export class Store {
16552
16597
  return this.transact(() => {
16553
16598
  const current = this.flowScript(script.repo, script.name);
16554
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;
16555
- if (current !== null)
16556
- this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE id = ?").run(current.id);
16557
- // A "runs on its own" grant signed this script's code: a new version ends it (flow-unattended.ts).
16558
- endUnattendedGrants(this, { script: { repo: script.repo, name: script.name } }, script.by, "script changed", now);
16559
- this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
16560
- 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);
16561
- 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);
16562
16609
  });
16563
16610
  }
16564
- /** A person approves a held script (one an imported flow brought): from now on it runs. False when it wasn't held. */
16565
- approveFlowScript(repo, name) {
16566
- 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);
16567
- 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
+ });
16568
16624
  }
16569
16625
  removeFlowScript(repo, name, by = "flow", now = new Date()) {
16570
- 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, () => {
16571
16628
  const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
16572
- // A grant that signed this script's code covers nothing without it: it ends (flow-unattended.ts).
16573
- if (Number(changes) === 1)
16574
- endUnattendedGrants(this, { script: { repo, name } }, by, "script changed", now);
16575
16629
  return Number(changes) === 1;
16576
16630
  });
16577
16631
  }
@@ -21871,3 +21925,221 @@ export function restoreTaskFamily(store, taskId, by, now, guard) {
21871
21925
  return { ok: true, refs };
21872
21926
  });
21873
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
+ }
package/dist/surface.js CHANGED
@@ -165,6 +165,10 @@ const GUIDE_ROWS = [
165
165
  positionals: [{ name: "task", required: true, meaning: "any version of the task" }],
166
166
  flags: [jsonFlag, dbFlag, { name: "level", takesValue: true, meaning: "quick (default) or full" }],
167
167
  notableReasons: ["no-result", "no-check", "checks-failed"] },
168
+ { invocation: "task ship", synopsis: "the task's pull request checks as last observed for its head: each check's state and how long it took",
169
+ audience: "agent", agentMayInvoke: true, mutation: "none",
170
+ positionals: [{ name: "task", required: true, meaning: "any version of the task" }], flags: [jsonFlag, dbFlag],
171
+ notableReasons: ["unknown-task", "no-pull-request"] },
168
172
  { invocation: "task add-tests", synopsis: "file a small task to write tests for the task's latest change; it waits for approval like any task",
169
173
  audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
170
174
  positionals: [{ name: "task", required: true, meaning: "any version of the task" }], flags: [jsonFlag, dbFlag],
@@ -473,7 +477,7 @@ const REMOTE_STEP_UP = [
473
477
  ];
474
478
  const REMOTE_YES = [
475
479
  "status", "integrations", "ready", "gaps", "grants", "sync", "audit",
476
- "task add", "task ask", "task checks", "task add-tests", "task list", "task show", "check-progress", "task wait", "task complete", "task revise",
480
+ "task add", "task ask", "task checks", "task ship", "task add-tests", "task list", "task show", "check-progress", "task wait", "task complete", "task revise",
477
481
  "task state", "task block", "task unblock", "task next", "task steer", "task pause", "task continue", "task assign", "task scope", "task plan", "task hold", "task unhold", "task require",
478
482
  "task requeue", "task review", "task repair", "task route", "task reopen", "task stop", "task resume",
479
483
  "runner list", "coordinator list", "cap list", "cap add", "outbox list", "incident list", "incident resolve",
@@ -1,7 +1,8 @@
1
1
  /** Settings → Teams: connect the Entra app once (operator), then each
2
2
  * teammate pairs their own Teams account. The messaging endpoint the bot
3
3
  * registration must point at is shown here; it has to be reachable from the
4
- * internet, which a tailnet-only address is not. */
4
+ * internet, which a tailnet-only address is not. One page with Slack and
5
+ * Discord (chat-settings-page.ts). */
5
6
  import type { Store } from "./store.js";
6
7
  import { type Html } from "./html.js";
7
8
  export declare function teamsSettingsHtml(store: Store, dir: string, options?: {
@@ -1,46 +1,29 @@
1
- import { html, joinHtml, postForm } from "./html.js";
2
- import { ChatState } from "./chat-delivery-state.js";
1
+ import { html, joinHtml } from "./html.js";
3
2
  import { loadTeamsCredentials } from "./teams-api.js";
4
- import { loadPrimary } from "./webhooks.js";
5
3
  import { TEAMS_MESSAGES_PATH } from "./teams.js";
4
+ import { formRow, input } from "./server/kit.js";
5
+ import { chatSecretRow, chatSettingsHtml } from "./chat-settings-page.js";
6
6
  export function teamsSettingsHtml(store, dir, options = {}) {
7
- const credentials = loadTeamsCredentials(dir), state = new ChatState(store, "teams");
8
- const bindings = credentials ? state.bindings(credentials.installation).filter(one => state.live(one)) : [];
9
- const binding = options.who === undefined ? null : bindings.find(one => one.approver === options.who) ?? null;
10
- const others = bindings.length - (binding === null ? 0 : 1);
11
- const runtime = credentials ? state.runtime(credentials.installation) : null;
12
- const now = options.now ?? new Date();
13
- const live = runtime && runtime.leaseUntil !== null && runtime.leaseUntil > now.toISOString() && runtime.connected;
14
- const password = html `<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>`;
15
- const post = (action, content) => postForm(`/settings/teams/${action}`, content, { attrs: { class: "card" } });
16
7
  const endpoint = options.publicUrl ? `${options.publicUrl.replace(/\/$/, "")}${TEAMS_MESSAGES_PATH}` : null;
17
- const parts = [];
18
- if (!credentials) {
19
- parts.push(html `<p>Manage projects and review results in a Teams chat, and let a channel follow a team conversation.</p>`, html `<details open><summary>Register the bot</summary><ol><li>In <a href="https://portal.azure.com" target="_blank" rel="noopener noreferrer">Azure</a>, create an <em>Azure Bot</em> with a new single-tenant app registration; note the app (client) id and the directory (tenant) id, and create a client secret.</li>`, html `<li>Set its messaging endpoint to <code>${endpoint === null ? "https://your-public-address" + TEAMS_MESSAGES_PATH : endpoint}</code>. Microsoft must reach it from the internet: a tailnet-only address needs Tailscale Funnel (or another public tunnel) on that path.</li>`, html `<li>Enable the Microsoft Teams channel on the bot, then add the app to Teams (personal scope; team scope for channels).</li></ol></details>`, post("connect", joinHtml([
20
- html `<label>App (client) id<input style="min-height:44px" type="text" name="app-id" autocomplete="off" required></label>`,
21
- html `<label>Directory (tenant) id<input style="min-height:44px" type="text" name="tenant" autocomplete="off" required></label>`,
22
- html `<label>Client secret<input style="min-height:44px" type="password" name="secret" autocomplete="off" required></label>`,
23
- html `${password}<p class="meta">The secret stays on this installation. Next, pair your own Teams account.</p><button type="submit">Connect Teams</button>`
24
- ])));
25
- }
26
- else {
27
- parts.push(html `<p><strong>Microsoft Teams</strong> · ${live ? "Signed in" : "Waiting for sign-in"}</p>`);
28
- if (endpoint !== null)
29
- parts.push(html `<p class="meta">Messaging endpoint: <code>${endpoint}</code> — must be reachable from the internet.</p>`);
30
- if (runtime?.problem)
31
- parts.push(html `<p role="status">${String(runtime.problem)}</p>`);
32
- if (!live && !runtime?.problem)
33
- parts.push(html `<p>Keep the Toolroll worker running to answer Teams messages.</p>`);
34
- if (options.code) {
35
- parts.push(html `<h2>Pair your account</h2><p>Send this to the Toolroll bot in a personal Teams chat. It expires in 10 minutes.</p>`, html `<label>Pairing message<input type="text" readonly value="pair ${options.code}" autocomplete="off" style="width:100%;max-width:100%;font-size:14px;min-height:44px"></label>`, html `<a class="button-link" style="min-height:44px;white-space:nowrap" href="/settings/teams">Check connection</a>`);
36
- }
37
- else if (binding && state.live(binding)) {
38
- parts.push(html `<p>Your Teams account is paired.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} paired too.` : ""}</p>`, html `<p>Try “What needs my attention?” or send <code>status</code>. In a channel, mention the bot with <code>team</code> to follow a conversation.</p>`, html `<p><a href="/chat">Open saved chat</a></p>`, loadPrimary(process.env, dir) === "teams" ? html `<p>Task updates are sent here.</p>` : post("alerts", html `<button type="submit">Send task updates here</button>`), post("unpair", html `${password}<p class="meta">Unpairing ends every open button in your chat. Teammates are unaffected.</p><button type="submit">Unpair my account</button>`));
39
- }
40
- else {
41
- parts.push(post("pair", html `<h2>Pair your account</h2><p>Your paired Teams account can read your connected projects and confirm proposed changes. Password approvals still open in Toolroll.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} already paired.` : ""}</p>${password}<button type="submit">Create pairing code</button>`));
42
- }
43
- parts.push(html `<details><summary>Connection settings</summary>${post("disconnect", html `${password}<button type="submit">Disconnect Teams</button>`)}</details>`);
44
- }
45
- return html `<section style="max-width:42rem;overflow-wrap:anywhere"><p><a href="/settings">Settings</a></p><h1>Teams</h1>${options.problem ? html `<p class="problem" role="alert">${options.problem}</p>` : ""}${parts}</section>`;
8
+ const text = (id, name, label) => formRow(label, null, input({ id, name, attrs: { autocomplete: "off", spellcheck: "false", required: true, "aria-label": label } }));
9
+ const { code, problem, now, who } = options;
10
+ return chatSettingsHtml(store, dir, { ...(code === undefined ? {} : { code }), ...(problem === undefined ? {} : { problem }), ...(now === undefined ? {} : { now }), ...(who === undefined ? {} : { who }) }, {
11
+ provider: "teams",
12
+ label: "Teams",
13
+ credentials: loadTeamsCredentials(dir),
14
+ intro: "Manage projects and review results in a Teams chat, and let a channel follow a team conversation.",
15
+ steps: html `<ol class="k-note"><li>In <a class="k-link" href="https://portal.azure.com" target="_blank" rel="noopener noreferrer">Azure</a>, create an <em>Azure Bot</em> with a new single-tenant app registration; note the app (client) id and the directory (tenant) id, and create a client secret.</li>\
16
+ <li>Set its messaging endpoint to <code class="k-mono">${endpoint === null ? "https://your-public-address" + TEAMS_MESSAGES_PATH : endpoint}</code>. Microsoft must reach it from the internet: a tailnet-only address needs Tailscale Funnel (or another public tunnel) on that path.</li>\
17
+ <li>Enable the Microsoft Teams channel on the bot, then add the app to Teams (personal scope; team scope for channels).</li></ol>`,
18
+ fields: joinHtml([
19
+ text("teams-app-id", "app-id", "App (client) id"),
20
+ text("teams-tenant", "tenant", "Directory (tenant) id"),
21
+ chatSecretRow({ id: "teams-secret", name: "secret", label: "Client secret" }),
22
+ ]),
23
+ fieldsNote: "The secret stays on this installation. Next, pair your own Teams account.",
24
+ pairWhere: "to the Toolroll bot in a personal Teams chat",
25
+ tryLine: html `Try “What needs my attention?” or send <code class="k-mono">status</code>. In a channel, mention the bot with <code class="k-mono">team</code> to follow a conversation.`,
26
+ liveWords: ["Signed in", "Waiting for sign-in"],
27
+ ...(endpoint === null ? {} : { connection: html `<p class="k-note">Messaging endpoint, reachable from the internet: <code class="k-mono">${endpoint}</code></p>` }),
28
+ });
46
29
  }
@@ -1,7 +1,3 @@
1
- /** The person's own Telegram pairing, from the console: one private chat per
2
- * teammate with the installation's bot (v72). The bot token itself stays on
3
- * the settings page; this card only mints or revokes the signed-in person's
4
- * pairing, never anyone else's. */
5
1
  import type { Store } from "./store.js";
6
2
  import { type Html } from "./html.js";
7
3
  /** How long a written reply may wait to be sent before it shows here with a Retry. */
@@ -1,36 +1,63 @@
1
+ /** The person's own Telegram pairing, from the console: one private chat per
2
+ * teammate with the installation's bot (v72). The bot token itself stays on
3
+ * Chat apps and alerts; this page only mints or revokes the signed-in person's
4
+ * pairing, never anyone else's. Laid out as three steps: token, pair, check. */
5
+ import { settingsSubPage } from "./settings-groups-ui.js";
1
6
  import { html, joinHtml, postForm } from "./html.js";
7
+ import { acts, btn, codeLine, note, notice, settingsRecord, settingsSection } from "./server/kit.js";
8
+ import { kitSectionForm } from "./settings-kit.js";
2
9
  /** How long a written reply may wait to be sent before it shows here with a Retry. */
3
10
  export const UNSENT_REPLY_MS = 2 * 60_000;
4
11
  export function telegramSettingsHtml(store, botId, who, options = {}) {
5
- const password = html `<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>`;
6
- const post = (action, content) => postForm(`/settings/telegram/${action}`, content, { attrs: { class: "card" } });
7
- let content;
12
+ const problem = options.problem === undefined ? "" : notice(options.problem);
13
+ const group = note(html `Each teammate pairs their own private chat. To connect a group, a conversation manager sends <code class="k-mono">/team</code> there and chooses a conversation. Everyone in the group can read its replies.`);
8
14
  if (botId === null) {
9
- content = html `<p>No Telegram bot is connected yet. An installation approver saves the bot token on the <a href="/settings">settings page</a> first.</p>`;
15
+ return settingsSubPage("alerts", "Telegram", joinHtml([
16
+ problem,
17
+ settingsSection({ id: "telegram-bot", title: "1. Bot token", state: " · Not saved",
18
+ lead: "No Telegram bot is connected yet. An approver saves the bot token first.",
19
+ body: acts(btn("Open Chat apps and alerts", { sm: true, href: "/settings/alerts#telegram-token" })) }),
20
+ settingsSection({ id: "telegram-pair", title: "2. Pair your phone", body: note("Once the bot token is saved, pair your phone here.") }),
21
+ group,
22
+ ]));
23
+ }
24
+ const bindings = store.liveTelegramBindings(botId);
25
+ const mine = bindings.filter(binding => binding.approver === who);
26
+ const others = bindings.length - mine.length;
27
+ const paired = mine.length > 0;
28
+ const teammates = others === 0 ? "No teammates are paired yet." : `${others} teammate${others === 1 ? " is" : "s are"} paired.`;
29
+ const bot = settingsSection({ id: "telegram-bot", title: "1. Bot token", state: " · Saved",
30
+ body: note(html `Saved on <a class="k-link" href="/settings/alerts#telegram-token">Chat apps and alerts</a>.`) });
31
+ let pair;
32
+ let check;
33
+ if (options.code !== undefined) {
34
+ // The code shows once, on this page only: selectable text, never in a link or an address.
35
+ pair = settingsSection({ id: "telegram-pair", title: "2. Pair your phone", state: " · Code ready",
36
+ lead: "Send this to the bot in a private Telegram chat within 10 minutes:",
37
+ body: joinHtml([codeLine(`/pair ${options.code}`), note("The code works once. That chat then answers as you, so treat it like your sign-in.")]) });
38
+ check = settingsSection({ id: "telegram-check", title: "3. Check it works",
39
+ lead: "Once you've sent it, this page says your phone is paired.",
40
+ body: acts(btn("Check connection", { sm: true, href: "/settings/telegram" })) });
10
41
  }
11
42
  else {
12
- const bindings = store.liveTelegramBindings(botId);
13
- const mine = bindings.filter(binding => binding.approver === who);
14
- const others = bindings.length - mine.length;
15
- const parts = [html `<p><strong>${mine.length > 0 ? "Your phone is paired." : "Your phone is not paired."}</strong> ${others === 0 ? "No teammates are paired yet." : `${others} teammate${others === 1 ? " is" : "s are"} paired.`}</p>`];
43
+ pair = settingsSection({ id: "telegram-pair", title: "2. Pair your phone", state: paired ? " · Paired" : " · Not paired",
44
+ lead: `${paired ? "Your phone is paired." : "Your phone is not paired."} ${teammates}`,
45
+ body: paired
46
+ ? kitSectionForm({ proof: "telegram-unpair", action: "/settings/telegram/unpair", id: "telegram-unpair-form", save: { label: "Unpair my phone", variant: "danger" },
47
+ confirm: "Unpair your phone from Telegram", body: note("This phone will lose access. Its existing Telegram buttons will stop working.") })
48
+ : kitSectionForm({ proof: "telegram-pair", action: "/settings/telegram/pair", id: "telegram-pair-form", native: true, save: { label: "Pair my phone" },
49
+ confirm: "Pair your phone with Telegram", body: note("You get a one-time code to send to the bot.") }) });
16
50
  // A reply the assistant wrote that has not reached this person's phone for two minutes.
17
51
  const unsent = store.unsentTelegramReplies(botId, who, new Date((options.now ?? new Date()).getTime() - UNSENT_REPLY_MS));
18
- if (unsent.length > 0) {
19
- const oldest = unsent[0];
20
- parts.push(html `<div class="card" role="status" data-unsent-replies><p><strong>${unsent.length === 1 ? html `A reply hasn't reached your phone` : html `${unsent.length} replies haven't reached your phone`}</strong></p><p class="meta">Trying since ${oldest.since.slice(11, 16)} UTC${oldest.error === null ? "" : `: ${oldest.error}`}</p>${postForm("/settings/telegram/retry", html `<button type="submit">Retry</button>`)}</div>`);
21
- }
22
- if (options.code !== undefined) {
23
- parts.push(html `<h2>Pair your phone</h2><p>Send this to the bot in a private Telegram chat within 10 minutes:</p><pre class="pairing-code">/pair ${options.code}</pre><p class="meta">The code works once. That chat then answers as you, so treat it like your sign-in.</p>`);
24
- }
25
- else if (mine.length === 0) {
26
- parts.push(post("pair", html `${password}<button type="submit">Pair my phone</button>`));
27
- }
28
- else {
29
- parts.push(post("unpair", html `${password}<p class="meta">This phone will lose access. Its existing Telegram buttons will stop working.</p><button type="submit" class="danger">Unpair my phone</button>`));
30
- }
31
- content = joinHtml(parts);
52
+ const oldest = unsent[0];
53
+ check = settingsSection({ id: "telegram-check", title: "3. Check it works",
54
+ lead: paired ? html `In your private chat with the bot, send <code class="k-mono">/status</code>. It answers without an AI model.` : "Pair your phone first.",
55
+ body: oldest === undefined ? "" : settingsRecord({
56
+ title: unsent.length === 1 ? html `A reply hasn't reached your phone` : html `${unsent.length} replies haven't reached your phone`,
57
+ sub: `Trying since ${oldest.since.slice(11, 16)} UTC${oldest.error === null ? "" : `: ${oldest.error}`}`,
58
+ action: postForm("/settings/telegram/retry", btn("Retry", { sm: true, type: "submit" })),
59
+ attrs: { role: "status", "data-unsent-replies": true },
60
+ }) });
32
61
  }
33
- if (options.problem !== undefined)
34
- content = html `<p role="status">${options.problem}</p>${content}`;
35
- return html `<h1>Telegram</h1>${content}<p class="meta">Each teammate pairs their own private chat. To connect a group, a conversation manager sends <code>/team</code> there and chooses a conversation. Everyone in the group can read its replies.</p>`;
62
+ return settingsSubPage("alerts", "Telegram", joinHtml([problem, bot, pair, check, group]));
36
63
  }