toolroll 0.9.8 → 0.9.10

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 (85) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assignment-brief.d.ts +2 -0
  3. package/dist/assignment-brief.js +2 -1
  4. package/dist/assignment-presentation.js +3 -1
  5. package/dist/assignment-status.d.ts +1 -1
  6. package/dist/assignment-ui.js +5 -0
  7. package/dist/assignment.d.ts +9 -0
  8. package/dist/assignment.js +42 -8
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/browser/workspace.js +18 -18
  11. package/dist/browser-crew.js +4 -0
  12. package/dist/browser-workspace.d.ts +40 -0
  13. package/dist/builder.d.ts +2 -0
  14. package/dist/builder.js +114 -8
  15. package/dist/chat-controls.d.ts +1 -1
  16. package/dist/chat-controls.js +1 -1
  17. package/dist/chat-delivery-state.d.ts +5 -0
  18. package/dist/chat-delivery.js +45 -8
  19. package/dist/chat-quiet.d.ts +10 -4
  20. package/dist/chat-quiet.js +87 -22
  21. package/dist/chat-voice.d.ts +68 -0
  22. package/dist/chat-voice.js +175 -0
  23. package/dist/check-progress.js +4 -3
  24. package/dist/checkout-cleanup.d.ts +40 -2
  25. package/dist/checkout-cleanup.js +129 -9
  26. package/dist/desktop-update.d.ts +52 -0
  27. package/dist/desktop-update.js +147 -5
  28. package/dist/discord-chat.js +1 -0
  29. package/dist/evidence.d.ts +6 -0
  30. package/dist/evidence.js +8 -0
  31. package/dist/first-run.d.ts +1 -1
  32. package/dist/first-run.js +7 -5
  33. package/dist/lead-context.d.ts +1 -1
  34. package/dist/lead-context.js +4 -2
  35. package/dist/lead-status.d.ts +10 -1
  36. package/dist/lead-status.js +21 -24
  37. package/dist/needs-you.d.ts +55 -0
  38. package/dist/needs-you.js +83 -0
  39. package/dist/operate.d.ts +2 -0
  40. package/dist/operate.js +122 -15
  41. package/dist/plane-review.js +4 -0
  42. package/dist/process-recovery-eligibility.d.ts +1 -0
  43. package/dist/project-delete.js +1 -0
  44. package/dist/project-learning.d.ts +3 -3
  45. package/dist/project-learning.js +28 -17
  46. package/dist/proof.d.ts +3 -1
  47. package/dist/proof.js +4 -2
  48. package/dist/releases.d.ts +9 -0
  49. package/dist/releases.js +30 -0
  50. package/dist/result-completion.d.ts +4 -0
  51. package/dist/result-completion.js +11 -0
  52. package/dist/review-context.d.ts +3 -0
  53. package/dist/review-context.js +45 -5
  54. package/dist/run-closeout.d.ts +89 -0
  55. package/dist/run-closeout.js +302 -0
  56. package/dist/serve.d.ts +15 -1
  57. package/dist/serve.js +129 -14
  58. package/dist/setup-guide.d.ts +2 -2
  59. package/dist/shared-deps.d.ts +94 -0
  60. package/dist/shared-deps.js +365 -0
  61. package/dist/slack-chat.js +2 -0
  62. package/dist/storage.js +2 -0
  63. package/dist/store.d.ts +60 -0
  64. package/dist/store.js +207 -30
  65. package/dist/task-status.d.ts +18 -4
  66. package/dist/task-status.js +35 -18
  67. package/dist/teams-chat.js +1 -1
  68. package/dist/telegram-progress.d.ts +6 -1
  69. package/dist/telegram-progress.js +13 -5
  70. package/dist/telegram-status.d.ts +2 -1
  71. package/dist/telegram-status.js +6 -5
  72. package/dist/telegram.d.ts +1 -1
  73. package/dist/telegram.js +93 -43
  74. package/dist/test-temp.d.ts +19 -0
  75. package/dist/test-temp.js +98 -0
  76. package/dist/toolroll-update-ui.js +7 -2
  77. package/dist/toolroll-update.d.ts +21 -1
  78. package/dist/toolroll-update.js +135 -5
  79. package/dist/work-index.d.ts +11 -0
  80. package/dist/work-index.js +103 -16
  81. package/dist/work-summary.d.ts +3 -1
  82. package/dist/workspace-ui.js +1 -1
  83. package/dist/worktree.d.ts +16 -0
  84. package/dist/worktree.js +47 -0
  85. package/package.json +1 -1
package/dist/store.js CHANGED
@@ -139,7 +139,8 @@ CREATE TABLE IF NOT EXISTS monitoring_status (
139
139
  * every step, and an optional evening digest at a local HH:MM. `digest_on` is the local day the last evening
140
140
  * digest was considered, so each day is judged once. A task's card is the one chat message a destination keeps
141
141
  * for it (or for several tasks filed within a minute): `message` is the Telegram message id or the chat app's
142
- * part id, `digest` the text last shown, so an unchanged card is never re-sent. */
142
+ * part id, `digest` the text last shown, so an unchanged card is never re-sent. A `chat_batch` is one person's
143
+ * finished-work message: every result that lands within two minutes of its first joins it, edited in place. */
143
144
  const QUIET_CHAT_SCHEMA = `
144
145
  CREATE TABLE IF NOT EXISTS notification_preference (
145
146
  account TEXT PRIMARY KEY,
@@ -166,6 +167,21 @@ CREATE TABLE IF NOT EXISTS chat_card_task (
166
167
  PRIMARY KEY (destination, task_ref)
167
168
  );
168
169
  CREATE INDEX IF NOT EXISTS chat_card_task_card ON chat_card_task(card);
170
+ CREATE TABLE IF NOT EXISTS chat_batch (
171
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
172
+ destination TEXT NOT NULL,
173
+ opened_at TEXT NOT NULL,
174
+ message TEXT,
175
+ digest TEXT
176
+ );
177
+ CREATE INDEX IF NOT EXISTS chat_batch_destination ON chat_batch(destination, opened_at);
178
+ CREATE TABLE IF NOT EXISTS chat_batch_item (
179
+ batch INTEGER NOT NULL REFERENCES chat_batch(id),
180
+ task_ref INTEGER NOT NULL,
181
+ run INTEGER,
182
+ joined_at TEXT NOT NULL,
183
+ PRIMARY KEY (batch, task_ref)
184
+ );
169
185
  `;
170
186
  /** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
171
187
  * for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
@@ -215,6 +231,20 @@ CREATE TABLE IF NOT EXISTS project_mute (
215
231
  const SECURITY_KINDS = new Set(["secret-detected", "security-release"]);
216
232
  /** A failure that leaves nothing for the lead to try: the attempts are spent. */
217
233
  const SPENT_KINDS = new Set(["attempts-exhausted", "plan-attempts-exhausted"]);
234
+ /** Store.stopQuiescenceFact, read safely: a deploy proves completion with this code over the INSTALLED runtime's store,
235
+ * which may predate the method. There the older stopQuiescenceProblem decides, and its words give the kind. */
236
+ export function stopFactOf(store, runId) {
237
+ if (typeof store.stopQuiescenceFact === "function")
238
+ return store.stopQuiescenceFact(runId);
239
+ const problem = store.stopQuiescenceProblem(runId);
240
+ if (problem === null)
241
+ return null;
242
+ const kind = /still open|does not exist/.test(problem) ? "open"
243
+ : /another host/.test(problem) ? "elsewhere"
244
+ : /may still be running|still has|held by|has not finished|still has members/.test(problem) ? "alive"
245
+ : /cannot be proven|could not be established|incomplete native/.test(problem) ? "unknown" : "unprovable";
246
+ return { run: Number(/run #(\d+)/.exec(problem)?.[1] ?? runId), kind, problem };
247
+ }
218
248
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
219
249
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
220
250
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -3812,6 +3842,8 @@ function initializeStore(db, file) {
3812
3842
  db.exec(POLICY_SCHEMA);
3813
3843
  db.exec(RUN_CHECK_SCHEMA);
3814
3844
  db.exec(QUIET_CHAT_SCHEMA);
3845
+ // The update a batch line speaks for: its newest fact about that task (null: a finished result).
3846
+ addColumn(db, "chat_batch_item", "notification", "INTEGER");
3815
3847
  db.exec(LEAD_QUIET_SCHEMA);
3816
3848
  mergeCheckTables(db);
3817
3849
  addColumn(db, "monitoring_status", "target", "TEXT");
@@ -10007,6 +10039,10 @@ export class Store {
10007
10039
  stampWorktreeSetup(path, digest) {
10008
10040
  this.db.prepare("UPDATE worktree SET setup_digest = ? WHERE path = ?").run(digest, path);
10009
10041
  }
10042
+ /** The checkout no longer holds what its setup made (its dependencies were dropped): the next lease runs setup again. */
10043
+ forgetWorktreeSetup(path) {
10044
+ this.db.prepare("UPDATE worktree SET setup_digest = NULL WHERE path = ?").run(path);
10045
+ }
10010
10046
  // ---- verify command (Priority 2) ---------------------------------------
10011
10047
  /**
10012
10048
  * Approve one repo's verification command, revoking any predecessor in
@@ -12721,6 +12757,22 @@ export class Store {
12721
12757
  .get(taskRef, head);
12722
12758
  return Number(row?.["n"] ?? 0);
12723
12759
  }
12760
+ /** Why a finished result can't be built again on the same filing: it was
12761
+ * verified, attested or accepted, or its branch was published (revised
12762
+ * through its pull request). Null when `requeueTask` may run it again. */
12763
+ finalResultReason(runId) {
12764
+ const verdict = this.proofVerdictFor(runId);
12765
+ if (verdict !== null && (verdict.verdict === "verified" || verdict.verdict === "attested"))
12766
+ return "accepted-result";
12767
+ if (this.proofAcceptance(runId) !== null)
12768
+ return "accepted-result";
12769
+ // An intent that never pushed is not a publication; a pushed or
12770
+ // opened branch is, and is revised through its pull request.
12771
+ const publication = this.publicationForRun(runId);
12772
+ if (publication !== null && (publication.state === "pushed" || publication.state === "opened"))
12773
+ return "published";
12774
+ return null;
12775
+ }
12724
12776
  requeueTask(taskId, by, now) {
12725
12777
  return this.transact(() => {
12726
12778
  const ref = this.db
@@ -12759,16 +12811,9 @@ export class Store {
12759
12811
  }
12760
12812
  else {
12761
12813
  rejectedRun = Number(last["id"]);
12762
- const verdict = this.proofVerdictFor(rejectedRun);
12763
- if (verdict !== null && (verdict.verdict === "verified" || verdict.verdict === "attested"))
12764
- return { ok: false, reason: "accepted-result" };
12765
- if (this.proofAcceptance(rejectedRun) !== null)
12766
- return { ok: false, reason: "accepted-result" };
12767
- // An intent that never pushed is not a publication; a pushed or
12768
- // opened branch is, and is revised through its pull request.
12769
- const publication = this.publicationForRun(rejectedRun);
12770
- if (publication !== null && (publication.state === "pushed" || publication.state === "opened"))
12771
- return { ok: false, reason: "published" };
12814
+ const final = this.finalResultReason(rejectedRun);
12815
+ if (final !== null)
12816
+ return { ok: false, reason: final };
12772
12817
  }
12773
12818
  }
12774
12819
  else if (incidents.length === 0 && state !== "failed") {
@@ -15065,6 +15110,10 @@ export class Store {
15065
15110
  .run(now.toISOString(), head.length, head);
15066
15111
  return Number(changes);
15067
15112
  }
15113
+ notificationById(id) {
15114
+ const row = this.db.prepare("SELECT * FROM notification WHERE id = ?").get(id);
15115
+ return row === undefined ? null : readNotification(row);
15116
+ }
15068
15117
  notificationsAfter(id, limit = 100) {
15069
15118
  return this.db.prepare("SELECT * FROM notification WHERE id>? ORDER BY id LIMIT ?").all(id, Math.max(1, Math.min(100, limit))).map(readNotification);
15070
15119
  }
@@ -20981,21 +21030,32 @@ export class Store {
20981
21030
  /** Every owned run must have ended, and every retained local process
20982
21031
  * witness must be absent. Read-only probes never authorize PID kills. */
20983
21032
  stopQuiescenceProblem(runId) {
21033
+ return this.stopQuiescenceFact(runId)?.problem ?? null;
21034
+ }
21035
+ /** The same answer as stopQuiescenceProblem, with which run it names and
21036
+ * what kind of answer it is (needs-you.ts reads it): `alive` while
21037
+ * something of the run may still be running, `elsewhere` when only
21038
+ * another computer can tell, `unprovable` when nothing is known to run
21039
+ * but its exit was never recorded, `unknown` when this computer can't
21040
+ * check at all (both are what `run settle` resolves; only the first may
21041
+ * be called not running). */
21042
+ stopQuiescenceFact(runId) {
21043
+ const fact = (run, kind, problem) => ({ run, kind, problem });
20984
21044
  const ids = this.ownedRunsOf(runId);
20985
21045
  if (ids.length === 0)
20986
- return `run #${runId} does not exist`;
21046
+ return fact(runId, "open", `run #${runId} does not exist`);
20987
21047
  for (const id of ids) {
20988
21048
  const run = this.getRun(id);
20989
21049
  if (run.outcome === null)
20990
- return `run #${id} is still open`;
21050
+ return fact(id, "open", `run #${id} is still open`);
20991
21051
  if (ownedProcessCount(runOwnerTag(this, id)) > 0)
20992
- return `run #${id} still has an owned subprocess`;
21052
+ return fact(id, "alive", `run #${id} still has an owned subprocess`);
20993
21053
  const held = this.heldSessionOf(id);
20994
21054
  if (held !== null && held.endedAt === null)
20995
- return `run #${id}'s held supervisor has not finished shutdown`;
21055
+ return fact(id, "alive", `run #${id}'s held supervisor has not finished shutdown`);
20996
21056
  const witnesses = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(id);
20997
21057
  if (run.providerStartedAt !== null && witnesses.length === 0) {
20998
- return `run #${id} may have spawned before its process witness was recorded; exit is unproven`;
21058
+ return fact(id, "unprovable", `run #${id} may have spawned before its process witness was recorded; exit is unproven`);
20999
21059
  }
21000
21060
  for (const witness of witnesses) {
21001
21061
  if (witness["exited_at"] !== null)
@@ -21009,12 +21069,12 @@ export class Store {
21009
21069
  if (provenDeadByBootChange({ host, bootId }))
21010
21070
  continue;
21011
21071
  if (host !== hostname())
21012
- return `run #${id}'s process belongs to another host; exit is unproven here`;
21072
+ return fact(id, "elsewhere", `run #${id}'s process belongs to another host; exit is unproven here`);
21013
21073
  const backend = witness["containment"] === null || witness["containment"] === undefined ? null : String(witness["containment"]);
21014
21074
  const container = witness["container"] === null || witness["container"] === undefined ? null : String(witness["container"]);
21015
21075
  if (backend !== null || container !== null) {
21016
21076
  if (backend === null || container === null)
21017
- return `run #${id} has incomplete native containment custody`;
21077
+ return fact(id, "unknown", `run #${id} has incomplete native containment custody`);
21018
21078
  if (witness["container_empty_at"] !== null)
21019
21079
  continue;
21020
21080
  // A native witness settles ONLY on the OS's word: the object is
@@ -21022,30 +21082,37 @@ export class Store {
21022
21082
  // when empty). A transport exit never proved it.
21023
21083
  const state = containerEmptiness(backend, container, process.platform, witness["container_identity"] == null ? null : String(witness["container_identity"]));
21024
21084
  if (state === "populated")
21025
- return `run #${id}'s ${backend} object ${container} still has members`;
21085
+ return fact(id, "alive", `run #${id}'s ${backend} object ${container} still has members`);
21026
21086
  if (state === "unknown")
21027
- return `run #${id}'s ${backend} object ${container} cannot be proven empty from here`;
21087
+ return fact(id, "unknown", `run #${id}'s ${backend} object ${container} cannot be proven empty from here`);
21028
21088
  continue;
21029
21089
  }
21030
21090
  if (witness["pid"] === null)
21031
- return `run #${id} has an incomplete spawn witness; exit is unproven`;
21091
+ return fact(id, "unprovable", `run #${id} has an incomplete spawn witness; exit is unproven`);
21032
21092
  const pid = Number(witness["pid"]);
21033
21093
  if (processMayBeAlive(pid, witness["process_group"] === 1, { observedAt: witness["observed_at"], finishedAt: run.finishedAt }))
21034
- return `run #${id}'s process ${pid} may still be running`;
21094
+ return fact(id, "alive", `run #${id}'s process ${pid} may still be running`);
21035
21095
  }
21036
21096
  if (run.worktree !== null) {
21037
21097
  try {
21038
21098
  const occupied = worktreeProcessOccupancy(run.worktree);
21039
21099
  if (occupied.held)
21040
- return `run #${id}'s workspace is still held by process ${occupied.by}`;
21100
+ return fact(id, "alive", `run #${id}'s workspace is still held by process ${occupied.by}`);
21041
21101
  }
21042
21102
  catch {
21043
- return `run #${id}'s workspace occupancy could not be established`;
21103
+ // An unreadable workspace note is no proof either way; an approver's recorded word settles it.
21104
+ if (!this.workspaceConfirmedByApprover(id))
21105
+ return fact(id, "unknown", `run #${id}'s workspace occupancy could not be established`);
21044
21106
  }
21045
21107
  }
21046
21108
  }
21047
21109
  return null;
21048
21110
  }
21111
+ /** `run settle` recorded an approver's word that a run whose workspace
21112
+ * note couldn't be read has stopped. */
21113
+ workspaceConfirmedByApprover(runId) {
21114
+ return this.db.prepare("SELECT 1 FROM action_ledger WHERE run_id = ? AND action = 'workspace settled by approver' LIMIT 1").get(runId) !== undefined;
21115
+ }
21049
21116
  reserveRunProcess(runId, now, group = true) {
21050
21117
  return Number(this.db.prepare("INSERT INTO run_process (run,host,process_group,observed_at,boot_id) VALUES (?,?,?,?,?)")
21051
21118
  .run(runId, hostname(), group ? 1 : 0, now.toISOString(), currentBootId()).lastInsertRowid);
@@ -21059,6 +21126,10 @@ export class Store {
21059
21126
  const rows = only === undefined
21060
21127
  ? this.db.prepare("SELECT * FROM run_process WHERE run = ? AND exited_at IS NULL").all(runId)
21061
21128
  : this.db.prepare("SELECT * FROM run_process WHERE run = ? AND pid = ? AND process_group = ? AND exited_at IS NULL").all(runId, only.pid, only.group ? 1 : 0);
21129
+ // A finished run's witness gets the same proof the deploy's quiescence
21130
+ // check uses: ESRCH, or a process now holding the pid that was born after
21131
+ // the run ended. An open run has no finish time, so only ESRCH counts.
21132
+ const finishedAt = this.getRun(runId)?.finishedAt ?? null;
21062
21133
  let recorded = 0;
21063
21134
  for (const row of rows) {
21064
21135
  const host = String(row["host"]), bootId = row["boot_id"] == null ? null : String(row["boot_id"]);
@@ -21072,9 +21143,7 @@ export class Store {
21072
21143
  gone = backend !== null && container !== null && (row["container_empty_at"] != null || containerEmptiness(backend, container, process.platform, row["container_identity"] == null ? null : String(row["container_identity"])) === "empty");
21073
21144
  }
21074
21145
  else {
21075
- // Do not borrow the run's finish time or guess a legacy spawn time.
21076
- // With no historical witness argument only ESRCH proves absence.
21077
- gone = row["pid"] != null && !processMayBeAlive(Number(row["pid"]), row["process_group"] === 1);
21146
+ gone = row["pid"] != null && !processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, finishedAt === null ? undefined : { observedAt: row["observed_at"], finishedAt });
21078
21147
  }
21079
21148
  }
21080
21149
  if (gone)
@@ -21085,6 +21154,25 @@ export class Store {
21085
21154
  }
21086
21155
  return recorded;
21087
21156
  }
21157
+ /** The reconcile road for a finished run whose process outlived its
21158
+ * finish: once each process is provably gone, its exit is recorded, so
21159
+ * the task stops waiting on a person by itself. A run that can't be proven
21160
+ * yet stays open and is passed over, never holding newer runs behind it:
21161
+ * every finished run is visited, a batch at a time. Never signals. */
21162
+ recordFinishedRunExits(now, batch = 200) {
21163
+ const page = this.db.prepare(`SELECT DISTINCT p.run FROM run_process p JOIN run r ON r.id = p.run
21164
+ WHERE p.exited_at IS NULL AND p.host = ? AND p.run > ? AND r.outcome IS NOT NULL AND r.finished_at IS NOT NULL
21165
+ ORDER BY p.run LIMIT ?`);
21166
+ let recorded = 0;
21167
+ for (let after = 0;;) {
21168
+ const runs = page.all(hostname(), after, batch).map(row => Number(row["run"]));
21169
+ for (const run of runs)
21170
+ recorded += this.recordRunProcessExits(run, now);
21171
+ if (runs.length < batch)
21172
+ return recorded;
21173
+ after = runs[runs.length - 1];
21174
+ }
21175
+ }
21088
21176
  finishUnspawnedProcess(witness, now) {
21089
21177
  this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND pid IS NULL AND ((containment IS NULL AND container IS NULL) OR container_empty_at IS NOT NULL)")
21090
21178
  .run(now.toISOString(), witness);
@@ -21179,12 +21267,42 @@ export class Store {
21179
21267
  return alive(`run #${args.runId}'s ${String(row["containment"])} object ${String(row["container"])} still has members`);
21180
21268
  }
21181
21269
  }
21182
- if (open.length === 0)
21183
- return { ok: true, witnesses: [], repeated: true };
21270
+ // A workspace still held by a live process refuses; one whose note can't
21271
+ // be read is settled by the approver's word, in the ledger.
21272
+ let workspace = false;
21273
+ if (run.worktree !== null) {
21274
+ try {
21275
+ const occupied = worktreeProcessOccupancy(run.worktree);
21276
+ if (occupied.held)
21277
+ return alive(`run #${args.runId}'s workspace is still held by process ${occupied.by}`);
21278
+ }
21279
+ catch {
21280
+ workspace = !this.workspaceConfirmedByApprover(args.runId);
21281
+ }
21282
+ }
21283
+ const ref = this.refById(run.taskRef);
21284
+ if (workspace) {
21285
+ this.recordAction({ at: now.toISOString(), actor: args.by, repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: args.runId,
21286
+ action: "workspace settled by approver", outcome: run.worktree ?? "", source: "request", detail: args.why });
21287
+ }
21288
+ // A run that may have spawned before any witness was written has none
21289
+ // to settle: the approver's word is recorded as one ended witness.
21290
+ if (rows.length === 0 && run.providerStartedAt !== null) {
21291
+ const id = Number(this.db.prepare("INSERT INTO run_process (run,host,process_group,observed_at,boot_id,exited_at) VALUES (?,?,0,?,?,?)")
21292
+ .run(args.runId, hostname(), now.toISOString(), currentBootId(), now.toISOString()).lastInsertRowid);
21293
+ this.recordAction({ at: now.toISOString(), actor: args.by, repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: args.runId,
21294
+ action: "process witness settled by approver", outcome: `witness ${id}`, source: "request", detail: args.why });
21295
+ this.settleQuiescentStops(now);
21296
+ return { ok: true, witnesses: [id], repeated: false };
21297
+ }
21298
+ if (open.length === 0) {
21299
+ if (workspace)
21300
+ this.settleQuiescentStops(now);
21301
+ return { ok: true, witnesses: [], repeated: !workspace };
21302
+ }
21184
21303
  const ids = open.map(row => Number(row["id"]));
21185
21304
  for (const id of ids)
21186
21305
  this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND run = ? AND exited_at IS NULL").run(now.toISOString(), id, args.runId);
21187
- const ref = this.refById(run.taskRef);
21188
21306
  this.recordAction({ at: now.toISOString(), actor: args.by, repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: args.runId,
21189
21307
  action: "process witness settled by approver", outcome: `witness ${ids.join(", ")}`, source: "request", detail: args.why });
21190
21308
  this.settleQuiescentStops(now);
@@ -22101,6 +22219,7 @@ export class Store {
22101
22219
  * and the evening digest keep every fact either way.
22102
22220
  */
22103
22221
  pingAllowed(row, account) {
22222
+ // A security alert always pings, ahead of every rule below: own acts, settled, replaced or release-check tasks.
22104
22223
  if (SECURITY_KINDS.has(row.kind) || row.kind === "evening-digest")
22105
22224
  return true;
22106
22225
  if (row.recipient !== null && row.kind === "task-ask")
@@ -22110,10 +22229,42 @@ export class Store {
22110
22229
  return false;
22111
22230
  if (row.recipient !== null)
22112
22231
  return true;
22232
+ // A task this person (or their lead) completed or cancelled is settled for them: nothing more about it.
22233
+ // Release checks and replaced or superseded tasks never ping anyone; a deploy says "is live" instead.
22234
+ if (row.taskRef !== null && (this.settledBy(row.taskRef) === account || this.neverPings(row.taskRef)))
22235
+ return false;
22113
22236
  if (this.leadQuiet(row, actor))
22114
22237
  return false;
22115
22238
  return row.project === null || this.db.prepare("SELECT 1 AS hit FROM project_mute WHERE account = ? AND repo = ?").get(account, row.project) === undefined;
22116
22239
  }
22240
+ /** Who last completed or cancelled this task, when that is still its latest act: null otherwise. A revision's
22241
+ * completion is recorded on its root, so an act on a task it revises counts too, but only one made after this
22242
+ * revision was filed: settling older work never silences a newer revision, whose failures still reach people. */
22243
+ settledBy(taskRef) {
22244
+ const ancestors = [];
22245
+ for (let at = 0, from = taskRef; at < 20; at++) {
22246
+ const parent = this.db.prepare(`SELECT p.id FROM task_ref c JOIN task_ref p ON p.backend = c.backend AND p.external_id = c.revision_of AND p.repo IS c.repo
22247
+ WHERE c.id = ?`).get(from);
22248
+ if (parent === undefined || from === Number(parent["id"]) || ancestors.includes(Number(parent["id"])))
22249
+ break;
22250
+ from = Number(parent["id"]);
22251
+ ancestors.push(from);
22252
+ }
22253
+ const filed = this.db.prepare("SELECT t.created_at FROM task_ref r JOIN task t ON t.id = r.external_id AND r.backend = ? WHERE r.id = ?").get(BUILT_IN, taskRef);
22254
+ const since = filed === undefined || ancestors.length === 0 ? null : String(filed["created_at"]);
22255
+ const last = since === null
22256
+ ? this.db.prepare("SELECT act, account FROM task_act WHERE task_ref = ? ORDER BY id DESC LIMIT 1").get(taskRef)
22257
+ : this.db.prepare(`SELECT act, account FROM task_act WHERE task_ref = ? OR (task_ref IN (${ancestors.map(() => "?").join(", ")}) AND at >= ?)
22258
+ ORDER BY id DESC LIMIT 1`).get(taskRef, ...ancestors, since);
22259
+ return last !== undefined && (last["act"] === "completed" || last["act"] === "cancelled") ? String(last["account"]) : null;
22260
+ }
22261
+ /** A release check, a replaced task, or one a revision superseded: its facts stay in the console. */
22262
+ neverPings(taskRef) {
22263
+ return this.db.prepare(`SELECT 1 AS hit FROM task_ref r WHERE r.id = ? AND (
22264
+ EXISTS (SELECT 1 FROM run JOIN run_check rc ON rc.run = run.id WHERE run.task_ref = r.id AND rc.release = 1)
22265
+ OR EXISTS (SELECT 1 FROM task_replacement x WHERE x.task_ref = r.id)
22266
+ OR EXISTS (SELECT 1 FROM task_ref c WHERE c.backend = r.backend AND c.revision_of = r.external_id AND c.repo IS r.repo))`).get(taskRef) !== undefined;
22267
+ }
22117
22268
  /** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
22118
22269
  leadQuiet(row, actor = this.notificationActor(row.id)) {
22119
22270
  if (SECURITY_KINDS.has(row.kind))
@@ -22166,6 +22317,32 @@ export class Store {
22166
22317
  return this.db.prepare("SELECT * FROM notification WHERE created_at >= ? AND task_ref IS NOT NULL AND project IS NOT NULL ORDER BY id DESC LIMIT ?")
22167
22318
  .all(since, limit).map(readNotification).reverse();
22168
22319
  }
22320
+ /** The one message this update joins: the destination's batch opened within `windowMs`, or a new one. A task
22321
+ * has one line in it, saying its newest update (`notification`; null for a finished result). */
22322
+ chatBatchFor(destination, taskRef, run, now, windowMs, notification = null) {
22323
+ return this.transact(() => {
22324
+ const open = this.db.prepare("SELECT id FROM chat_batch WHERE destination = ? AND opened_at > ? AND opened_at <= ? ORDER BY id DESC LIMIT 1")
22325
+ .get(destination, new Date(now.getTime() - windowMs).toISOString(), now.toISOString());
22326
+ const batch = open !== undefined ? Number(open["id"])
22327
+ : Number(this.db.prepare("INSERT INTO chat_batch (destination, opened_at) VALUES (?, ?)").run(destination, now.toISOString()).lastInsertRowid);
22328
+ this.db.prepare(`INSERT INTO chat_batch_item (batch, task_ref, run, joined_at, notification) VALUES (?, ?, ?, ?, ?)
22329
+ ON CONFLICT (batch, task_ref) DO UPDATE SET run = excluded.run, notification = excluded.notification`).run(batch, taskRef, run, now.toISOString(), notification);
22330
+ return this.chatBatch(batch);
22331
+ });
22332
+ }
22333
+ chatBatch(id) {
22334
+ const row = this.db.prepare("SELECT * FROM chat_batch WHERE id = ?").get(id);
22335
+ if (row === undefined)
22336
+ return null;
22337
+ const items = this.db.prepare("SELECT task_ref, run, notification FROM chat_batch_item WHERE batch = ? ORDER BY joined_at, task_ref").all(id)
22338
+ .map(one => ({ taskRef: Number(one["task_ref"]), run: one["run"] == null ? null : Number(one["run"]),
22339
+ notification: one["notification"] == null ? null : Number(one["notification"]) }));
22340
+ return { id, destination: String(row["destination"]), openedAt: String(row["opened_at"]), message: row["message"] == null ? null : String(row["message"]),
22341
+ digest: row["digest"] == null ? null : String(row["digest"]), items };
22342
+ }
22343
+ setChatBatchMessage(id, message, digest) {
22344
+ this.db.prepare("UPDATE chat_batch SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
22345
+ }
22169
22346
  /** The message now showing this card, and what it shows. */
22170
22347
  setChatCardMessage(id, message, digest) {
22171
22348
  this.db.prepare("UPDATE chat_card SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
@@ -11,9 +11,12 @@
11
11
  * output) is an amber note on its own row with one action; the exact
12
12
  * technical reason stays one tap away in `why`. */
13
13
  import type { AssignmentSnapshot } from "./assignment.js";
14
+ import { type NeedAction, type NeedContext, type NeedKey, type WaitKey } from "./needs-you.js";
15
+ export type { NeedKey, WaitKey } from "./needs-you.js";
14
16
  /** A cancelled task with a successor reads "Replaced by <id>" everywhere, never "Cancelled". */
15
17
  export declare const replacedWords: (successor: string) => string;
16
- export declare const HEADLINES: readonly ["Queued", "Planning", "Needs you", "Building", "Ready for review", "Complete", "Failed", "Stopped"];
18
+ /** Waiting: a reason no person can act on, worded as what it waits for (needs-you.ts). */
19
+ export declare const HEADLINES: readonly ["Queued", "Planning", "Needs you", "Waiting", "Building", "Ready for review", "Complete", "Failed", "Stopped"];
17
20
  export type Headline = (typeof HEADLINES)[number];
18
21
  export type HeadlineTone = "neutral" | "live" | "attention" | "ready" | "success" | "danger";
19
22
  export declare const HEADLINE_TONE: Readonly<Record<Headline, HeadlineTone>>;
@@ -34,6 +37,7 @@ export type StatusDetail = {
34
37
  action: StatusAction | null;
35
38
  why: string | null;
36
39
  };
40
+ /** `need`: what a Needs you asks for and the one action that resolves it; null under every other headline. */
37
41
  export type TaskStatus = {
38
42
  headline: Headline;
39
43
  tone: HeadlineTone;
@@ -41,9 +45,13 @@ export type TaskStatus = {
41
45
  details: StatusDetail[];
42
46
  primaryAction: StatusAction | null;
43
47
  why: string[];
48
+ need?: {
49
+ key: NeedKey;
50
+ action: NeedAction;
51
+ } | null;
44
52
  };
45
53
  /** `reviewing`: the one automatic review (build-review.ts) is reading a finished build; it reads Building, not a headline of its own. */
46
- export type TaskStage = "queued" | "planning" | "needs-you" | "building" | "checking" | "reviewing" | "finished" | "complete" | "failed" | "stopped";
54
+ export type TaskStage = "queued" | "planning" | "needs-you" | "waiting" | "building" | "checking" | "reviewing" | "finished" | "complete" | "failed" | "stopped";
47
55
  export type ChecksFact = {
48
56
  status: "passed" | "failed" | "running" | "not-run" | "unavailable";
49
57
  exitCode: number | null;
@@ -66,8 +74,12 @@ export type PullRequestFact = {
66
74
  };
67
75
  export type TaskStatusFacts = {
68
76
  stage: TaskStage;
69
- /** Needs you: what a person is asked for. The sentence names it. */
70
- need?: "approval" | "answer" | "sign-in" | "other";
77
+ /** Needs you: what a person is asked for (needs-you.ts). The sentence names it. */
78
+ need?: NeedKey;
79
+ /** Waiting: what the work waits for when no person can act. */
80
+ wait?: WaitKey;
81
+ /** The build or agent a need's sentence names. */
82
+ needContext?: NeedContext;
71
83
  /** The recorded reason in plain words, when the stage has one. */
72
84
  reason?: string | null;
73
85
  /** A research report rather than a code change. */
@@ -124,12 +136,14 @@ export declare function pullRequestFactOf(publication: {
124
136
  * the exact reason (a running planner, a pause, a failed attempt, a sign-in). */
125
137
  export declare function assignmentStageOf(assignment: Pick<AssignmentSnapshot, "state" | "primaryAction"> & {
126
138
  review?: AssignmentSnapshot["review"];
139
+ need?: AssignmentSnapshot["need"];
127
140
  }, work?: {
128
141
  token: string;
129
142
  views?: readonly string[];
130
143
  } | null, planning?: boolean): {
131
144
  stage: TaskStage;
132
145
  need?: TaskStatusFacts["need"];
146
+ wait?: WaitKey;
133
147
  };
134
148
  /** Requirements from the stored matrix: met, the ones only a person confirms, and the total. */
135
149
  export declare function requirementsOf(matrix: readonly {
@@ -1,9 +1,11 @@
1
+ import { CODE_NEED, NEEDS, needSentence, waitSentence } from "./needs-you.js";
1
2
  /** A cancelled task with a successor reads "Replaced by <id>" everywhere, never "Cancelled". */
2
3
  export const replacedWords = (successor) => `Replaced by ${successor}`;
3
4
  const REPLACED = /^Replaced by \S+/;
4
- export const HEADLINES = ["Queued", "Planning", "Needs you", "Building", "Ready for review", "Complete", "Failed", "Stopped"];
5
+ /** Waiting: a reason no person can act on, worded as what it waits for (needs-you.ts). */
6
+ export const HEADLINES = ["Queued", "Planning", "Needs you", "Waiting", "Building", "Ready for review", "Complete", "Failed", "Stopped"];
5
7
  export const HEADLINE_TONE = {
6
- Queued: "neutral", Planning: "live", "Needs you": "attention", Building: "live",
8
+ Queued: "neutral", Planning: "live", "Needs you": "attention", Waiting: "neutral", Building: "live",
7
9
  "Ready for review": "ready", Complete: "success", Failed: "danger", Stopped: "neutral",
8
10
  };
9
11
  const short = (sha) => sha !== null && /^[a-f0-9]{7,40}$/.test(sha) ? sha.slice(0, 7) : null;
@@ -12,6 +14,7 @@ export function headlineOf(facts) {
12
14
  case "queued": return "Queued";
13
15
  case "planning": return "Planning";
14
16
  case "needs-you": return "Needs you";
17
+ case "waiting": return "Waiting";
15
18
  case "building":
16
19
  case "checking":
17
20
  case "reviewing": return "Building";
@@ -33,10 +36,9 @@ function sentenceOf(headline, facts) {
33
36
  switch (headline) {
34
37
  case "Queued": return reason ?? "Waiting for a worker.";
35
38
  case "Planning": return reason ?? "The lead is writing the plan.";
36
- // An approval always reads the same way; the plan itself names the work.
37
- case "Needs you": return facts.need === "approval" ? "Review the plan and approve it to start." : reason ?? (facts.need === "answer" ? "Answer the question so the work can continue."
38
- : facts.need === "sign-in" ? "Sign in again; the task starts on its own after."
39
- : "Something needs your decision before the work can continue.");
39
+ // One plain sentence per need (needs-you.ts); a recorded reason only where the need keeps one.
40
+ case "Needs you": return needSentence(facts.need ?? "other", reason, facts.needContext);
41
+ case "Waiting": return waitSentence(facts.wait, reason, facts.needContext);
40
42
  case "Building": return facts.stage === "checking" ? "Checks are running on the change."
41
43
  : facts.stage === "reviewing" ? "Reviewing the change before it reaches you." : reason ?? "An agent is working on it.";
42
44
  case "Ready for review":
@@ -133,8 +135,12 @@ function detailsOf(headline, facts) {
133
135
  }
134
136
  export function taskStatusOf(facts) {
135
137
  const headline = headlineOf(facts);
138
+ // Every Needs you carries the one action that resolves it, worded the same everywhere.
139
+ const need = headline === "Needs you" ? { key: facts.need ?? "other", action: NEEDS[facts.need ?? "other"].action } : null;
140
+ const ownWords = need !== null && (need.key === "other" || need.key === "review-result") && facts.action != null;
141
+ const primaryAction = need === null || ownWords ? facts.action ?? null : { label: need.action.label, href: facts.action?.href ?? null };
136
142
  return { headline, tone: HEADLINE_TONE[headline], sentence: sentenceOf(headline, facts), details: detailsOf(headline, facts),
137
- primaryAction: facts.action ?? null, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== "") };
143
+ primaryAction, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== ""), need };
138
144
  }
139
145
  // ---- reading the existing projections ----------------------------------------
140
146
  /** Every dispatch or work-index code, as the stage it means. Unknown codes that
@@ -146,6 +152,7 @@ const CODE_STAGE = {
146
152
  "signed-out": ["needs-you", "sign-in"],
147
153
  "needs-approval": ["needs-you", "approval"],
148
154
  "waiting-decision": ["needs-you", "answer"], "decision-queue": ["needs-you", "answer"],
155
+ "build-stopping": ["waiting"], "other-computer": ["waiting"],
149
156
  stopping: ["stopped"], stopped: ["stopped"], paused: ["stopped"], cancelled: ["stopped"],
150
157
  failed: ["failed"], "waiting-incident": ["failed"], "checks-failed": ["failed"],
151
158
  "ready-to-check": ["finished"], "ready-to-review": ["finished"], "report-ready": ["finished"], "no-change": ["finished"],
@@ -166,11 +173,11 @@ export function stageOfCode(code, options = {}) {
166
173
  if (code === "running" && options.planning)
167
174
  return { stage: "planning" };
168
175
  if (code === "held")
169
- return options.operatorHold === false ? { stage: "needs-you", need: "other" } : { stage: "stopped" };
176
+ return options.operatorHold === false ? { stage: "needs-you", need: "hold" } : { stage: "stopped" };
170
177
  const known = CODE_STAGE[code];
171
178
  if (known !== undefined)
172
179
  return known[1] === undefined ? { stage: known[0] } : { stage: known[0], need: known[1] };
173
- return options.needsPerson === false ? { stage: "queued" } : { stage: "needs-you", need: "other" };
180
+ return options.needsPerson === false ? { stage: "queued" } : { stage: "needs-you", need: CODE_NEED[code] ?? "other" };
174
181
  }
175
182
  /** The pull request as the task page's follower reports it, or the bare publication row. */
176
183
  export function pullRequestFactOf(publication) {
@@ -197,6 +204,10 @@ export function pullRequestFactOf(publication) {
197
204
  /** The assignment's stage. The work status, when the caller read one, names
198
205
  * the exact reason (a running planner, a pause, a failed attempt, a sign-in). */
199
206
  export function assignmentStageOf(assignment, work, planning = false) {
207
+ // The assignment's own reading (a build Toolroll can't confirm stopped) comes first.
208
+ const own = assignment.need ?? null;
209
+ if (own !== null && (assignment.state === "working" || assignment.state === "needs-decision"))
210
+ return "wait" in own ? { stage: "waiting", wait: own.wait } : { stage: "needs-you", need: own.key };
200
211
  switch (assignment.state) {
201
212
  case "cancelled": return { stage: "stopped" };
202
213
  case "complete": return { stage: "complete" };
@@ -211,8 +222,10 @@ export function assignmentStageOf(assignment, work, planning = false) {
211
222
  const action = assignment.primaryAction?.code;
212
223
  if (action === "approve-scope")
213
224
  return { stage: "needs-you", need: "approval" };
214
- if (action === "answer-decision" || action === "inspect-decisions")
225
+ if (action === "answer-decision")
215
226
  return { stage: "needs-you", need: "answer" };
227
+ if (action === "inspect-decisions")
228
+ return { stage: "needs-you", need: "questions" };
216
229
  const token = work?.token ?? "";
217
230
  // A failed attempt is Failed. A saved result that still needs a decision
218
231
  // (its scope, a hold, a process exit) is Needs you, even if its checks
@@ -220,7 +233,10 @@ export function assignmentStageOf(assignment, work, planning = false) {
220
233
  if (token === "failed" || token === "waiting-incident")
221
234
  return { stage: "failed" };
222
235
  const read = stageOfCode(token, { needsPerson: true, operatorHold: action === "unhold" });
223
- return read.stage === "stopped" ? read : { stage: "needs-you", need: read.need ?? "other" };
236
+ if (read.stage === "stopped" || read.stage === "waiting")
237
+ return read;
238
+ // A saved result that isn't ready keeps its own reading; a task's dispatch reason names its need.
239
+ return { stage: "needs-you", need: read.need ?? "other" };
224
240
  }
225
241
  }
226
242
  }
@@ -243,11 +259,12 @@ export function evidenceOf(receipt, problems = receipt?.caveats ?? []) {
243
259
  }
244
260
  /** The status facts of one assignment, as every surface reads them. */
245
261
  export function assignmentStatusFacts(assignment, options = {}) {
246
- const { stage, need } = assignmentStageOf(assignment, options.work ?? null, options.planning ?? false);
262
+ const { stage, need, wait } = assignmentStageOf(assignment, options.work ?? null, options.planning ?? false);
263
+ const build = assignment.need?.build ?? null;
247
264
  const receipt = assignment.receipt;
248
265
  const finished = stage === "finished" || stage === "complete";
249
266
  // A saved result's rows show whenever it is the reason a person is needed, too.
250
- const withResult = finished || stage === "failed" || (stage === "needs-you" && receipt !== null);
267
+ const withResult = finished || stage === "failed" || ((stage === "needs-you" || stage === "waiting") && receipt !== null);
251
268
  const report = receipt?.completionKind === "research-report";
252
269
  const checks = receipt === null || !withResult ? null
253
270
  : { status: receipt.checks.status, exitCode: receipt.checks.exitCode, head: receipt.head,
@@ -257,11 +274,11 @@ export function assignmentStatusFacts(assignment, options = {}) {
257
274
  : publication === null ? (withResult && !report ? { state: "none", number: null, url: null, ci: null, error: null } : null)
258
275
  : pullRequestFactOf(publication);
259
276
  // Ready, Complete and Failed speak for themselves; every other stage keeps its recorded reason.
260
- const reason = stage === "needs-you" || stage === "stopped" || stage === "queued" || (stage === "failed" && checks?.status !== "failed")
277
+ const reason = stage === "needs-you" || stage === "waiting" || stage === "stopped" || stage === "queued" || (stage === "failed" && checks?.status !== "failed")
261
278
  ? stage === "stopped" && assignment.state === "cancelled" ? REPLACED.test(assignment.detail) ? assignment.detail : "Cancelled. Nothing else will run." : plainReasonOf(stage, options.work?.token ?? "", options.work?.detail ?? assignment.detail)
262
279
  : null;
263
280
  return {
264
- stage, ...(need === undefined ? {} : { need }), reason, report,
281
+ stage, ...(need === undefined ? {} : { need }), ...(wait === undefined ? {} : { wait }), ...(build === null ? {} : { needContext: { build } }), reason, report,
265
282
  checks,
266
283
  pullRequest: withResult ? pullRequest : null,
267
284
  requirements: withResult ? requirementsOf(receipt?.proof?.matrix) : null,
@@ -292,7 +309,7 @@ export function statusDetailLines(status) {
292
309
  }
293
310
  /** The headline's emoji for chat cards (colour is never the only signal: the words follow). */
294
311
  export function headlineEmoji(headline) {
295
- return { Queued: "🕓", Planning: "📝", "Needs you": "👋", Building: "⏳", "Ready for review": "✅", Complete: "✅", Failed: "❌", Stopped: "⏹" }[headline];
312
+ return { Queued: "🕓", Planning: "📝", "Needs you": "👋", Waiting: "⏸", Building: "⏳", "Ready for review": "✅", Complete: "✅", Failed: "❌", Stopped: "⏹" }[headline];
296
313
  }
297
314
  const escapeHtml = (value) => value.replace(/[&<>"']/g, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
298
315
  const ICON_PATHS = {
@@ -332,8 +349,8 @@ export function stageOfDispatch(d, options = {}) {
332
349
  if (d.code === "complete" || d.code.startsWith("review-"))
333
350
  return { stage: options.completed ? "complete" : "finished" };
334
351
  if (d.code === "proof-refuted")
335
- return /approved check failed|checks failed/i.test(d.detail ?? "") ? { stage: "failed" } : { stage: "needs-you", need: "other" };
352
+ return /approved check failed|checks failed/i.test(d.detail ?? "") ? { stage: "failed" } : { stage: "needs-you", need: "review-result" };
336
353
  if (d.code === "needs-verification")
337
- return { stage: "needs-you", need: "other" };
354
+ return { stage: "needs-you", need: "review-result" };
338
355
  return stageOfCode(d.code, { needsPerson: d.condition === "waiting" && d.action !== null, planning: d.condition === "running" && d.role === "planner", operatorHold: d.action === "unhold" });
339
356
  }
@@ -195,7 +195,7 @@ export async function deliverTeamsPart(options) {
195
195
  const flow = content.flow ? chatFlowButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label, data: { so: one.token }, ...(one.action === "approve" ? { style: "positive" } : {}) })) : [];
196
196
  // A teammate's question (v93): its options, then "Answer in words".
197
197
  const asked = content.question ? chatQuestionButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
198
- actions = [...flow, ...asked, ...openUrlAction(options.origin(), content.link)];
198
+ actions = [...flow, ...asked, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
199
199
  }
200
200
  const target = content.edit ?? row.message;
201
201
  const body = actions.length || content.proposal ? teamsCard(text, actions) : { type: "message", text, textFormat: "plain" };