toolroll 0.9.9 → 0.9.11
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 +15 -0
- package/dist/assignment.js +3 -1
- package/dist/chat-channel.d.ts +9 -7
- package/dist/chat-channel.js +23 -17
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +49 -8
- package/dist/chat-quiet.d.ts +10 -4
- package/dist/chat-quiet.js +87 -22
- package/dist/chat-voice.d.ts +68 -0
- package/dist/chat-voice.js +175 -0
- package/dist/discord-chat.js +1 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +118 -37
- package/dist/project-delete.js +1 -0
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +30 -0
- package/dist/serve.js +9 -2
- package/dist/slack-chat.js +2 -0
- package/dist/store.d.ts +39 -1
- package/dist/store.js +112 -2
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-mate.d.ts +7 -0
- package/dist/telegram-mate.js +50 -21
- package/dist/telegram-progress.d.ts +6 -1
- package/dist/telegram-progress.js +11 -4
- package/dist/telegram-settings.d.ts +3 -0
- package/dist/telegram-settings.js +11 -0
- package/dist/telegram-status.d.ts +2 -1
- package/dist/telegram-status.js +3 -2
- package/dist/telegram.d.ts +15 -1
- package/dist/telegram.js +164 -52
- package/package.json +1 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { phoneText } from "./telegram-status.js";
|
|
2
|
+
/** The bot's display name in every chat app. */
|
|
3
|
+
export const BOT_NAME = "Toolroll";
|
|
4
|
+
/** Updates for one person landing within this window become one message, edited in place as it grows. */
|
|
5
|
+
export const BATCH_MS = 2 * 60_000;
|
|
6
|
+
export const SHORT_TITLE_MAX = 60;
|
|
7
|
+
/** A token that could be an id: letters and digits joined by `-` or `_`, or with a digit in it (`release-099b`,
|
|
8
|
+
* `t42`). Words such as `node-20` and `utf-8` look the same, so a token goes only when it names a real task. */
|
|
9
|
+
const ID_SHAPED = /\(?(?<![\p{L}\p{N}._/-])[\p{L}\p{N}]+(?:[-_.][\p{L}\p{N}]+)*(?![\p{L}\p{N}_/-]|\.[\p{L}\p{N}])\)?/gu;
|
|
10
|
+
/** An id that could be mistaken for nothing else (`release-099b`, `fix_tax`, `t42`). A one-word id such as `tidy`
|
|
11
|
+
* is also a word, and prose keeps its words. */
|
|
12
|
+
const idLike = (id) => /[-_\d]/.test(id);
|
|
13
|
+
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
+
/** A short human summary of a task's title, at most 60 characters: no "— revision", and no task ids. Only the
|
|
15
|
+
* task's own id and tokens `isTaskId` names as real tasks are dropped; `node-20` and `utf-8` stay. */
|
|
16
|
+
export function shortTitle(title, taskId, isTaskId) {
|
|
17
|
+
let text = phoneText(title ?? "", 400);
|
|
18
|
+
// "— revision", "- revision 2", "(revision)": a revision is the same work to a person.
|
|
19
|
+
text = text.replace(/(?:(?:\s*[—–]+|\s+-+)\s*revision\b(?:\s*#?\d+)?|\s*[([]\s*revision(?:\s*#?\d+)?\s*[)\]])/giu, "");
|
|
20
|
+
text = text.replace(ID_SHAPED, token => {
|
|
21
|
+
const bare = token.replace(/^\(|\)$/g, "");
|
|
22
|
+
return idLike(bare) && (bare === taskId || isTaskId?.(bare) === true) ? " " : token;
|
|
23
|
+
});
|
|
24
|
+
text = text.replace(/\(\s*\)/g, " ").replace(/\s+/g, " ").replace(/^[\s·:,;—–-]+|[\s·:,;—–-]+$/gu, "").trim();
|
|
25
|
+
if (text === "") {
|
|
26
|
+
// A task filed with only its id as a title: the words, never the slug.
|
|
27
|
+
const words = (taskId ?? "").replace(/[-_]+/g, " ").trim();
|
|
28
|
+
return words === "" ? "A task" : shortTitle(words[0].toUpperCase() + words.slice(1));
|
|
29
|
+
}
|
|
30
|
+
if (text.length <= SHORT_TITLE_MAX)
|
|
31
|
+
return text;
|
|
32
|
+
const cut = text.slice(0, SHORT_TITLE_MAX - 1);
|
|
33
|
+
const space = cut.lastIndexOf(" ");
|
|
34
|
+
return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[\s·:,;—–-]+$/u, "")}…`;
|
|
35
|
+
}
|
|
36
|
+
/** A task's title as every chat says it: short, with its own id and any other real task's id dropped. */
|
|
37
|
+
export function chatTitle(store, taskId) {
|
|
38
|
+
return shortTitle(store.getTask(taskId)?.title, taskId, token => token !== taskId && store.getTask(token) !== null);
|
|
39
|
+
}
|
|
40
|
+
/** A title inside a sentence or list: "faster tests", but "API keys" and "iOS" keep their capitals. */
|
|
41
|
+
export function inSentence(summary) {
|
|
42
|
+
return /^\p{Lu}\p{Ll}/u.test(summary) ? summary[0].toLowerCase() + summary.slice(1) : summary;
|
|
43
|
+
}
|
|
44
|
+
/** The two buttons under a result ready for a person: the real next step, then a look first. */
|
|
45
|
+
export const READY_ACTIONS = { merge: "Merge", complete: "Mark complete", look: "Look first" };
|
|
46
|
+
/** Whether this line asks for the result's next step, so it carries [Merge] or [Mark complete] and [Look first]. */
|
|
47
|
+
export function asksToFinish(fact) {
|
|
48
|
+
return fact.update == null && fact.headline === "Ready for review" && !fact.report;
|
|
49
|
+
}
|
|
50
|
+
/** One update, said plainly: what happened, then what happens next. The lead says "I" for what it did. */
|
|
51
|
+
export function finishedLine(fact) {
|
|
52
|
+
if (fact.update != null)
|
|
53
|
+
return fact.update.words;
|
|
54
|
+
const name = fact.summary, lower = inSentence(fact.summary);
|
|
55
|
+
const ready = fact.lead === true ? `I finished ${lower}` : `${name} is ready`;
|
|
56
|
+
switch (fact.headline) {
|
|
57
|
+
case "Ready for review":
|
|
58
|
+
if (fact.report)
|
|
59
|
+
return fact.lead === true ? `I wrote up ${lower}. The report is ready to read.` : `${name}: the report is ready to read.`;
|
|
60
|
+
if (fact.checks === "passed")
|
|
61
|
+
return `${ready}. Your tests passed. ${fact.pullRequest === true ? "Merge it?" : "Mark it complete?"}`;
|
|
62
|
+
if (fact.checks === "off")
|
|
63
|
+
return `${ready}. Checks are off for this project, so look it over first.`;
|
|
64
|
+
return `${ready}, but no tests ran. Look it over first.`;
|
|
65
|
+
case "Failed":
|
|
66
|
+
if (fact.checks === "failed")
|
|
67
|
+
return fact.lead === true
|
|
68
|
+
? `I built ${lower}, but its tests failed. It waits for you: retry or ask for changes.`
|
|
69
|
+
: `${name} is built, but its tests failed. It waits for you: retry or ask for changes.`;
|
|
70
|
+
return fact.lead === true
|
|
71
|
+
? `I couldn't finish ${lower}. The work so far is kept; retry when you're ready.`
|
|
72
|
+
: `${name} stopped before it finished. The work so far is kept; retry when you're ready.`;
|
|
73
|
+
case "Complete":
|
|
74
|
+
if (fact.lead === true && fact.completedBy)
|
|
75
|
+
return `I marked ${lower} complete.`;
|
|
76
|
+
return fact.completedBy ? `${name} is done. ${fact.completedBy} marked it complete.` : `${name} is done.`;
|
|
77
|
+
case "Needs you":
|
|
78
|
+
return fact.lead === true ? `I need your decision on ${lower} before I can go on.` : `${name} needs your decision before it can continue.`;
|
|
79
|
+
default:
|
|
80
|
+
return `${name}: ${fact.headline.toLowerCase()}.`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** One task in a list of updates: "faster tests is ready", "cleanup failed". */
|
|
84
|
+
function phraseOf(fact) {
|
|
85
|
+
const name = inSentence(fact.summary);
|
|
86
|
+
if (fact.update != null)
|
|
87
|
+
return `${name} ${fact.update.phrase}`;
|
|
88
|
+
switch (fact.headline) {
|
|
89
|
+
case "Ready for review": return `${name} is ready`;
|
|
90
|
+
case "Failed": return `${name} failed`;
|
|
91
|
+
case "Complete": return `${name} is done`;
|
|
92
|
+
case "Needs you": return `${name} needs you`;
|
|
93
|
+
default: return `${name}: ${fact.headline.toLowerCase()}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Several updates in one message: "4 tasks finished: faster tests, cleanup, …" (or "3 updates: …" when not all of
|
|
97
|
+
* them are finished work), then what broke and what happens next. */
|
|
98
|
+
export function batchLine(facts) {
|
|
99
|
+
if (facts.length === 1)
|
|
100
|
+
return finishedLine(facts[0]);
|
|
101
|
+
const finishedOnly = facts.every(one => one.update == null);
|
|
102
|
+
const names = finishedOnly ? facts.map(one => inSentence(one.summary)) : facts.map(phraseOf);
|
|
103
|
+
const shown = names.slice(0, 3).join(", ");
|
|
104
|
+
const first = `${facts.length} ${finishedOnly ? "tasks finished" : "updates"}: ${shown}${names.length > 3 ? ", …" : "."}`;
|
|
105
|
+
// What happens next: anything that broke or needs a decision waits for the person.
|
|
106
|
+
const failed = facts.filter(one => one.headline === "Failed");
|
|
107
|
+
const needs = facts.filter(one => one.headline === "Needs you").length;
|
|
108
|
+
const broke = failed.length !== 1 ? `${failed.length} failed`
|
|
109
|
+
: failed[0].checks === "failed" ? `tests failed on ${inSentence(failed[0].summary)}` : `${inSentence(failed[0].summary)} stopped before it finished`;
|
|
110
|
+
const waits = [
|
|
111
|
+
...(failed.length === 0 ? [] : [broke]),
|
|
112
|
+
...(needs === 0 ? [] : [needs === facts.length ? "each needs your decision" : `${needs} ${needs === 1 ? "needs" : "need"} your decision`]),
|
|
113
|
+
];
|
|
114
|
+
if (waits.length === 0)
|
|
115
|
+
return first;
|
|
116
|
+
const said = waits.join(", and ");
|
|
117
|
+
return `${first}\n${said[0].toUpperCase()}${said.slice(1)}; ${failed.length + needs === 1 ? "it waits" : "they wait"} for you.`;
|
|
118
|
+
}
|
|
119
|
+
/** The old product name a bot may still wear from before the rename. */
|
|
120
|
+
const OLD_NAME = /standing\s*-?\s*orders/i;
|
|
121
|
+
/**
|
|
122
|
+
* Name the Telegram bot Toolroll. On pairing it always becomes Toolroll; on upgrade only a bot still called
|
|
123
|
+
* StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
|
|
124
|
+
* leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
|
|
125
|
+
*/
|
|
126
|
+
export async function nameTelegramBot(transport, when) {
|
|
127
|
+
try {
|
|
128
|
+
const current = await transport("getMyName", {});
|
|
129
|
+
const name = current.ok ? current.result?.name : undefined;
|
|
130
|
+
const shown = typeof name === "string" ? name : null;
|
|
131
|
+
if (shown === BOT_NAME)
|
|
132
|
+
return false;
|
|
133
|
+
if (when === "upgrade" && (shown === null || !OLD_NAME.test(shown)))
|
|
134
|
+
return false;
|
|
135
|
+
const set = await transport("setMyName", { name: BOT_NAME });
|
|
136
|
+
return set.ok;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** The last word on any pushed chat text: a task's own id (bare or in brackets), a "— revision" suffix and a
|
|
143
|
+
* "Replaced by <id>" never reach a chat, whatever words the fact was recorded with. A bare id reads as the
|
|
144
|
+
* task's short title when one is given, else "this task". */
|
|
145
|
+
export function chatText(text, tasks = []) {
|
|
146
|
+
let out = text.replace(/(?:(?:[ \t]*[—–]+|[ \t]+-+)[ \t]*revision\b(?:[ \t]*#?\d+)?|[ \t]*[([][ \t]*revision(?:[ \t]*#?\d+)?[ \t]*[)\]])/giu, "");
|
|
147
|
+
out = out.replace(/\bReplaced by (?!a newer task)[^\s.,;:!?)]+/gu, "Replaced by a newer task");
|
|
148
|
+
for (const task of tasks) {
|
|
149
|
+
const id = typeof task === "string" ? task : task?.id;
|
|
150
|
+
if (!id || !idLike(id))
|
|
151
|
+
continue;
|
|
152
|
+
const title = typeof task === "object" && task !== null ? task.title : undefined;
|
|
153
|
+
out = out.replace(new RegExp(`[ \\t]*\\(${escape(id)}\\)|(?<![\\p{L}\\p{N}_/=-])${escape(id)}(?![\\p{L}\\p{N}_-])`, "gu"), match => match.trimStart().startsWith("(") ? "" : title ?? "this task");
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
/** The one button a plain fact may carry: its label names where its machine-minted console path goes; nothing in
|
|
158
|
+
* it comes from the fact's text. */
|
|
159
|
+
export function factLinkLabel(path) {
|
|
160
|
+
if (/^\/review\?result=[^&]+&run=\d+&tab=checks$/.test(path))
|
|
161
|
+
return "Inspect result";
|
|
162
|
+
if (/^\/t\/[^?#]+#merge$/.test(path))
|
|
163
|
+
return "Merge";
|
|
164
|
+
if (/^\/chat\?task=[^&]+&result=/.test(path))
|
|
165
|
+
return "Open result";
|
|
166
|
+
if (/^\/(?:chat\?task=|t\/)/.test(path))
|
|
167
|
+
return "Open task";
|
|
168
|
+
if (/^\/d\//.test(path))
|
|
169
|
+
return "Open decision";
|
|
170
|
+
return "Open console";
|
|
171
|
+
}
|
|
172
|
+
/** Whether the words already name this title as a whole phrase (so it need not be said twice). */
|
|
173
|
+
export function mentions(text, title) {
|
|
174
|
+
return new RegExp(`(?<![\\p{L}\\p{N}_-])${escape(title)}(?![\\p{L}\\p{N}_-])`, "u").test(text);
|
|
175
|
+
}
|
package/dist/discord-chat.js
CHANGED
|
@@ -273,6 +273,7 @@ export async function deliverDiscordPart(options) {
|
|
|
273
273
|
}))
|
|
274
274
|
: []),
|
|
275
275
|
...link(options.origin(), content.link),
|
|
276
|
+
...(content.also ?? []).flatMap(one => link(options.origin(), one)),
|
|
276
277
|
];
|
|
277
278
|
let target = content.edit ?? row.message;
|
|
278
279
|
if (target && content.image && row.uploaded) {
|
package/dist/operate.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ export type OperateOptions = {
|
|
|
50
50
|
gitRunner?: CommandRunner;
|
|
51
51
|
/** Injected by tests: the Telegram Bot API. Production dials the real one. */
|
|
52
52
|
telegramTransport?: TelegramTransport;
|
|
53
|
+
/** Injected by tests: the environment checked for a fenced network before Telegram is reached (default: this process's, with the real transport). */
|
|
54
|
+
telegramEnv?: Record<string, string | undefined>;
|
|
53
55
|
publishExec?: PublishExec;
|
|
54
56
|
/** Injected by tests: the stop fence a watch would set. */
|
|
55
57
|
shouldStop?: () => boolean;
|
package/dist/operate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UNSENT_REPLY_MS } from "./telegram-settings.js";
|
|
1
2
|
import { maybeTriggerRepair } from "./dispose.js";
|
|
2
3
|
import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
|
|
3
4
|
import { fileAddTestsTask, followUpChecksOf, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
|
|
@@ -138,7 +139,7 @@ import { diagnoseTaskDispatch, diagnosisIsDispatchable, scopeApprovedForDispatch
|
|
|
138
139
|
import { createDecisionServer } from "./serve.js";
|
|
139
140
|
import { readTailnetNames } from "./tailnet.js";
|
|
140
141
|
import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
|
|
141
|
-
import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
|
|
142
|
+
import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, networkFence, networkFenceLine, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
|
|
142
143
|
import { scanRepo } from "./capscan.js";
|
|
143
144
|
import { computeGaps, describeCapability } from "./gaps.js";
|
|
144
145
|
import { ask, askHidden, confirm, interactive, underAgent } from "./prompt.js";
|
|
@@ -198,7 +199,7 @@ import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
|
|
|
198
199
|
import { beads } from "./beads.js";
|
|
199
200
|
import { githubIssues } from "./issues.js";
|
|
200
201
|
import { installationStatus, renderInstallationStatus, renderTaskWait, repairStaleStatuses, taskWaitSnapshot } from "./lead-status.js";
|
|
201
|
-
import { latestRelease, newerRelease, notifySecurityRelease, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
|
|
202
|
+
import { latestRelease, newerRelease, notifySecurityRelease, notifyVersionLive, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
|
|
202
203
|
import { installMethod } from "./install-method.js";
|
|
203
204
|
import { PACKAGE_VERSION } from "./version.js";
|
|
204
205
|
/**
|
|
@@ -820,6 +821,7 @@ export async function runOperate(command, argv, write, options = {}) {
|
|
|
820
821
|
...(options.agentRunner === undefined ? {} : { agentRunner: options.agentRunner }),
|
|
821
822
|
...(options.gitRunner === undefined ? {} : { gitRunner: options.gitRunner }),
|
|
822
823
|
...(options.telegramTransport === undefined ? {} : { telegramTransport: options.telegramTransport }),
|
|
824
|
+
...(options.telegramEnv === undefined ? {} : { telegramEnv: options.telegramEnv }),
|
|
823
825
|
...(options.publishExec === undefined ? {} : { publishExec: options.publishExec }),
|
|
824
826
|
...(options.dispatchAdapter === undefined ? {} : { dispatchAdapter: options.dispatchAdapter }),
|
|
825
827
|
...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
|
|
@@ -7288,6 +7290,50 @@ async function daemonCommand(positional, flags, context) {
|
|
|
7288
7290
|
const WATCH_LEASE_MS = 90_000;
|
|
7289
7291
|
const WATCH_HEARTBEAT_MS = 30_000;
|
|
7290
7292
|
/** The start repair runs once per process, not once per project loop. */
|
|
7293
|
+
/**
|
|
7294
|
+
* The Telegram follower: one actor on the wire that sends notifications and
|
|
7295
|
+
* replies and reads what arrives, all over the same transport. Null when no
|
|
7296
|
+
* bot token is saved, or when this process's network is fenced (it says so
|
|
7297
|
+
* once instead of failing every send).
|
|
7298
|
+
*/
|
|
7299
|
+
function startTelegramFollower(args) {
|
|
7300
|
+
const { context, progress, label } = args;
|
|
7301
|
+
const source = loadBotToken(process.env, context.telegramTokenFile);
|
|
7302
|
+
if (source === null)
|
|
7303
|
+
return null;
|
|
7304
|
+
const fence = networkFence(context.telegramEnv ?? (context.telegramTransport === undefined ? process.env : {}));
|
|
7305
|
+
if (fence !== null) {
|
|
7306
|
+
progress(networkFenceLine(fence));
|
|
7307
|
+
return null;
|
|
7308
|
+
}
|
|
7309
|
+
const transport = context.telegramTransport ?? createTransport(source.token);
|
|
7310
|
+
// v98: with a public hooks address (Tailscale Funnel covers /hooks), Telegram pushes updates to it instead.
|
|
7311
|
+
const pushUrl = telegramPushUrl(readHooksBase(dirname(context.databaseFile)));
|
|
7312
|
+
return followBridge(context.store, {
|
|
7313
|
+
push: pushUrl === null ? null : { url: pushUrl, secret: telegramHookSecret(dirname(context.databaseFile), true) },
|
|
7314
|
+
readProjects: telegramReadProjects(context),
|
|
7315
|
+
canDeliver: telegramCanDeliver(context, source.token),
|
|
7316
|
+
conversation: telegramConversation(context, args.publicUrl === undefined ? {} : { serverOrigin: args.publicUrl }),
|
|
7317
|
+
botId: source.botId,
|
|
7318
|
+
transport,
|
|
7319
|
+
signal: args.signal,
|
|
7320
|
+
...(args.owner === undefined ? {} : { owner: args.owner }),
|
|
7321
|
+
onCycle: cycle => {
|
|
7322
|
+
// progress(), not write(): under --json this line went to stdout
|
|
7323
|
+
// BESIDE the final envelope — the one stdout contamination in the
|
|
7324
|
+
// watch (round-4 finding 12).
|
|
7325
|
+
progress(`${label} sent ${cycle.sent}, answered ${cycle.answered}, paired ${cycle.paired}` +
|
|
7326
|
+
((cycle.statusReplies ?? 0) > 0 ? `, status replies ${cycle.statusReplies}` : "") +
|
|
7327
|
+
((cycle.chatQueued ?? 0) > 0 ? `, chat received ${cycle.chatQueued}` : "") +
|
|
7328
|
+
((cycle.chatAnswered ?? 0) > 0 ? `, chat replied ${cycle.chatAnswered}` : "") +
|
|
7329
|
+
((cycle.chatConfirmed ?? 0) > 0 ? `, chat confirmed ${cycle.chatConfirmed}` : "") +
|
|
7330
|
+
(cycle.problems.length > 0 ? ` — ${cycle.problems.length} problem(s): ${cycle.problems[0].slice(0, 200)}` : ""));
|
|
7331
|
+
},
|
|
7332
|
+
}).catch(error => {
|
|
7333
|
+
progress(`${args.died.prefix}the telegram follower died — ${describe(error)}; ${args.died.then}`);
|
|
7334
|
+
return null;
|
|
7335
|
+
});
|
|
7336
|
+
}
|
|
7291
7337
|
let staleStatusesRepaired = false;
|
|
7292
7338
|
async function runWatchLoop(args) {
|
|
7293
7339
|
const { context, flags, runner, token, repo, progress } = args;
|
|
@@ -7324,6 +7370,11 @@ async function runWatchLoop(args) {
|
|
|
7324
7370
|
// attributes to this episode by runner and window, and `brief
|
|
7325
7371
|
// --latest-watch` bounds itself to exactly it.
|
|
7326
7372
|
store.startWatchEpisode({ repo, runner, incarnation }, new Date());
|
|
7373
|
+
// A deploy's one chat line, "Toolroll <version> is live.", when this worker last ran an older version.
|
|
7374
|
+
try {
|
|
7375
|
+
notifyVersionLive(store, dirname(context.databaseFile), PACKAGE_VERSION, new Date());
|
|
7376
|
+
}
|
|
7377
|
+
catch { /* A display fact; never blocks the worker. */ }
|
|
7327
7378
|
recordRunnerVersion(dirname(context.databaseFile), runner, PACKAGE_VERSION, new Date());
|
|
7328
7379
|
args.onReady?.();
|
|
7329
7380
|
// Once per worker start, before any pass: statuses an older build left
|
|
@@ -7361,38 +7412,13 @@ async function runWatchLoop(args) {
|
|
|
7361
7412
|
// moment they arrive, and answering bumps the wake sequence, so the very
|
|
7362
7413
|
// loop below wakes and resumes the freed task — phone to build, seconds.
|
|
7363
7414
|
// The poll lease keeps this the only live poller; a cron `bridge
|
|
7364
|
-
// telegram` overlapping it loses the lease race and reports busy.
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
const pushUrl = telegramPushUrl(readHooksBase(dirname(context.databaseFile)));
|
|
7372
|
-
follower = followBridge(store, {
|
|
7373
|
-
push: pushUrl === null ? null : { url: pushUrl, secret: telegramHookSecret(dirname(context.databaseFile), true) },
|
|
7374
|
-
readProjects: telegramReadProjects(context),
|
|
7375
|
-
canDeliver: telegramCanDeliver(context, followSource.token),
|
|
7376
|
-
conversation: telegramConversation(context, publicUrl === undefined ? {} : { serverOrigin: publicUrl }),
|
|
7377
|
-
botId: followSource.botId,
|
|
7378
|
-
transport,
|
|
7379
|
-
signal: followController.signal,
|
|
7380
|
-
onCycle: cycle => {
|
|
7381
|
-
// progress(), not write(): under --json this line went to stdout
|
|
7382
|
-
// BESIDE the final envelope — the one stdout contamination in the
|
|
7383
|
-
// watch (round-4 finding 12).
|
|
7384
|
-
progress(`watch: bridge sent ${cycle.sent}, answered ${cycle.answered}, paired ${cycle.paired}` +
|
|
7385
|
-
((cycle.statusReplies ?? 0) > 0 ? `, status replies ${cycle.statusReplies}` : "") +
|
|
7386
|
-
((cycle.chatQueued ?? 0) > 0 ? `, chat received ${cycle.chatQueued}` : "") +
|
|
7387
|
-
((cycle.chatAnswered ?? 0) > 0 ? `, chat replied ${cycle.chatAnswered}` : "") +
|
|
7388
|
-
((cycle.chatConfirmed ?? 0) > 0 ? `, chat confirmed ${cycle.chatConfirmed}` : "") +
|
|
7389
|
-
(cycle.problems.length > 0 ? ` — ${cycle.problems.length} problem(s): ${cycle.problems[0].slice(0, 200)}` : ""));
|
|
7390
|
-
},
|
|
7391
|
-
}).catch(error => {
|
|
7392
|
-
progress(`watch: the telegram follower died — ${describe(error)}; taps wait for the next watch`);
|
|
7393
|
-
return null;
|
|
7394
|
-
});
|
|
7395
|
-
}
|
|
7415
|
+
// telegram` overlapping it loses the lease race and reports busy. Under
|
|
7416
|
+
// `up` the service runs the one follower itself and project loops leave
|
|
7417
|
+
// Telegram to it.
|
|
7418
|
+
const serviceBridge = args.serviceBridge === true;
|
|
7419
|
+
const follower = serviceBridge
|
|
7420
|
+
? null
|
|
7421
|
+
: startTelegramFollower({ context, publicUrl: text(flags, "public-url"), signal: followController.signal, progress, label: "watch: bridge", died: { prefix: "watch: ", then: "taps wait for the next watch" } });
|
|
7396
7422
|
const slackFollower = followSlack({ store, dir: dirname(context.databaseFile), signal: followController.signal,
|
|
7397
7423
|
readProjects: telegramReadProjects(context), evidenceRoot: context.evidenceRoot,
|
|
7398
7424
|
...(context.mateSeams?.subscriptionRunner ? { subscriptionRunner: context.mateSeams.subscriptionRunner } : {}),
|
|
@@ -7685,7 +7711,7 @@ async function runWatchLoop(args) {
|
|
|
7685
7711
|
// pass is the fallback shape for a watch started before a token existed.
|
|
7686
7712
|
if (follower === null && now - lastBridge >= bridgeEveryMs) {
|
|
7687
7713
|
lastBridge = now;
|
|
7688
|
-
const source = loadBotToken(process.env, context.telegramTokenFile);
|
|
7714
|
+
const source = serviceBridge ? null : loadBotToken(process.env, context.telegramTokenFile);
|
|
7689
7715
|
const dir = dirname(context.databaseFile);
|
|
7690
7716
|
// Exactly ONE service carries the pages — the chosen primary, or
|
|
7691
7717
|
// the sensible implicit one. Telegram keeps draining taps and
|
|
@@ -8533,6 +8559,7 @@ async function upCommand(flags, context) {
|
|
|
8533
8559
|
onFollowController: controller => followControllers.push(controller),
|
|
8534
8560
|
containmentLines,
|
|
8535
8561
|
passes: projectPasses,
|
|
8562
|
+
serviceBridge: true,
|
|
8536
8563
|
onReady: () => {
|
|
8537
8564
|
markReady();
|
|
8538
8565
|
if (bind)
|
|
@@ -8552,6 +8579,33 @@ async function upCommand(flags, context) {
|
|
|
8552
8579
|
await ready;
|
|
8553
8580
|
};
|
|
8554
8581
|
await Promise.all(repos.map(repo => launchRepo(repo, false)));
|
|
8582
|
+
// Telegram: one follower for the whole service, never one per project
|
|
8583
|
+
// loop. Replies and notifications go out from it, over the same transport
|
|
8584
|
+
// in the same cycle (Oct 2: a reply sent by a project loop's own follower
|
|
8585
|
+
// failed "fetch failed" while notifications went out). A token saved
|
|
8586
|
+
// while the service runs connects within seconds; a follower that dies
|
|
8587
|
+
// is started again.
|
|
8588
|
+
const telegramController = new AbortController();
|
|
8589
|
+
followControllers.push(telegramController);
|
|
8590
|
+
const telegramSupervisor = (async () => {
|
|
8591
|
+
const pause = (ms) => new Promise(resolvePause => {
|
|
8592
|
+
const timer = setTimeout(done, ms);
|
|
8593
|
+
function done() { clearTimeout(timer); telegramController.signal.removeEventListener("abort", done); resolvePause(); }
|
|
8594
|
+
telegramController.signal.addEventListener("abort", done, { once: true });
|
|
8595
|
+
});
|
|
8596
|
+
while (!stopping && !telegramController.signal.aborted) {
|
|
8597
|
+
const follower = startTelegramFollower({
|
|
8598
|
+
context, publicUrl: text(flags, "public-url"), signal: telegramController.signal, progress, label: "telegram:",
|
|
8599
|
+
died: { prefix: "", then: "starting it again" }, owner: `service-${randomUUID()}`,
|
|
8600
|
+
});
|
|
8601
|
+
if (follower !== null)
|
|
8602
|
+
await follower;
|
|
8603
|
+
// Without a token (or on a fenced network) look again shortly; the fence line is said once.
|
|
8604
|
+
if (follower === null && loadBotToken(process.env, context.telegramTokenFile) !== null)
|
|
8605
|
+
break;
|
|
8606
|
+
await pause(follower === null ? 2_000 : 5_000);
|
|
8607
|
+
}
|
|
8608
|
+
})();
|
|
8555
8609
|
const dynamicCeiling = resolveCeiling(repos, projectRoots).ceiling;
|
|
8556
8610
|
const rejected = new Set();
|
|
8557
8611
|
const registrySupervisor = (async () => {
|
|
@@ -8613,6 +8667,10 @@ async function upCommand(flags, context) {
|
|
|
8613
8667
|
});
|
|
8614
8668
|
};
|
|
8615
8669
|
observeReadiness();
|
|
8670
|
+
try {
|
|
8671
|
+
notifyVersionLive(context.store, dirname(context.databaseFile), PACKAGE_VERSION, clock());
|
|
8672
|
+
}
|
|
8673
|
+
catch { /* A display fact; never blocks the worker. */ }
|
|
8616
8674
|
recordRunnerVersion(dirname(context.databaseFile), runnerName, PACKAGE_VERSION, clock());
|
|
8617
8675
|
const runnerHeartbeat = setInterval(() => {
|
|
8618
8676
|
const beat = heartbeatRunner(store, runnerName, runnerToken, clock());
|
|
@@ -8682,6 +8740,7 @@ async function upCommand(flags, context) {
|
|
|
8682
8740
|
await stopped;
|
|
8683
8741
|
await registrySupervisor;
|
|
8684
8742
|
await Promise.all([...loopResults.values()]);
|
|
8743
|
+
await telegramSupervisor;
|
|
8685
8744
|
clearInterval(runnerHeartbeat);
|
|
8686
8745
|
if (runTimer !== undefined)
|
|
8687
8746
|
clearTimeout(runTimer);
|
|
@@ -8729,6 +8788,7 @@ async function upCommand(flags, context) {
|
|
|
8729
8788
|
* bridge telegram token [<bot-token>|--clear] set the credential file
|
|
8730
8789
|
* bridge telegram status
|
|
8731
8790
|
* bridge telegram digest [--every 30m|2h|24h | --off] away mode: routine facts batch, decisions still page
|
|
8791
|
+
* bridge telegram retry send unsent assistant replies again now
|
|
8732
8792
|
*
|
|
8733
8793
|
* The bot token comes from ${TOKEN_ENV} or the credential file this command
|
|
8734
8794
|
* writes (0600, beside the database). Cron the pass right after tick; a
|
|
@@ -8745,7 +8805,14 @@ async function bridgeCommand(positional, flags, context) {
|
|
|
8745
8805
|
return demoFence;
|
|
8746
8806
|
const [channel, action] = positional;
|
|
8747
8807
|
if (channel !== "telegram") {
|
|
8748
|
-
return fail(write, json, "bridge", "usage", "`toolroll bridge telegram [pair|unpair|token|status|digest]`", EXIT.usage);
|
|
8808
|
+
return fail(write, json, "bridge", "usage", "`toolroll bridge telegram [pair|unpair|token|status|digest|retry]`", EXIT.usage);
|
|
8809
|
+
}
|
|
8810
|
+
if (action === "retry") {
|
|
8811
|
+
// Replies already written and fenced on the pairing like every send: this only moves their next attempt to now.
|
|
8812
|
+
const retried = store.retryTelegramReplies(null, clock());
|
|
8813
|
+
return succeed(write, json, "bridge retry", { retried }, () => [
|
|
8814
|
+
retried === 0 ? "No unsent Telegram replies." : `Sending ${retried} Telegram ${retried === 1 ? "reply" : "replies"} again on the next bridge pass.`,
|
|
8815
|
+
]);
|
|
8749
8816
|
}
|
|
8750
8817
|
if (action === "token") {
|
|
8751
8818
|
const value = positional[2];
|
|
@@ -8862,6 +8929,9 @@ async function bridgeCommand(positional, flags, context) {
|
|
|
8862
8929
|
if (source === null) {
|
|
8863
8930
|
return fail(write, json, "bridge", "no-token", `no bot token — set ${TOKEN_ENV}, run \`toolroll bridge telegram token <t>\`, or use the serve settings card`, EXIT.refused);
|
|
8864
8931
|
}
|
|
8932
|
+
const fenced = networkFence(context.telegramEnv ?? (context.telegramTransport === undefined ? process.env : {}));
|
|
8933
|
+
if (fenced !== null)
|
|
8934
|
+
return fail(write, json, "bridge", "network-fenced", networkFenceLine(fenced), EXIT.refused);
|
|
8865
8935
|
const transport = context.telegramTransport ?? createTransport(source.token);
|
|
8866
8936
|
// --follow: stay on the wire. One long-poll actor holds the poll lease;
|
|
8867
8937
|
// an answer tapped on a phone lands in seconds instead of at the next
|
|
@@ -9579,7 +9649,18 @@ async function statusCommand(positional, flags, context) {
|
|
|
9579
9649
|
const projects = projectBuildsStatus(context);
|
|
9580
9650
|
const projectsLine = projects.length === 0 ? null
|
|
9581
9651
|
: `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
|
|
9582
|
-
|
|
9652
|
+
// A Telegram reply written but still not sent after two minutes, with its Retry.
|
|
9653
|
+
const unsent = context.store.unsentTelegramReplies(null, null, new Date(context.clock().getTime() - UNSENT_REPLY_MS));
|
|
9654
|
+
const unsentLine = unsentRepliesLine(unsent);
|
|
9655
|
+
const unsentReplies = unsent.length === 0 ? {} : { unsentReplies: unsent.map(one => ({ ...one, retry: "toolroll bridge telegram retry" })) };
|
|
9656
|
+
return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting, ...unsentReplies }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
|
|
9657
|
+
}
|
|
9658
|
+
function unsentRepliesLine(unsent) {
|
|
9659
|
+
if (unsent.length === 0)
|
|
9660
|
+
return null;
|
|
9661
|
+
const oldest = unsent[0];
|
|
9662
|
+
const what = unsent.length === 1 ? "A Telegram reply hasn't" : `${unsent.length} Telegram replies haven't`;
|
|
9663
|
+
return `${what} been sent since ${oldest.since.slice(11, 16)} UTC${oldest.error === null ? "" : ` (${oldest.error})`}. Retry: toolroll bridge telegram retry`;
|
|
9583
9664
|
}
|
|
9584
9665
|
/** Each known project's running builds against its limit: its own number, never past its workers' capacity. */
|
|
9585
9666
|
function projectBuildsStatus(context) {
|
package/dist/project-delete.js
CHANGED
|
@@ -232,6 +232,7 @@ function deleteRows(store, repo, d, now) {
|
|
|
232
232
|
del("telegram_conversation", IN("id"), conversations);
|
|
233
233
|
del("telegram_outbound_message", `project = ? OR ${IN("notification")} OR ${IN("task_ref")} OR ${IN("source_run")}`, repo, notifications, T, R);
|
|
234
234
|
del("chat_card_task", IN("task_ref"), T);
|
|
235
|
+
del("chat_batch_item", IN("task_ref"), T);
|
|
235
236
|
del("push_delivery", IN("notification"), notifications);
|
|
236
237
|
del("notification_actor", IN("notification"), notifications);
|
|
237
238
|
del("notification_delivery", IN("notification"), notifications);
|
package/dist/releases.d.ts
CHANGED
|
@@ -83,3 +83,12 @@ export declare function latestVersionNow(io?: {
|
|
|
83
83
|
}): Promise<{
|
|
84
84
|
version: string;
|
|
85
85
|
}>;
|
|
86
|
+
/** What a release says about itself in one line ("Release 0.9.9: lighter tests, cleaner status"), when its notes
|
|
87
|
+
* have such a line; null otherwise. */
|
|
88
|
+
export declare function releaseSummary(notes: string, version: string): string | null;
|
|
89
|
+
/**
|
|
90
|
+
* A deploy says one thing in chat: "Toolroll 0.9.9 is live." (with the release's own one-line summary when its
|
|
91
|
+
* notes have one). Once per version, addressed to each instance operator, and only when a worker on this
|
|
92
|
+
* computer last ran an older version: a fresh install is not news. Call before recording this runner's version.
|
|
93
|
+
*/
|
|
94
|
+
export declare function notifyVersionLive(store: Store, dir: string, version: string, now: Date): number;
|
package/dist/releases.js
CHANGED
|
@@ -236,3 +236,33 @@ export async function latestVersionNow(io = {}) {
|
|
|
236
236
|
throw new Error("npm did not name a version");
|
|
237
237
|
return { version };
|
|
238
238
|
}
|
|
239
|
+
/** What a release says about itself in one line ("Release 0.9.9: lighter tests, cleaner status"), when its notes
|
|
240
|
+
* have such a line; null otherwise. */
|
|
241
|
+
export function releaseSummary(notes, version) {
|
|
242
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
243
|
+
const line = new RegExp(`^\\s*(?:#{1,6}\\s*)?(?:Toolroll\\s+)?(?:Release\\s+)?v?${escaped}\\s*[:—–-]\\s*(.+?)\\s*$`, "im").exec(notes);
|
|
244
|
+
const summary = line?.[1]?.replace(/[*_`]/g, "").replace(/\s+/g, " ").replace(/[.\s]+$/, "").trim() ?? "";
|
|
245
|
+
if (summary === "")
|
|
246
|
+
return null;
|
|
247
|
+
return summary.length <= 80 ? summary : `${summary.slice(0, 79).replace(/[\s,;:]+\S*$/, "")}…`;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* A deploy says one thing in chat: "Toolroll 0.9.9 is live." (with the release's own one-line summary when its
|
|
251
|
+
* notes have one). Once per version, addressed to each instance operator, and only when a worker on this
|
|
252
|
+
* computer last ran an older version: a fresh install is not news. Call before recording this runner's version.
|
|
253
|
+
*/
|
|
254
|
+
export function notifyVersionLive(store, dir, version, now) {
|
|
255
|
+
const before = runnerVersions(dir);
|
|
256
|
+
if (!VERSION.test(version) || !before.some(one => isNewer(version, one.version)) || before.some(one => isNewer(one.version, version)))
|
|
257
|
+
return 0;
|
|
258
|
+
const cached = cachedRelease(dir);
|
|
259
|
+
const summary = cached?.version === version ? releaseSummary(cached.notes, version) : null;
|
|
260
|
+
const line = summary === null ? `Toolroll ${version} is live.` : `Toolroll ${version} is live: ${summary}.`;
|
|
261
|
+
let queued = 0;
|
|
262
|
+
for (const recipient of store.accountFacts().map(one => one.name).filter(name => store.isInstanceOperator(name))) {
|
|
263
|
+
if (store.enqueueNotification({ dedupeKey: `release:live:${version}:${recipient}`, kind: "version-live", subject: line, body: "",
|
|
264
|
+
recipient, source: { installation: true } }, now))
|
|
265
|
+
queued++;
|
|
266
|
+
}
|
|
267
|
+
return queued;
|
|
268
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -1674,7 +1674,7 @@ export function createDecisionServer(options) {
|
|
|
1674
1674
|
// separately proves cookie, approver standing and password; instance bot
|
|
1675
1675
|
// settings remain outside this exact allowlist.
|
|
1676
1676
|
if ((request.method === "GET" && path === "/settings/telegram") ||
|
|
1677
|
-
(request.method === "POST" && ["/settings/telegram/pair", "/settings/telegram/unpair"].includes(path)))
|
|
1677
|
+
(request.method === "POST" && ["/settings/telegram/pair", "/settings/telegram/unpair", "/settings/telegram/retry"].includes(path)))
|
|
1678
1678
|
return true;
|
|
1679
1679
|
// Coding routes require an instance operator, then prove saved ownership and project access.
|
|
1680
1680
|
if (path === "/code" || path.startsWith("/code/"))
|
|
@@ -4066,7 +4066,7 @@ export function createDecisionServer(options) {
|
|
|
4066
4066
|
// The settings view holds no secret (a pairing code only appears in
|
|
4067
4067
|
// the response to Pair, which stays script-free), so it joins the
|
|
4068
4068
|
// workspace like every other settings page.
|
|
4069
|
-
return sendScreen(response, 200, screen("Telegram", telegramSettingsHtml(store, botId, who.name, who.session.csrf), { chrome: chromeFor(project, "settings") }));
|
|
4069
|
+
return sendScreen(response, 200, screen("Telegram", telegramSettingsHtml(store, botId, who.name, who.session.csrf, { now }), { chrome: chromeFor(project, "settings") }));
|
|
4070
4070
|
}
|
|
4071
4071
|
if (url.pathname === "/settings/teams") {
|
|
4072
4072
|
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
@@ -7885,6 +7885,13 @@ export function createDecisionServer(options) {
|
|
|
7885
7885
|
}
|
|
7886
7886
|
return redirect(response, "/settings/slack");
|
|
7887
7887
|
}
|
|
7888
|
+
if (url.pathname === "/settings/telegram/retry") {
|
|
7889
|
+
// The signed-in person's own unsent replies only: already written and fenced on the pairing; this sends them again now.
|
|
7890
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
7891
|
+
return refuse(response, who, 403, "An approver can retry their own replies.", "/settings");
|
|
7892
|
+
store.retryTelegramReplies(who.name, now);
|
|
7893
|
+
return redirect(response, "/settings/telegram");
|
|
7894
|
+
}
|
|
7888
7895
|
if (url.pathname === "/settings/telegram/pair" || url.pathname === "/settings/telegram/unpair") {
|
|
7889
7896
|
// The person's own pairing, under their password: a code minted for
|
|
7890
7897
|
// them alone, or their own chats revoked. Teammates' pairings are
|
package/dist/slack-chat.js
CHANGED
|
@@ -360,6 +360,8 @@ export async function deliverSlackPart(options) {
|
|
|
360
360
|
}))
|
|
361
361
|
: []),
|
|
362
362
|
...linkButton(options.origin(), content.link),
|
|
363
|
+
// [Look first] beside [Merge] or [Mark complete]: each button's own action id.
|
|
364
|
+
...(content.also ?? []).flatMap((one, index) => linkButton(options.origin(), one).map(button => ({ ...button, action_id: `toolroll_link_${index + 2}` }))),
|
|
363
365
|
];
|
|
364
366
|
const target = content.edit ?? row.message;
|
|
365
367
|
const args = {
|
package/dist/store.d.ts
CHANGED
|
@@ -990,6 +990,18 @@ export type ChatCard = {
|
|
|
990
990
|
digest: string | null;
|
|
991
991
|
tasks: number[];
|
|
992
992
|
};
|
|
993
|
+
export type ChatBatch = {
|
|
994
|
+
id: number;
|
|
995
|
+
destination: string;
|
|
996
|
+
openedAt: string;
|
|
997
|
+
message: string | null;
|
|
998
|
+
digest: string | null;
|
|
999
|
+
items: {
|
|
1000
|
+
taskRef: number;
|
|
1001
|
+
run: number | null;
|
|
1002
|
+
notification: number | null;
|
|
1003
|
+
}[];
|
|
1004
|
+
};
|
|
993
1005
|
/**
|
|
994
1006
|
* What one recovery walk over a gone runner's work settled (P0.1a): the run
|
|
995
1007
|
* ids it finished as failed/interrupted and the built-in task ids it returned
|
|
@@ -4609,6 +4621,7 @@ export declare class Store {
|
|
|
4609
4621
|
enqueueEpisode(prefix: string, notification: Omit<NotificationInput, "dedupeKey">, suffix: string, now: Date): boolean;
|
|
4610
4622
|
/** Close every open episode under a prefix — the fault is provably gone. */
|
|
4611
4623
|
resolveEpisodes(prefix: string, now: Date): number;
|
|
4624
|
+
notificationById(id: number): Notification | null;
|
|
4612
4625
|
notificationsAfter(id: number, limit?: number): Notification[];
|
|
4613
4626
|
listNotifications(only?: "pending" | "all"): Notification[];
|
|
4614
4627
|
recordDelivery(id: number, outcome: {
|
|
@@ -7281,7 +7294,7 @@ export declare class Store {
|
|
|
7281
7294
|
* under a claim that lapsed (a crash mid-turn), and not deferred past now.
|
|
7282
7295
|
* One at a time per bot — the mate runs one turn per approver anyway.
|
|
7283
7296
|
*/
|
|
7284
|
-
claimTelegramConversation(botId: string, owner: string, ttlMs: number, now: Date): TelegramConversation | null;
|
|
7297
|
+
claimTelegramConversation(botId: string, owner: string, ttlMs: number, now: Date, only?: "turns" | "replies"): TelegramConversation | null;
|
|
7285
7298
|
/** Extend a held claim; false means it lapsed and somebody else may hold the row. */
|
|
7286
7299
|
renewTelegramConversation(id: number, owner: string, ttlMs: number, now: Date): boolean;
|
|
7287
7300
|
/**
|
|
@@ -7308,6 +7321,20 @@ export declare class Store {
|
|
|
7308
7321
|
outcome: string;
|
|
7309
7322
|
nextAttemptAt: string;
|
|
7310
7323
|
}, now: Date): boolean;
|
|
7324
|
+
/**
|
|
7325
|
+
* Replies the assistant wrote that still have not reached the person
|
|
7326
|
+
* `since` ago or longer (status and the console show them with a Retry):
|
|
7327
|
+
* the message's row, whose it is, when the oldest unsent part was
|
|
7328
|
+
* written, and the last send error.
|
|
7329
|
+
*/
|
|
7330
|
+
unsentTelegramReplies(botId: string | null, approver: string | null, since: Date): {
|
|
7331
|
+
conversation: number;
|
|
7332
|
+
approver: string;
|
|
7333
|
+
since: string;
|
|
7334
|
+
error: string | null;
|
|
7335
|
+
}[];
|
|
7336
|
+
/** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
|
|
7337
|
+
retryTelegramReplies(approver: string | null, now: Date): number;
|
|
7311
7338
|
/**
|
|
7312
7339
|
* Persist the whole outbound half of a turn — every reply part and every
|
|
7313
7340
|
* card, with the cards' tokens already minted — in one transaction, BEFORE
|
|
@@ -7477,6 +7504,12 @@ export declare class Store {
|
|
|
7477
7504
|
* and the evening digest keep every fact either way.
|
|
7478
7505
|
*/
|
|
7479
7506
|
pingAllowed(row: Pick<Notification, "id" | "kind" | "recipient" | "project" | "taskRef">, account: string): boolean;
|
|
7507
|
+
/** Who last completed or cancelled this task, when that is still its latest act: null otherwise. A revision's
|
|
7508
|
+
* completion is recorded on its root, so an act on a task it revises counts too, but only one made after this
|
|
7509
|
+
* revision was filed: settling older work never silences a newer revision, whose failures still reach people. */
|
|
7510
|
+
settledBy(taskRef: number): string | null;
|
|
7511
|
+
/** A release check, a replaced task, or one a revision superseded: its facts stay in the console. */
|
|
7512
|
+
neverPings(taskRef: number): boolean;
|
|
7480
7513
|
/** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
|
|
7481
7514
|
leadQuiet(row: Pick<Notification, "id" | "kind" | "taskRef">, actor?: Actor | null): boolean;
|
|
7482
7515
|
/** A failure with nothing left for the lead to do: its attempts are spent, or the task has failed for good. */
|
|
@@ -7492,6 +7525,11 @@ export declare class Store {
|
|
|
7492
7525
|
} | null;
|
|
7493
7526
|
/** Task-bound facts recorded since an instant, newest last (bounded): what an evening digest reads. */
|
|
7494
7527
|
taskFactsSince(since: string, limit?: number): Notification[];
|
|
7528
|
+
/** The one message this update joins: the destination's batch opened within `windowMs`, or a new one. A task
|
|
7529
|
+
* has one line in it, saying its newest update (`notification`; null for a finished result). */
|
|
7530
|
+
chatBatchFor(destination: string, taskRef: number, run: number | null, now: Date, windowMs: number, notification?: number | null): ChatBatch;
|
|
7531
|
+
chatBatch(id: number): ChatBatch | null;
|
|
7532
|
+
setChatBatchMessage(id: number, message: string, digest: string): void;
|
|
7495
7533
|
/** The message now showing this card, and what it shows. */
|
|
7496
7534
|
setChatCardMessage(id: number, message: string, digest: string): void;
|
|
7497
7535
|
markTelegramDigestSent(now: Date): void;
|