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.
- package/CHANGELOG.md +11 -0
- package/dist/acp-check.js +4 -2
- package/dist/agent-state.js +2 -1
- package/dist/agents-board.js +3 -2
- package/dist/agents-live.js +3 -2
- package/dist/approval-authority.d.ts +2 -1
- package/dist/approval-authority.js +2 -1
- package/dist/chat-ask.js +4 -2
- package/dist/chat-core.js +4 -2
- package/dist/chat-decide.js +1 -2
- package/dist/chat-delivery-state.d.ts +29 -6
- package/dist/chat-delivery-state.js +43 -13
- package/dist/chat-delivery.js +12 -5
- package/dist/chat-flow.js +15 -13
- package/dist/chat-live.js +5 -2
- package/dist/chat-message-states.d.ts +75 -0
- package/dist/chat-message-states.js +74 -0
- package/dist/chat-messages.d.ts +10 -4
- package/dist/chat-messages.js +12 -7
- package/dist/check-failure.js +2 -1
- package/dist/check-report.js +1 -1
- package/dist/claim.d.ts +40 -2
- package/dist/claim.js +92 -19
- package/dist/contracts/chat-tables.js +3 -0
- package/dist/coordinator.js +3 -3
- package/dist/demo.js +7 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +2 -3
- package/dist/dispatch.js +2 -1
- package/dist/dispose.d.ts +4 -1
- package/dist/dispose.js +2 -1
- package/dist/fallback.js +3 -2
- package/dist/fast-start.js +2 -2
- package/dist/monitoring.js +2 -1
- package/dist/operate.js +75 -36
- package/dist/plane-review.js +2 -1
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/release-authority.d.ts +9 -0
- package/dist/release-authority.js +13 -0
- package/dist/release-auto.js +2 -3
- package/dist/release-gate.js +1 -1
- package/dist/release-view.js +2 -1
- package/dist/release.js +18 -30
- package/dist/result-shots.js +1 -1
- package/dist/route-results.js +2 -1
- package/dist/run-ending.d.ts +21 -0
- package/dist/run-ending.js +19 -0
- package/dist/scope.js +2 -2
- package/dist/server/render-results.js +3 -2
- package/dist/server/render-tasks.js +3 -2
- package/dist/setup-guide.d.ts +2 -2
- package/dist/slack-chat.js +2 -3
- package/dist/store.d.ts +61 -2
- package/dist/store.js +127 -77
- package/dist/subagent-question.js +8 -6
- package/dist/surface.js +2 -2
- package/dist/task-moves.d.ts +90 -0
- package/dist/task-moves.js +68 -0
- package/dist/teams-chat.js +2 -2
- package/dist/telegram-flow.d.ts +1 -1
- package/dist/telegram-flow.js +1 -1
- package/dist/telegram-lead.d.ts +1 -2
- package/dist/telegram-lead.js +18 -23
- package/dist/telegram-progress.js +2 -1
- package/dist/telegram.d.ts +0 -1
- package/dist/telegram.js +6 -7
- package/dist/vanished-retry.js +3 -2
- package/dist/workspace-ui.js +2 -1
- package/dist/worktree.js +3 -2
- 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 {
|
|
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
|
-
|
|
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());
|
package/dist/agent-state.js
CHANGED
|
@@ -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
|
|
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")
|
package/dist/agents-board.js
CHANGED
|
@@ -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
|
|
121
|
-
return { group: "needs-you", label: facts.outcome === "parked" ? "Needs a decision" : facts
|
|
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). */
|
package/dist/agents-live.js
CHANGED
|
@@ -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 = (
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
});
|
package/dist/chat-decide.js
CHANGED
|
@@ -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
|
-
/**
|
|
66
|
-
|
|
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
|
-
/**
|
|
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
|
-
}
|
|
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
|
-
/**
|
|
99
|
-
|
|
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
|
-
/**
|
|
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" ?
|
|
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
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
-
/**
|
|
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 = ?")
|
|
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;
|
package/dist/chat-delivery.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 {};
|