toolroll 0.9.99 → 0.9.100

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 (70) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/acp-check.js +4 -2
  3. package/dist/agent-state.js +2 -1
  4. package/dist/agents-board.js +3 -2
  5. package/dist/agents-live.js +3 -2
  6. package/dist/approval-authority.d.ts +2 -1
  7. package/dist/approval-authority.js +2 -1
  8. package/dist/chat-ask.js +4 -2
  9. package/dist/chat-core.js +4 -2
  10. package/dist/chat-decide.js +1 -2
  11. package/dist/chat-delivery-state.d.ts +29 -6
  12. package/dist/chat-delivery-state.js +43 -13
  13. package/dist/chat-delivery.js +12 -5
  14. package/dist/chat-flow.js +15 -13
  15. package/dist/chat-live.js +5 -2
  16. package/dist/chat-message-states.d.ts +75 -0
  17. package/dist/chat-message-states.js +74 -0
  18. package/dist/chat-messages.d.ts +10 -4
  19. package/dist/chat-messages.js +12 -7
  20. package/dist/check-failure.js +2 -1
  21. package/dist/check-report.js +1 -1
  22. package/dist/claim.d.ts +40 -2
  23. package/dist/claim.js +92 -19
  24. package/dist/contracts/chat-tables.js +3 -0
  25. package/dist/coordinator.js +3 -3
  26. package/dist/demo.js +7 -2
  27. package/dist/desktop-update.d.ts +1 -1
  28. package/dist/discord-chat.js +2 -3
  29. package/dist/dispatch.js +2 -1
  30. package/dist/dispose.d.ts +4 -1
  31. package/dist/dispose.js +2 -1
  32. package/dist/fallback.js +3 -2
  33. package/dist/fast-start.js +2 -2
  34. package/dist/monitoring.js +2 -1
  35. package/dist/operate.js +75 -36
  36. package/dist/plane-review.js +2 -1
  37. package/dist/project-knowledge.d.ts +3 -3
  38. package/dist/release-authority.d.ts +9 -0
  39. package/dist/release-authority.js +13 -0
  40. package/dist/release-auto.js +2 -3
  41. package/dist/release-gate.js +1 -1
  42. package/dist/release-view.js +2 -1
  43. package/dist/release.js +18 -30
  44. package/dist/result-shots.js +1 -1
  45. package/dist/route-results.js +2 -1
  46. package/dist/run-ending.d.ts +21 -0
  47. package/dist/run-ending.js +19 -0
  48. package/dist/scope.js +2 -2
  49. package/dist/server/render-results.js +3 -2
  50. package/dist/server/render-tasks.js +3 -2
  51. package/dist/setup-guide.d.ts +2 -2
  52. package/dist/slack-chat.js +2 -3
  53. package/dist/store.d.ts +61 -2
  54. package/dist/store.js +127 -77
  55. package/dist/subagent-question.js +8 -6
  56. package/dist/surface.js +2 -2
  57. package/dist/task-moves.d.ts +90 -0
  58. package/dist/task-moves.js +68 -0
  59. package/dist/teams-chat.js +2 -2
  60. package/dist/telegram-flow.d.ts +1 -1
  61. package/dist/telegram-flow.js +1 -1
  62. package/dist/telegram-lead.d.ts +1 -2
  63. package/dist/telegram-lead.js +18 -23
  64. package/dist/telegram-progress.js +2 -1
  65. package/dist/telegram.d.ts +0 -1
  66. package/dist/telegram.js +6 -7
  67. package/dist/vanished-retry.js +3 -2
  68. package/dist/workspace-ui.js +2 -1
  69. package/dist/worktree.js +3 -2
  70. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.100 — 2026-11-10
4
+
5
+ - **An update whose new version never starts completely is now rolled back without asking you to unload the service by hand.** When Toolroll can prove the service still loaded is the one this update installed, running its new version on this database, it stops that service itself and puts the previous version back. If anything doesn't match, nothing is stopped and the message says why.
6
+ - **An agent cut short shows as Stopped, not Failed.** When a restart or a stop ends an agent's run before it finishes, the run now shows as Stopped, so Failed means the work itself failed.
7
+ - **A chat message that was dropped stays dropped.** When a chat is disconnected while Toolroll is still working on a message from it, the late answer is no longer saved or sent, and a dropped part of a reply is never brought back by a later update.
8
+ - **Teams keeps trying to send, like Slack and Discord.** A reply Teams fails to take is no longer given up after 20 tries. It waits longer between tries (up to 5 minutes) and goes out when Teams takes it. The three apps now share one retry rule.
9
+ - **People lists only invites that can still be used.** An invite that was already used no longer shows as open.
10
+ - **A claim on a finished task is refused, and says why.** `toolroll claim` on a task that is done, failed or cancelled now answers `closed` with the task's state and a next step that works for that task (`task requeue` when a rerun would be accepted, `task state <id> queued` for a cancelled task, otherwise `task show`). Before, it took the task and marked it running again. A worker whose lease ran out can't renew it onto a task that was closed meanwhile either (`heartbeat` answers `closed`).
11
+ - **`toolroll worker release` hands a task back in one step.** Releasing the lease and putting the task back in the queue now happen together, so another worker can no longer claim the task in between and then see it marked as waiting. Releasing the same lease twice changes nothing the second time.
12
+ - **No false "stalled, retry it" notice for a task that is already closed.** When a build or scouting attempt uses up its retries on a task a person already marked done, failed or cancelled, the task keeps that state, and no incident, hold or notification is added. The failed attempt is still recorded.
13
+
3
14
  ## 0.9.99 — 2026-11-09
4
15
 
5
16
  - **A Toolroll update interrupted while the new service was starting no longer gets stuck.** Before, an update killed in that short window left new work paused until someone edited a file by hand. Running the release again now finishes the update when the new service is running, and puts the previous service back once the new one is proved gone. Nothing is stopped on a guess: a new service that is loaded but never answers is left alone, and the message names the one command that stops it.
package/dist/acp-check.js CHANGED
@@ -21,7 +21,7 @@ import { dirname, join } from "node:path";
21
21
  import { ACP_AGENTS, isAcpProvider } from "./acp-agents.js";
22
22
  import { ACP_CONFORMANCE_VERSION, launchOf } from "./acp-attest.js";
23
23
  import { inspectAcpAgent } from "./acp-inspect.js";
24
- import { acquire, acquireForCheck, heartbeat, release } from "./claim.js";
24
+ import { acquireForCheck, acquireForCheckCleanup, heartbeat, release } from "./claim.js";
25
25
  import { invokeAgent } from "./invoke.js";
26
26
  import { BUILT_IN } from "./store.js";
27
27
  export const CHECK_PROMPTS = {
@@ -74,7 +74,9 @@ async function settleChecks(job, clock) {
74
74
  if (lease !== null)
75
75
  release(store, lease, clock());
76
76
  const fresh = `agent-check-${request.id}-settle-${randomBytes(4).toString("hex")}`;
77
- const took = acquire(store, ref, job.runner, { now: clock(), token: job.token, newLeaseId: () => fresh, ttlMs: SETTLE_HOLD_MS, incarnation: job.watch.owner });
77
+ // Cleanup's own door: it may claim the check's task even when that task is done, failed or cancelled, proved
78
+ // against this check row, this runner and this attempt (claim.ts acquireForCheckCleanup).
79
+ const took = acquireForCheckCleanup(store, request.id, attempt, job.runner, { now: clock(), token: job.token, newLeaseId: () => fresh, ttlMs: SETTLE_HOLD_MS, incarnation: job.watch.owner });
78
80
  if (!took.ok || !store.setAcpCheckLease(request.id, fresh)) {
79
81
  if (took.ok)
80
82
  release(store, fresh, clock());
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { agentFactsFor, agentGroupOf } from "./agents-board.js";
8
8
  import { stuckSince } from "./agent-board.js";
9
+ import { wasInterrupted } from "./run-ending.js";
9
10
  import { flowDefinitionOf } from "./flow-engine.js";
10
11
  import { runActivityOf } from "./task-activity.js";
11
12
  import { workStateRows } from "./work-index.js";
@@ -48,7 +49,7 @@ export function factsOfAttempt(facts, activityAt, paused, now, decisionOpen) {
48
49
  if (facts.outcome === "built" || facts.outcome === "no-change")
49
50
  return { activity: "finished", outcome: facts.outcome, obligation: false, unread };
50
51
  // Stopped by a person or a restart, in either spelling (a legacy outcome, or a failure whose reason is the interruption).
51
- if (facts.outcome === "interrupted" || facts.outcome === "refused" || (facts.outcome === "failed" && facts.reason === "interrupted"))
52
+ if (wasInterrupted(facts) || facts.outcome === "refused")
52
53
  return { activity: "stopped", outcome: "none", obligation: false, unread };
53
54
  // A parked attempt asks only while its decision is unanswered (when known) and its task is open.
54
55
  if (facts.outcome === "parked")
@@ -13,6 +13,7 @@ import { readVerifiedArtifact } from "./evidence.js";
13
13
  import { failedAttemptSentence } from "./needs-you.js";
14
14
  import { projectName } from "./project.js";
15
15
  import { providerName } from "./provider-auth.js";
16
+ import { wasInterrupted } from "./run-ending.js";
16
17
  import { runActivityOf } from "./task-activity.js";
17
18
  export const AGENT_GROUPS = [
18
19
  { key: "needs-you", label: "Needs you" },
@@ -117,8 +118,8 @@ export function agentGroupOf(facts, activityAt, now) {
117
118
  return { group: facts.seen || facts.legacy ? "idle" : "done", label };
118
119
  }
119
120
  if (SETTLED_TASK.has(facts.taskState))
120
- return { group: "idle", label: facts.outcome === "interrupted" ? "Stopped" : "Failed" };
121
- return { group: "needs-you", label: facts.outcome === "parked" ? "Needs a decision" : facts.outcome === "interrupted" ? "Stopped" : facts.outcome === "refused" ? "Not started" : "Failed" };
121
+ return { group: "idle", label: wasInterrupted(facts) ? "Stopped" : "Failed" };
122
+ return { group: "needs-you", label: facts.outcome === "parked" ? "Needs a decision" : wasInterrupted(facts) ? "Stopped" : facts.outcome === "refused" ? "Not started" : "Failed" };
122
123
  }
123
124
  const sealed = new Map();
124
125
  /** A finished attempt's sealed diff stats (cached by artifact: sealed bytes never change). */
@@ -5,16 +5,17 @@
5
5
  */
6
6
  import { attemptKeyOf, agentNowForRun, openAgentQuestions } from "./agent-board.js";
7
7
  import { agentsLiveRuns } from "./store.js";
8
+ import { wasInterrupted } from "./run-ending.js";
8
9
  /** An ended agent stays on the grid this long, then leaves (it remains on the List). */
9
10
  export const LIVE_ENDED_KEEP_MS = 10 * 60_000;
10
- const stateOf = (outcome) => outcome === null ? "working" : outcome === "built" || outcome === "no-change" ? "done" : outcome === "parked" ? "parked" : outcome === "interrupted" ? "stopped" : "failed";
11
+ const stateOf = (run) => run.outcome === null ? "working" : run.outcome === "built" || run.outcome === "no-change" ? "done" : run.outcome === "parked" ? "parked" : wasInterrupted(run) ? "stopped" : "failed";
11
12
  /** The grid's tiles for one viewer: `canSee` is the caller's proof (ceiling and account access) for each run. */
12
13
  export function agentsLiveOf(store, now, viewer) {
13
14
  const tiles = [];
14
15
  for (const run of agentsLiveRuns(store, new Date(now.getTime() - LIVE_ENDED_KEEP_MS))) {
15
16
  if (!viewer.canSee(run))
16
17
  continue;
17
- let state = stateOf(run.outcome);
18
+ let state = stateOf(run);
18
19
  let why = null, quietSince = null;
19
20
  if (state === "working") {
20
21
  try {
@@ -39,7 +39,8 @@ export declare const carrySealSql: (scope: string) => string;
39
39
  * SQL: a task that belongs to a release authority (its anchor, or a carried commit) dispatches only as the current
40
40
  * generation's tip while the release is open and unexpired, with the anchor's approver still an active approver who
41
41
  * can reach the project and the anchor's approval on its exact sealed digest. A task that belongs to no release passes.
42
- * The claim and build belts read the whole predicate (scope.ts liveReleaseOf) besides.
42
+ * The claim and build belts read the whole predicate (scope.ts liveReleaseOf) besides. "Open and unexpired" here is
43
+ * release-authority.ts releaseStateAt(a, now) === "open", written in SQL.
43
44
  */
44
45
  export declare const releaseStandsSql: (scope: string, now: string) => string;
45
46
  /** SQL: a machine made this seal — any proof at the seal's instant that isn't a person's (whatever its detail says,
@@ -89,7 +89,8 @@ export const carrySealSql = (scope) => `EXISTS (SELECT 1 FROM release_carry rc2
89
89
  * SQL: a task that belongs to a release authority (its anchor, or a carried commit) dispatches only as the current
90
90
  * generation's tip while the release is open and unexpired, with the anchor's approver still an active approver who
91
91
  * can reach the project and the anchor's approval on its exact sealed digest. A task that belongs to no release passes.
92
- * The claim and build belts read the whole predicate (scope.ts liveReleaseOf) besides.
92
+ * The claim and build belts read the whole predicate (scope.ts liveReleaseOf) besides. "Open and unexpired" here is
93
+ * release-authority.ts releaseStateAt(a, now) === "open", written in SQL.
93
94
  */
94
95
  export const releaseStandsSql = (scope, now) => `((NOT EXISTS (SELECT 1 FROM release_authority rx WHERE rx.anchor_task = ${scope}.task_id)
95
96
  AND NOT EXISTS (SELECT 1 FROM release_carry cx WHERE cx.task_id = ${scope}.task_id))
package/dist/chat-ask.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chatHash, partContent, savedChatPart } from "./chat-delivery-state.js";
1
+ import { chatHash, partContent } from "./chat-delivery-state.js";
2
2
  import { LEAD_ASK_OTHER } from "./store.js";
3
3
  /** Whether a task (the execution shown, never only its family's root) moved past a lead's question asked at `since`:
4
4
  * its plan was rewritten or approved, or a new attempt of it started. The options answered a state that is gone. A run
@@ -48,7 +48,9 @@ function repaint(state, part, event, line, now) {
48
48
  }
49
49
  const before = partContent(String(row["payload"]));
50
50
  const content = { text: `${before.text}\n\n${line}`.slice(0, 3400), edit: event.ts, ...(before.channel === undefined ? {} : { channel: before.channel }) };
51
- state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE provider=:provider AND id=?").run(savedChatPart(content), part);
51
+ // A question that was dropped isn't brought back: the line goes as a new message, the same as one too old to edit.
52
+ if (!state.repaint(part, content))
53
+ state.plan(event.id, [{ text: line }], now);
52
54
  state.finish(event.id);
53
55
  }
54
56
  /** A tapped ask button, inside the action's transaction; false when the token isn't one. */
package/dist/chat-core.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * Imports the store's types only, so the store can build its Telegram methods on it.
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
+ /** The result a waiting event is dropped with when its chat is disconnected (kept only when it had none). */
12
+ const REVOKED = "revoked";
11
13
  export const chatHash = (text) => createHash("sha256").update(text).digest("hex");
12
14
  export const PAIRING_CODE_TTL_MS = 10 * 60_000;
13
15
  const str = (value) => (value === null || value === undefined ? null : String(value));
@@ -141,7 +143,7 @@ export class ChatCore {
141
143
  this.prepare(`UPDATE ${table} SET consumed = ? WHERE provider = :provider AND binding = ? AND consumed IS NULL`).run(stamp, binding.id);
142
144
  for (const table of ["chat_flow_prompt", "chat_question_prompt"])
143
145
  this.prepare(`UPDATE ${table} SET consumed = ? WHERE provider = :provider AND binding = ? AND consumed IS NULL`).run(stamp, binding.id);
144
- this.prepare("UPDATE chat_event SET state = 'dropped', payload = '{}', problem = 'Chat disconnected', result = COALESCE(result, 'revoked') WHERE provider = :provider AND binding = ? AND state = 'queued'").run(binding.id);
146
+ this.prepare("UPDATE chat_event SET state = 'dropped', payload = '{}', problem = 'Chat disconnected', result = COALESCE(result, ?) WHERE provider = :provider AND binding = ? AND state = 'queued'").run(REVOKED, binding.id);
145
147
  this.prepare(`UPDATE chat_part SET state = 'dropped', problem = 'Chat disconnected' WHERE provider = :provider AND state = 'pending'
146
148
  AND event IN (SELECT id FROM chat_event WHERE provider = :provider AND binding = ?)`).run(binding.id);
147
149
  return revoked;
@@ -153,7 +155,7 @@ export class ChatCore {
153
155
  for (const binding of this.bindings(installation))
154
156
  this.revokeBinding(binding, now, by);
155
157
  this.prepare("UPDATE chat_pair SET consumed = ? WHERE provider = :provider AND (installation = ? OR installation IS NULL) AND consumed IS NULL").run(now.toISOString(), installation);
156
- this.prepare("UPDATE chat_event SET state = 'dropped', payload = '{}', problem = 'Chat disconnected', result = COALESCE(result, 'revoked') WHERE provider = :provider AND installation = ? AND state = 'queued' AND kind <> 'update'").run(installation);
158
+ this.prepare("UPDATE chat_event SET state = 'dropped', payload = '{}', problem = 'Chat disconnected', result = COALESCE(result, ?) WHERE provider = :provider AND installation = ? AND state = 'queued' AND kind <> 'update'").run(REVOKED, installation);
157
159
  this.prepare(`UPDATE chat_part SET state = 'dropped', problem = 'Chat disconnected' WHERE provider = :provider AND state = 'pending'
158
160
  AND event IN (SELECT id FROM chat_event WHERE provider = :provider AND installation = ?)`).run(installation);
159
161
  });
@@ -13,7 +13,7 @@
13
13
  * offer, mint and answer these through the same tokens (the `channel` column). Every act runs through the door the
14
14
  * console uses. */
15
15
  import { routeChangeOf } from "./route-change.js";
16
- import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, releaseTaskRef } from "./store.js";
16
+ import { CONFIRM_TTL_MS, NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, releaseTaskRef } from "./store.js";
17
17
  import { isAcpProfile } from "./acp-agents.js";
18
18
  import { DECIDE_ACTS, readDecideActionRow } from "./contracts/chat-callback-rows.js";
19
19
  import { createHash, randomBytes } from "node:crypto";
@@ -60,7 +60,6 @@ export const DECIDE_LABELS = {
60
60
  };
61
61
  const OFFER_TTL_MS = 7 * 86_400_000;
62
62
  const CHANNEL_NAMES = { telegram: "Telegram", slack: "Slack", discord: "Discord", teams: "Teams" };
63
- const CONFIRM_TTL_MS = 10 * 60_000;
64
63
  const PROMPT_TTL_MS = 30 * 60_000;
65
64
  export const WHAT_SHOULD_CHANGE = "What should change?";
66
65
  const sha = (text) => createHash("sha256").update(text).digest("hex");
@@ -58,12 +58,21 @@ export declare class ChatState extends ChatMessages {
58
58
  event(id: string): ChatEvent | null;
59
59
  next(installation: string, now: Date): ChatEvent | null;
60
60
  defer(event: string, problem: string, until: Date): void;
61
+ /**
62
+ * Mark an event handled: done (or dropped). For a message this runs when its reply is planned, before any part is
63
+ * sent; so on these apps done does not mean the person has the reply. Ask `unsent()` in chat-message-states.ts for
64
+ * that. Dropped is final: an event dropped while its handler was still working (the chat was disconnected) is
65
+ * never written back to done.
66
+ */
61
67
  finish(event: string, dropped?: boolean): void;
62
68
  /** Point an event at the binding its pairing made. */
63
69
  setEventBinding(event: string, binding: number): void;
64
70
  part(id: number): ChatPart | null;
65
- /** The reply to an event, part by part, with each part's buttons minted with it; the event is then handled. Planned once. */
66
- plan(event: string, parts: ChatContent[], now: Date): void;
71
+ /**
72
+ * The reply to an event, part by part, with each part's buttons minted with it; the event is then handled. Planned
73
+ * once. A dropped event gets no reply: nothing is saved, no button is minted, and the answer is false.
74
+ */
75
+ plan(event: string, parts: ChatContent[], now: Date): boolean;
67
76
  /** A proposal card's buttons on its part: earlier ones on that part are spent. Yes and Cancel last ten minutes. */
68
77
  tokens(part: number, proposal: number, phases: Array<"confirm" | "dismiss" | "yes" | "cancel">, now: Date): void;
69
78
  /** A proposal card's live buttons on one part, in the order they were minted. */
@@ -84,22 +93,36 @@ export declare class ChatState extends ChatMessages {
84
93
  * (chat-decide.ts) now ride that message, and any older ones on it stop working; a "What should change?" names it.
85
94
  */
86
95
  partSent(id: number, message: string | null, now: Date, attempt?: boolean): void;
87
- /** Not sent this time: counted, named, and (when the answer was lost rather than refused) counted as uncertain. */
96
+ /**
97
+ * Not sent this time: counted, named, and (when the answer was lost rather than refused) counted as uncertain. The
98
+ * part stays pending. A failed send never drops it on any app: it goes when the app says it can never be sent, or
99
+ * when the chat is disconnected.
100
+ */
88
101
  partFailed(id: number, failure: {
89
102
  problem: string;
90
103
  until: string;
91
104
  uncertain: boolean;
92
- }, giveUpAfter?: number): void;
105
+ }): void;
93
106
  /** One person's replies still waiting to send, and those that could not be delivered (settings show both). */
94
107
  partCounts(binding: number): {
95
108
  pending: number;
96
109
  dropped: number;
97
110
  };
98
- /** Replace a part's content and send it again (a repaint after a tap). */
99
- repaint(id: number, content: ChatContent): void;
111
+ /**
112
+ * Replace a part's content and send it again (a repaint after a tap). A dropped part stays dropped: it was given up
113
+ * on, or its chat disconnected, and new content doesn't bring it back. False then, so the caller can say it another way.
114
+ */
115
+ repaint(id: number, content: ChatContent): boolean;
100
116
  }
101
117
  /** A saved part's content. One that can't be read is a delivery problem that names the field, never a guess. */
102
118
  export declare function partContent(payload: string): ChatContent;
119
+ /** The least time before a failed part is sent again, by how many times it has failed. The last step repeats. */
120
+ export declare const CHAT_RETRY_STEPS_MS: readonly [5000, 15000, 60000, 300000];
121
+ /**
122
+ * When a part that failed to send is tried again: after the app's own wait or the step for its count of failures,
123
+ * whichever is longer. One rule for Slack, Discord and Teams.
124
+ */
125
+ export declare function chatRetryAt(now: Date, retryMs: number, attempts: number): string;
103
126
  export declare class ChatDeliveryError extends Error {
104
127
  readonly code: string;
105
128
  readonly retryMs: number;
@@ -1,8 +1,9 @@
1
1
  /** Durable chat receipts on the shared chat core: what each app's chats sent us and what we plan to send them. */
2
2
  import { randomBytes } from "node:crypto";
3
- import { LEAD_ASK_TTL_MS } from "./store.js";
3
+ import { CONFIRM_TTL_MS, LEAD_ASK_TTL_MS } from "./store.js";
4
4
  import { ChatCore, chatHash } from "./chat-core.js";
5
5
  import { ChatMessages } from "./chat-messages.js";
6
+ import { unsentPart } from "./chat-message-states.js";
6
7
  import { readChatPart, savedChatEventBody, savedChatPart } from "./contracts/chat-content.js";
7
8
  export { savedChatPart, chatHash };
8
9
  const DAY_MS = 86_400_000;
@@ -52,8 +53,14 @@ export class ChatState extends ChatMessages {
52
53
  defer(event, problem, until) {
53
54
  this.prepare("UPDATE chat_event SET next_at = ?, problem = ? WHERE provider = :provider AND id = ? AND state = 'queued'").run(until.toISOString(), problem, event);
54
55
  }
56
+ /**
57
+ * Mark an event handled: done (or dropped). For a message this runs when its reply is planned, before any part is
58
+ * sent; so on these apps done does not mean the person has the reply. Ask `unsent()` in chat-message-states.ts for
59
+ * that. Dropped is final: an event dropped while its handler was still working (the chat was disconnected) is
60
+ * never written back to done.
61
+ */
55
62
  finish(event, dropped = false) {
56
- this.prepare("UPDATE chat_event SET state = ?, payload = '{}' WHERE provider = :provider AND id = ?").run(dropped ? "dropped" : "done", event);
63
+ this.prepare("UPDATE chat_event SET state = ?, payload = '{}' WHERE provider = :provider AND id = ? AND state <> 'dropped'").run(dropped ? "dropped" : "done", event);
57
64
  }
58
65
  /** Point an event at the binding its pairing made. */
59
66
  setEventBinding(event, binding) {
@@ -62,9 +69,14 @@ export class ChatState extends ChatMessages {
62
69
  part(id) {
63
70
  return this.prepare("SELECT * FROM chat_part WHERE provider = :provider AND id = ?").get(id) ?? null;
64
71
  }
65
- /** The reply to an event, part by part, with each part's buttons minted with it; the event is then handled. Planned once. */
72
+ /**
73
+ * The reply to an event, part by part, with each part's buttons minted with it; the event is then handled. Planned
74
+ * once. A dropped event gets no reply: nothing is saved, no button is minted, and the answer is false.
75
+ */
66
76
  plan(event, parts, now) {
67
- this.store.transact(() => {
77
+ return this.store.transact(() => {
78
+ if (this.prepare("SELECT state FROM chat_event WHERE provider = :provider AND id = ?").get(event)?.["state"] === "dropped")
79
+ return false;
68
80
  for (const [ordinal, part] of parts.entries()) {
69
81
  if (this.prepare("SELECT 1 AS hit FROM chat_part WHERE provider = :provider AND event = ? AND ordinal = ?").get(event, ordinal) !== undefined)
70
82
  continue;
@@ -95,13 +107,14 @@ export class ChatState extends ChatMessages {
95
107
  .run(token(), id, part.flow.card, part.flow.entry, action, now.toISOString(), new Date(now.getTime() + 7 * DAY_MS).toISOString());
96
108
  }
97
109
  this.finish(event);
110
+ return true;
98
111
  });
99
112
  }
100
113
  /** A proposal card's buttons on its part: earlier ones on that part are spent. Yes and Cancel last ten minutes. */
101
114
  tokens(part, proposal, phases, now) {
102
115
  this.prepare("UPDATE chat_action SET consumed = ? WHERE provider = :provider AND part = ? AND consumed IS NULL").run(now.toISOString(), part);
103
116
  for (const phase of phases)
104
- this.prepare("INSERT INTO chat_action (provider, token, part, proposal, phase, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?)").run(randomBytes(16).toString("hex"), part, proposal, phase, now.toISOString(), new Date(now.getTime() + (phase === "yes" || phase === "cancel" ? 600_000 : DAY_MS)).toISOString());
117
+ this.prepare("INSERT INTO chat_action (provider, token, part, proposal, phase, created, expires) VALUES (:provider, ?, ?, ?, ?, ?, ?)").run(randomBytes(16).toString("hex"), part, proposal, phase, now.toISOString(), new Date(now.getTime() + (phase === "yes" || phase === "cancel" ? CONFIRM_TTL_MS : DAY_MS)).toISOString());
105
118
  }
106
119
  /** A proposal card's live buttons on one part, in the order they were minted. */
107
120
  partTokens(part, now) {
@@ -160,21 +173,28 @@ export class ChatState extends ChatMessages {
160
173
  this.prepare("UPDATE chat_decide_prompt SET message = ? WHERE channel = :provider AND id = ? AND message IS NULL").run(placed, content.prompt);
161
174
  });
162
175
  }
163
- /** Not sent this time: counted, named, and (when the answer was lost rather than refused) counted as uncertain. */
164
- partFailed(id, failure, giveUpAfter) {
165
- this.prepare(`UPDATE chat_part SET attempts = attempts + 1, uncertain = uncertain + ?, next_at = ?, problem = ?,
166
- state = CASE WHEN ? IS NOT NULL AND attempts >= ? THEN 'dropped' ELSE state END WHERE provider = :provider AND id = ?`)
167
- .run(failure.uncertain ? 1 : 0, failure.until, failure.problem, giveUpAfter ?? null, giveUpAfter ?? 0, id);
176
+ /**
177
+ * Not sent this time: counted, named, and (when the answer was lost rather than refused) counted as uncertain. The
178
+ * part stays pending. A failed send never drops it on any app: it goes when the app says it can never be sent, or
179
+ * when the chat is disconnected.
180
+ */
181
+ partFailed(id, failure) {
182
+ this.prepare("UPDATE chat_part SET attempts = attempts + 1, uncertain = uncertain + ?, next_at = ?, problem = ? WHERE provider = :provider AND id = ?")
183
+ .run(failure.uncertain ? 1 : 0, failure.until, failure.problem, id);
168
184
  }
169
185
  /** One person's replies still waiting to send, and those that could not be delivered (settings show both). */
170
186
  partCounts(binding) {
171
- const row = this.prepare(`SELECT SUM(p.state = 'pending') AS pending, SUM(p.state = 'dropped') AS dropped FROM chat_part p
187
+ const row = this.prepare(`SELECT SUM(${unsentPart("p")}) AS pending, SUM(p.state = 'dropped') AS dropped FROM chat_part p
172
188
  JOIN chat_event e ON e.provider = p.provider AND e.id = p.event WHERE p.provider = :provider AND e.binding = ?`).get(binding);
173
189
  return { pending: Number(row?.["pending"] ?? 0), dropped: Number(row?.["dropped"] ?? 0) };
174
190
  }
175
- /** Replace a part's content and send it again (a repaint after a tap). */
191
+ /**
192
+ * Replace a part's content and send it again (a repaint after a tap). A dropped part stays dropped: it was given up
193
+ * on, or its chat disconnected, and new content doesn't bring it back. False then, so the caller can say it another way.
194
+ */
176
195
  repaint(id, content) {
177
- this.prepare("UPDATE chat_part SET payload = ?, state = 'pending', next_at = NULL WHERE provider = :provider AND id = ?").run(savedChatPart(content), id);
196
+ return Number(this.prepare("UPDATE chat_part SET payload = ?, state = 'pending', next_at = NULL WHERE provider = :provider AND id = ? AND state <> 'dropped'")
197
+ .run(savedChatPart(content), id).changes) === 1;
178
198
  }
179
199
  }
180
200
  /** A saved part's content. One that can't be read is a delivery problem that names the field, never a guess. */
@@ -184,6 +204,16 @@ export function partContent(payload) {
184
204
  throw new ChatDeliveryError(`This saved message can't be read: ${read.issues.map(issue => issue.line).join("; ")}`, 0, false, true);
185
205
  return read.value;
186
206
  }
207
+ /** The least time before a failed part is sent again, by how many times it has failed. The last step repeats. */
208
+ export const CHAT_RETRY_STEPS_MS = [5000, 15000, 60000, 300000];
209
+ /**
210
+ * When a part that failed to send is tried again: after the app's own wait or the step for its count of failures,
211
+ * whichever is longer. One rule for Slack, Discord and Teams.
212
+ */
213
+ export function chatRetryAt(now, retryMs, attempts) {
214
+ const step = CHAT_RETRY_STEPS_MS[Math.min(Math.max(attempts, 0), CHAT_RETRY_STEPS_MS.length - 1)];
215
+ return new Date(now.getTime() + Math.max(retryMs, step)).toISOString();
216
+ }
187
217
  export class ChatDeliveryError extends Error {
188
218
  code;
189
219
  retryMs;
@@ -117,6 +117,8 @@ const decideRows = (rows) => rows.map(row => row.map(one => "token" in one ? { l
117
117
  function mintDecide(options, binding, target, offer, now) {
118
118
  return { rows: decideRows(mintDecideButtons(options.store, decideSeat(options.state, binding), target, offer, now).rows) };
119
119
  }
120
+ /** Said when a card that still waits for a tap can no longer be shown again in the chat. */
121
+ const CARD_GONE = "This card can't be updated here now. Open Toolroll to finish this.";
120
122
  /**
121
123
  * A decide button (chat-decide.ts), inside the tap's transaction: the same door, words and two taps as on Telegram.
122
124
  * The card it rode is repainted in place; Request changes asks for the next message; a merge goes out after.
@@ -138,7 +140,10 @@ function applyChatDecideTap(options, event, binding, token, repos, now) {
138
140
  if (tapped.edit !== undefined && row !== undefined && card !== null) {
139
141
  const { decide: _decide, link: _link, also: _also, edit: _edit, ...kept } = card;
140
142
  repainted = { ...kept, text: tapped.edit.text, ...(tapped.edit.rows.length === 0 ? {} : { decide: { rows: decideRows(tapped.edit.rows) } }) };
141
- state.repaint(Number(row["id"]), repainted);
143
+ // A card that was dropped isn't brought back: what happened goes as a new message. A step that waits for a second
144
+ // tap can't be shown on it, so the person is sent to the task.
145
+ if (!state.repaint(Number(row["id"]), repainted))
146
+ replies.push({ text: tapped.edit.tokens.length === 0 ? tapped.ack : CARD_GONE });
142
147
  }
143
148
  else
144
149
  replies.push({ text: tapped.ack });
@@ -726,10 +731,12 @@ export function applyChatAction(options, event, binding, repos) {
726
731
  content.link = link;
727
732
  }
728
733
  }
729
- // Repaint the original persisted card; retain one placement and fresh tokens.
730
- state
731
- .prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE provider=:provider AND id=?")
732
- .run(savedChatPart(content), action.part);
734
+ // Repaint the original persisted card; retain one placement and fresh tokens. A card that was dropped isn't brought
735
+ // back: an outcome goes as a new message, and a card that still waits for a tap sends the person to Toolroll.
736
+ if (!state.repaint(action.part, content)) {
737
+ const { edit: _edit, proposal: _proposal, phase: _phase, ...asNew } = content;
738
+ state.plan(event.id, [content.proposal === undefined ? asNew : { text: CARD_GONE }], now);
739
+ }
733
740
  if (!content.proposal)
734
741
  state
735
742
  .prepare("UPDATE chat_action SET consumed=? WHERE provider=:provider AND proposal=? AND consumed IS NULL")
package/dist/chat-flow.js CHANGED
@@ -35,7 +35,7 @@ import { renderReply } from "./reply-shape.js";
35
35
  import { deciderOf, replyTarget } from "./flows.js";
36
36
  import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
37
37
  import { verifyApproverStanding } from "./principal.js";
38
- import { chatHash, partContent, savedChatPart } from "./chat-delivery-state.js";
38
+ import { chatHash, partContent } from "./chat-delivery-state.js";
39
39
  import { readHeldWords } from "./contracts/chat-content.js";
40
40
  import { FLOW_DECIDE_KEY, flowDecisionAt } from "./telegram-flow.js";
41
41
  import { phoneCommand } from "./telegram-status.js";
@@ -144,14 +144,16 @@ const retire = (state, card, entry, now) => {
144
144
  state.prepare("UPDATE chat_flow_choice SET consumed=? WHERE provider=:provider AND card=? AND entry=? AND consumed IS NULL").run(now.toISOString(), card, entry);
145
145
  state.prepare("UPDATE chat_flow_prompt SET consumed=? WHERE provider=:provider AND card=? AND entry=? AND consumed IS NULL").run(now.toISOString(), card, entry);
146
146
  };
147
- /** Show the tapped notice again with what happened, and without its buttons. */
148
- function repaint(state, part, event, line) {
147
+ /** Show the tapped notice again with what happened, and without its buttons. A notice that was dropped isn't brought
148
+ * back: the line goes as a new message. */
149
+ function repaint(state, part, event, line, now) {
149
150
  const row = state.prepare("SELECT payload FROM chat_part WHERE provider=:provider AND id=?").get(part);
150
151
  const before = row === undefined ? { text: "" } : partContent(String(row["payload"]));
151
152
  // An item list (flow-items.ts) stays whole and keeps its bold titles and labelled links; only plain text is capped.
152
153
  const content = { text: before.voice === true ? `${before.text}\n\n${line}` : `${before.text}\n\n${line}`.slice(0, PART_CHARS + 400), edit: event.ts, ...(before.voice === true ? { voice: true } : {}),
153
154
  ...(before.link === undefined ? {} : { link: before.link }), ...(before.channel === undefined ? {} : { channel: before.channel }) };
154
- state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE provider=:provider AND id=?").run(savedChatPart(content), part);
155
+ if (!state.repaint(part, content))
156
+ state.plan(event.id, [{ text: line }], now);
155
157
  }
156
158
  /**
157
159
  * A tapped flow button, inside the action's transaction. false when the
@@ -177,7 +179,7 @@ export function applyChatFlowTap(options, event, binding, token, repos, now) {
177
179
  const waiting = flowDecisionAt(store, card, entry);
178
180
  if (waiting === null) {
179
181
  retire(state, card, entry, now);
180
- repaint(state, part, event, "This card has moved on since; nothing was changed.");
182
+ repaint(state, part, event, "This card has moved on since; nothing was changed.", now);
181
183
  state.finish(event.id);
182
184
  return true;
183
185
  }
@@ -199,7 +201,7 @@ export function applyChatFlowTap(options, event, binding, token, repos, now) {
199
201
  }
200
202
  retire(state, card, entry, now);
201
203
  store.retireTelegramFlowVisit(card, entry, now);
202
- repaint(state, part, event, `✅ You approved it. ${decided.said}`);
204
+ repaint(state, part, event, `✅ You approved it. ${decided.said}`, now);
203
205
  state.finish(event.id);
204
206
  return true;
205
207
  }
@@ -234,7 +236,7 @@ function applyChatChoiceTap(options, event, binding, token, repos, now) {
234
236
  const visit = flowChoiceAt(store, card, entry);
235
237
  if (visit === null) {
236
238
  retire(state, card, entry, now);
237
- repaint(state, part, event, "This card has moved on since; nothing was changed.");
239
+ repaint(state, part, event, "This card has moved on since; nothing was changed.", now);
238
240
  state.finish(event.id);
239
241
  return true;
240
242
  }
@@ -254,7 +256,7 @@ function applyChatChoiceTap(options, event, binding, token, repos, now) {
254
256
  }
255
257
  retire(state, card, entry, now);
256
258
  store.retireFlowChoices(card, entry, now);
257
- repaint(state, part, event, `✅ You chose “${label}”. ${chosen.said}`);
259
+ repaint(state, part, event, `✅ You chose “${label}”. ${chosen.said}`, now);
258
260
  state.finish(event.id);
259
261
  return true;
260
262
  }
@@ -349,7 +351,7 @@ function applyChatNoteTap(options, event, binding, token, repos, now) {
349
351
  }
350
352
  const part = Number(tapped["part"]), card = Number(tapped["card"]), entry = Number(tapped["entry"]);
351
353
  if (tapped["consumed"] !== null || String(tapped["expires"]) <= now.toISOString()) {
352
- repaint(state, part, event, "That choice was already made, or the card moved on; nothing was changed.");
354
+ repaint(state, part, event, "That choice was already made, or the card moved on; nothing was changed.", now);
353
355
  state.finish(event.id);
354
356
  return true;
355
357
  }
@@ -358,7 +360,7 @@ function applyChatNoteTap(options, event, binding, token, repos, now) {
358
360
  const kept = typeof tapped["words"] === "string" ? readHeldWords(tapped["words"]) : null;
359
361
  const words = kept?.ok === true ? kept.value : null;
360
362
  if (held === null || words === null) {
361
- repaint(state, part, event, "That message is no longer kept; nothing was changed.");
363
+ repaint(state, part, event, "That message is no longer kept; nothing was changed.", now);
362
364
  state.finish(event.id);
363
365
  return true;
364
366
  }
@@ -366,12 +368,12 @@ function applyChatNoteTap(options, event, binding, token, repos, now) {
366
368
  // Passed on as the message it was, to the lead, once.
367
369
  state.enqueue({ id: chatHash(`${held.id}:lead`), installation: held.installation, binding: held.binding, kind: "message", channel: held.channel, member: held.member,
368
370
  ts: held.ts, thread: held.thread, payload: { ...words, lead: true }, created: now.toISOString() });
369
- repaint(state, part, event, "Passed to the lead.");
371
+ repaint(state, part, event, "Passed to the lead.", now);
370
372
  state.finish(event.id);
371
373
  return true;
372
374
  }
373
375
  if (flowChoiceAt(store, card, entry) === null) {
374
- repaint(state, part, event, "That choice was already made, or the card moved on; nothing was changed.");
376
+ repaint(state, part, event, "That choice was already made, or the card moved on; nothing was changed.", now);
375
377
  state.finish(event.id);
376
378
  return true;
377
379
  }
@@ -380,7 +382,7 @@ function applyChatNoteTap(options, event, binding, token, repos, now) {
380
382
  state.finish(event.id, true);
381
383
  return true;
382
384
  }
383
- repaint(state, part, event, done.ok ? `↩️ ${done.said}` : done.said);
385
+ repaint(state, part, event, done.ok ? `↩️ ${done.said}` : done.said, now);
384
386
  state.finish(event.id);
385
387
  return true;
386
388
  }
package/dist/chat-live.js CHANGED
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { createLiveReply, createLiveSender, LIVE_STATUS } from "./live-reply.js";
27
27
  import { renderReply, shapeReplyParts } from "./reply-shape.js";
28
+ import { unsent } from "./chat-message-states.js";
28
29
  /** The live message's fixed words. */
29
30
  export const LIVE_WORDS = {
30
31
  working: "Working on it…",
@@ -131,7 +132,9 @@ export function planWithLive(state, event, parts, now) {
131
132
  state.store.transact(() => {
132
133
  const row = liveRow(state, event);
133
134
  const onto = row !== null && row.message !== null && (row.state === "placed" || row.state === "abandoned") && editable(parts[0]);
134
- state.plan(event, onto ? [{ ...parts[0], edit: row.message }, ...parts.slice(1)] : parts, now);
135
+ // A dropped event gets no reply, so its live message is left as it is.
136
+ if (!state.plan(event, onto ? [{ ...parts[0], edit: row.message }, ...parts.slice(1)] : parts, now))
137
+ return;
135
138
  if (row === null)
136
139
  return;
137
140
  if (onto)
@@ -352,7 +355,7 @@ export async function ackPass(input) {
352
355
  if (hooks.unreact !== undefined) {
353
356
  const done = eventOf(state.prepare(`SELECT a.event FROM chat_ack a JOIN chat_event e ON e.provider = a.provider AND e.id = a.event
354
357
  WHERE a.provider = :provider AND a.state = 'sent' AND e.installation = ? AND e.state IN ('done','dropped')
355
- AND NOT EXISTS (SELECT 1 FROM chat_part p WHERE p.provider = a.provider AND p.event = a.event AND p.state = 'pending')
358
+ AND NOT ${unsent("e")}
356
359
  ORDER BY a.created LIMIT 1`).get(installation));
357
360
  if (done !== null) {
358
361
  try {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * An incoming chat message (a `chat_event` row of kind 'message'): what its states mean, the result words it is
3
+ * settled with, and the one question "which replies have not reached the person yet".
4
+ *
5
+ * States (contracts/chat-tables.ts): queued, running, done, failed, dropped.
6
+ *
7
+ * What `done` means differs by app, and nothing should read more into it than this:
8
+ * - Telegram: done after every planned part of the reply was sent (or dropped as moot, such as a card whose
9
+ * proposal was already answered elsewhere). It is written by `ChatMessages.finishMessage`, only by the claim
10
+ * holder and only from 'running'.
11
+ * - Slack, Discord and Teams: done as soon as the reply is planned, before any of it is sent. `ChatState.plan`
12
+ * writes the parts and then `ChatState.finish` marks the row done in the same transaction; the parts go out
13
+ * later, part by part, through `chat_part`.
14
+ * So `done` means "the message needs no more work from the lead", never "the person has the reply". To ask whether
15
+ * a reply has reached the person, use `unsent()` / `unsentPart()` below, which look at the parts themselves and
16
+ * give the same answer on every app.
17
+ *
18
+ * `failed` is used by Telegram only. `dropped` means the chat was disconnected (or an app gave the event up) while
19
+ * it waited; Telegram rows read a dropped row as failed. Dropped is final: `ChatState.finish` never writes over it,
20
+ * and `ChatState.plan` saves no reply for it.
21
+ */
22
+ import type { ResolvedLead } from "./chat-channel.js";
23
+ import type { LeadFailure, LeadRefusal } from "./lead.js";
24
+ /**
25
+ * Every fixed result word a message row can be written with. These are stored in `chat_event.result`, so each one
26
+ * must stay byte-identical: renaming one would change what older rows mean.
27
+ */
28
+ export declare const CHAT_MESSAGE_OUTCOMES: readonly ["answered", "replayed", "typed-start", "busy", "backlog", "replayed:running", "registry-unavailable", "registry-unavailable:admitted", "delivering", "answered:sending", "unpaired", "unsent:no-session", "replayed:missing", "typed-start:not-counted", "revoked"];
29
+ export type ChatMessageFixedOutcome = (typeof CHAT_MESSAGE_OUTCOMES)[number];
30
+ /** Words a message is queued under while it waits for another attempt (telegram-lead.ts `requeue`). */
31
+ export declare const CHAT_WAIT_OUTCOMES: readonly ["busy", "backlog", "replayed:running", "registry-unavailable", "registry-unavailable:admitted"];
32
+ export type ChatWaitOutcome = (typeof CHAT_WAIT_OUTCOMES)[number];
33
+ /** Waiting for the lead itself (it is answering, or earlier messages wait first): the two-hour bound applies. */
34
+ export declare const CHAT_LEAD_WAITS: readonly ["busy", "backlog"];
35
+ export type ChatLeadWait = (typeof CHAT_LEAD_WAITS)[number];
36
+ /** Waiting on a turn that was already admitted: no age bound (the turn's own deadline ends it). */
37
+ export declare const CHAT_ADMITTED_WAITS: readonly ["replayed:running", "registry-unavailable:admitted"];
38
+ /** The words a row is marked done with (the reply was sent). */
39
+ export type ChatDoneOutcome = "answered" | "replayed" | "typed-start";
40
+ type RefusedReason = LeadRefusal | Extract<ResolvedLead, {
41
+ ok: false;
42
+ }>["reason"];
43
+ /**
44
+ * Every result word, including the forms that carry a detail after a prefix or a suffix. Build the detailed forms
45
+ * with `chatOutcome` below, never by joining strings at the call site.
46
+ */
47
+ export type ChatMessageOutcome = ChatMessageFixedOutcome | `${ChatWaitOutcome}:gave-up` | `refused:${RefusedReason}` | `failed:${LeadFailure}` | `replayed:${string}` | `unsent:gave-up:${string}` | `unsent:${string}`;
48
+ /** Builders for the result words that carry a detail. The stored text is exactly what the old string joins wrote. */
49
+ export declare const chatOutcome: {
50
+ /** A wait that ran past its age bound. */
51
+ readonly gaveUp: <W extends ChatWaitOutcome>(wait: W) => `${W}:gave-up`;
52
+ /** The lead refused the turn, or no session could be resolved for it. */
53
+ readonly refused: <R extends RefusedReason>(reason: R) => `refused:${R}`;
54
+ /** The turn ran and failed. */
55
+ readonly failed: <R extends LeadFailure>(reason: R) => `failed:${R}`;
56
+ /** The recovered turn had ended without an answer: its failure reason as the turn row stores it ("failed" when none). */
57
+ readonly replayedFailed: (reason: string | null) => `replayed:${string}`;
58
+ /** The reply could not go out: the chat or its projects changed (the problem in words). */
59
+ readonly unsent: (problem: string) => `unsent:${string}`;
60
+ /** The reply was still unsent a day after it was planned (the last send error, or "unsent"). */
61
+ readonly unsentGaveUp: (error: string | null) => `unsent:gave-up:${string}`;
62
+ };
63
+ /** Is this word one of the waits for the lead itself (busy or backlog)? */
64
+ export declare function isLeadWait(outcome: string | null): outcome is ChatLeadWait;
65
+ /** Is this word a wait on a turn that was already admitted (no age bound)? */
66
+ export declare function isAdmittedWait(outcome: string | null): boolean;
67
+ /** SQL: this `chat_part` row (by its alias) has not reached the person: planned and neither sent nor dropped. */
68
+ export declare function unsentPart(part: string): string;
69
+ /**
70
+ * SQL: the `chat_event` row (by its alias or table name) has a reply part that has not reached the person yet.
71
+ * The one answer to "is anything of this reply unsent?" on every app; wrap it in NOT for "everything reached them
72
+ * or was dropped". `part` names the inner alias when the caller's query already uses the default.
73
+ */
74
+ export declare function unsent(event: string, part?: string): string;
75
+ export {};