toolroll 0.9.9 → 0.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/assignment.js +3 -1
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +45 -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.js +10 -1
- package/dist/project-delete.js +1 -0
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +30 -0
- package/dist/slack-chat.js +2 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.js +82 -1
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-progress.d.ts +6 -1
- package/dist/telegram-progress.js +11 -4
- package/dist/telegram-status.d.ts +2 -1
- package/dist/telegram-status.js +3 -2
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +93 -43
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.10 — 2026-10-02
|
|
4
|
+
|
|
5
|
+
- **Chat reads like a teammate.** Short messages, outcome first, with real
|
|
6
|
+
choices: Merge or Mark complete, and Look first. Updates within two minutes
|
|
7
|
+
arrive as one message. You're never told about your own actions, release
|
|
8
|
+
checks and replaced tasks stay quiet, and a release sends one "is live" line.
|
|
9
|
+
The Telegram bot is named Toolroll.
|
|
10
|
+
|
|
3
11
|
## 0.9.9 — 2026-10-02
|
|
4
12
|
|
|
5
13
|
- **Faster, lighter tests.** The unit suite runs about three times faster
|
package/dist/assignment.js
CHANGED
|
@@ -409,8 +409,10 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
|
|
|
409
409
|
store.recordAction({ at: now.toISOString(), actor, repo: current.repo,
|
|
410
410
|
taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work" });
|
|
411
411
|
const ref = store.lookupRef(current.rootId);
|
|
412
|
+
// Whose act this is, even outside a command that named its person: the operator who marked it.
|
|
413
|
+
const by = currentActor() ?? (actor.startsWith("operator:") ? { account: actor.slice("operator:".length), lead: false } : null);
|
|
412
414
|
if (ref !== null)
|
|
413
|
-
store.noteTaskAct(ref.id, "completed", now);
|
|
415
|
+
store.noteTaskAct(ref.id, "completed", now, undefined, by);
|
|
414
416
|
store.bumpWake();
|
|
415
417
|
}
|
|
416
418
|
const assignment = assignmentOf(store, current.rootId, now, access, root);
|
|
@@ -76,6 +76,11 @@ export type ChatContent = {
|
|
|
76
76
|
label: string;
|
|
77
77
|
path: string;
|
|
78
78
|
};
|
|
79
|
+
/** More link buttons after `link`: [Look first] beside [Merge] or [Mark complete]. */
|
|
80
|
+
also?: Array<{
|
|
81
|
+
label: string;
|
|
82
|
+
path: string;
|
|
83
|
+
}>;
|
|
79
84
|
/** A flow decision's buttons ride this part (v88): minted when it is planned. */
|
|
80
85
|
flow?: {
|
|
81
86
|
card: number;
|
package/dist/chat-delivery.js
CHANGED
|
@@ -9,7 +9,8 @@ import { answerChatFlowPrompt, applyChatFlowTap, flowDecisionParts } from "./cha
|
|
|
9
9
|
import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
|
|
10
10
|
import { triggerConfigOf } from "./flow-triggers.js";
|
|
11
11
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
12
|
-
import { enqueueEveningDigests, isTaskFact, needsPerson, quietCardView
|
|
12
|
+
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
13
|
+
import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
|
|
13
14
|
import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
|
|
14
15
|
import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
|
|
15
16
|
import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
|
|
@@ -582,7 +583,7 @@ function planQuietCard(options, binding, notification, now) {
|
|
|
582
583
|
if (notification.taskRef === null || notification.taskId === null)
|
|
583
584
|
return;
|
|
584
585
|
const card = store.chatCardFor(`${state.channel}:${binding.id}`, notification.taskRef, store.getTask(notification.taskId)?.createdAt ?? notification.createdAt, now);
|
|
585
|
-
const view = quietCardView(store, card.tasks, now, options.evidenceRoot);
|
|
586
|
+
const view = quietCardView(store, card.tasks, now, options.evidenceRoot, binding.approver);
|
|
586
587
|
if (view === null)
|
|
587
588
|
return;
|
|
588
589
|
const content = { text: view.text, link: view.link, ...(card.tasks.length === 1 ? { task: notification.taskId } : {}) };
|
|
@@ -602,6 +603,37 @@ function planQuietCard(options, binding, notification, now) {
|
|
|
602
603
|
if (part !== undefined)
|
|
603
604
|
store.setChatCardMessage(card.id, String(part.id), shown);
|
|
604
605
|
}
|
|
606
|
+
/** Any update for this person joins their open batch (two minutes from its first), and the batch's one message
|
|
607
|
+
* is planned once, then repainted in place as more updates land. `notification`: an update that is not a finished
|
|
608
|
+
* result (null for one that is). False when nothing in the batch reads as a line, so the update goes out on its own. */
|
|
609
|
+
function planFinished(options, binding, taskRef, run, now, notification = null) {
|
|
610
|
+
const { state, store, identity } = options;
|
|
611
|
+
const batch = store.chatBatchFor(`${state.channel}:${binding.id}`, taskRef, run, now, BATCH_MS, notification);
|
|
612
|
+
const view = finishedView(store, batch, now, options.evidenceRoot, binding.approver);
|
|
613
|
+
if (view === null)
|
|
614
|
+
return false;
|
|
615
|
+
// A single update keeps its task and run, so a reply to it names that work.
|
|
616
|
+
const single = batch.items.length === 1 ? batch.items[0] : null;
|
|
617
|
+
const task = single === null ? null : store.refById(single.taskRef)?.externalId ?? null;
|
|
618
|
+
const content = { text: view.text, link: view.link, ...(view.also === undefined ? {} : { also: view.also }),
|
|
619
|
+
...(task === null ? {} : { task }), ...(single?.run == null ? {} : { run: single.run }) };
|
|
620
|
+
const shown = chatHash(JSON.stringify(content));
|
|
621
|
+
if (batch.message !== null) {
|
|
622
|
+
if (batch.digest === shown)
|
|
623
|
+
return true;
|
|
624
|
+
state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(JSON.stringify(content), now.toISOString(), Number(batch.message));
|
|
625
|
+
store.setChatBatchMessage(batch.id, batch.message, shown);
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
const id = chatHash(`${state.channel}:batch:${binding.id}:${batch.id}`);
|
|
629
|
+
state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
|
|
630
|
+
ts: "", thread: "", payload: "{}", created: now.toISOString() });
|
|
631
|
+
state.plan(id, [content], now);
|
|
632
|
+
const part = state.prepare("SELECT id FROM chat_part WHERE event=?").get(id);
|
|
633
|
+
if (part !== undefined)
|
|
634
|
+
store.setChatBatchMessage(batch.id, String(part.id), shown);
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
605
637
|
/** One progress card per exact result; separate urgent facts retain their own review link. */
|
|
606
638
|
export async function planChatNotifications(options) {
|
|
607
639
|
const state = options.state, { store, identity } = options, bindings = state.bindings(identity.installation).filter(one => state.live(one));
|
|
@@ -645,15 +677,19 @@ export async function planChatNotifications(options) {
|
|
|
645
677
|
if (!needsPerson(notification))
|
|
646
678
|
return;
|
|
647
679
|
if (notification.kind === "run-finished" && run) {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
state.plan(id, [{ text: ping.text, task: notification.taskId, run: run.id, link: ping.link }], now);
|
|
680
|
+
// Finished work goes out only as the batch line; one left out on purpose (a release check, a replaced
|
|
681
|
+
// task, the reader's own completion) stays quiet rather than falling through to another message.
|
|
682
|
+
planFinished(options, binding, notification.taskRef, run.id, now);
|
|
652
683
|
return;
|
|
653
684
|
}
|
|
685
|
+
// Every other update for this person within two minutes joins the same one message.
|
|
686
|
+
else if (notification.kind !== "run-finished" && joinsBatch(notification)) {
|
|
687
|
+
if (planFinished(options, binding, notification.taskRef, run?.id ?? null, now, notification.id))
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
654
690
|
}
|
|
655
691
|
if (!quiet && run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
|
|
656
|
-
const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot);
|
|
692
|
+
const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot, binding.approver);
|
|
657
693
|
const content = {
|
|
658
694
|
text: card.text,
|
|
659
695
|
task: notification.taskId,
|
|
@@ -726,7 +762,8 @@ export async function planChatNotifications(options) {
|
|
|
726
762
|
});
|
|
727
763
|
state.plan(id, [
|
|
728
764
|
{
|
|
729
|
-
|
|
765
|
+
// Never the task's id or a "— revision" suffix (chat-voice.ts).
|
|
766
|
+
text: chatText(phoneText(notification.body === "" ? notification.subject : `${notification.subject}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
|
|
730
767
|
...(notification.taskId ? { task: notification.taskId } : {}),
|
|
731
768
|
...(run ? { run: run.id } : {}),
|
|
732
769
|
...(notification.link
|
package/dist/chat-quiet.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type ProgressEntity } from "./telegram-progress.js";
|
|
2
2
|
import { type PhoneTaskLink } from "./telegram-status.js";
|
|
3
|
-
import { type
|
|
3
|
+
import { type ChatBatch, type Notification, type Store } from "./store.js";
|
|
4
|
+
/** `also`: more buttons after `link`, such as [Look first] beside [Merge]. */
|
|
4
5
|
export type QuietView = {
|
|
5
6
|
text: string;
|
|
6
7
|
entities: ProgressEntity[];
|
|
7
8
|
link: PhoneTaskLink;
|
|
9
|
+
also?: PhoneTaskLink[];
|
|
8
10
|
};
|
|
9
11
|
export declare function needsPerson(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient">): boolean;
|
|
10
12
|
/** A fact about one placed task: in quiet mode it lands on that task's card. */
|
|
@@ -14,9 +16,13 @@ export declare function isTaskFact(row: Pick<Notification, "taskRef" | "taskId"
|
|
|
14
16
|
project: string;
|
|
15
17
|
};
|
|
16
18
|
/** The card's words now: one task's progress card, or one line per task when several were filed together. */
|
|
17
|
-
export declare function quietCardView(store: Store, taskRefs: readonly number[], now: Date, root?: string): QuietView | null;
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
export declare function quietCardView(store: Store, taskRefs: readonly number[], now: Date, root?: string, viewer?: string): QuietView | null;
|
|
20
|
+
/** Whether this update for a person joins their two-minute batch: anything about one task that needs them,
|
|
21
|
+
* except a fact that must stand alone. */
|
|
22
|
+
export declare function joinsBatch(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient" | "taskRef">): boolean;
|
|
23
|
+
/** One person's update message: one task says what happened with its next step; several updates within two
|
|
24
|
+
* minutes become "4 tasks finished: …" (or "3 updates: …") with one Open button. Null when none can be read. */
|
|
25
|
+
export declare function finishedView(store: Store, batch: Pick<ChatBatch, "items">, now: Date, root?: string, viewer?: string): QuietView | null;
|
|
20
26
|
/** This computer's local day and time, which is what a person means by "the evening". */
|
|
21
27
|
export declare function localDay(now: Date): string;
|
|
22
28
|
export declare function localTime(now: Date): string;
|
package/dist/chat-quiet.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* Delivery receipts, claims and retries stay with each transport; the
|
|
6
6
|
* console's activity keeps every fact whatever a chat showed. */
|
|
7
7
|
import { assignmentOf } from "./assignment.js";
|
|
8
|
-
import { chatControlHref } from "./chat-controls.js";
|
|
8
|
+
import { chatControlHref, chatResultHref } from "./chat-controls.js";
|
|
9
9
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
10
|
-
import { assignmentStatusFacts, headlineEmoji,
|
|
10
|
+
import { assignmentStatusFacts, headlineEmoji, taskStatusOf } from "./task-status.js";
|
|
11
|
+
import { asksToFinish, batchLine, chatText, chatTitle, factLinkLabel, mentions, READY_ACTIONS } from "./chat-voice.js";
|
|
11
12
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
12
13
|
import { isLifecycleNotification } from "./store.js";
|
|
13
14
|
/** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
|
|
@@ -49,25 +50,27 @@ const BEFORE_RUN = {
|
|
|
49
50
|
"task-requeued": before("Queued"),
|
|
50
51
|
"task-cancelled": before("Stopped"),
|
|
51
52
|
};
|
|
52
|
-
function taskLine(store, taskRef, now, root) {
|
|
53
|
+
function taskLine(store, taskRef, now, root, viewer) {
|
|
53
54
|
const ref = store.refById(taskRef);
|
|
54
55
|
if (ref === null || ref.repo === null)
|
|
55
56
|
return null;
|
|
56
57
|
const task = store.getTask(ref.externalId);
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// A short human title: never "— revision" or an id (chat-voice.ts).
|
|
59
|
+
const title = chatTitle(store, ref.externalId);
|
|
60
|
+
// Replaced, never "Cancelled": the card says a newer task took over and links to it, without either id.
|
|
59
61
|
const successor = task?.state === "cancelled" ? store.replacementOf(ref.externalId) : null;
|
|
60
62
|
if (successor !== null) {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
63
|
+
const words = "Replaced by a newer task";
|
|
64
|
+
const heading = `${headlineEmoji("Stopped")} ${words}`;
|
|
65
|
+
const text = [title, heading, "", projectLabel(ref.repo)].join("\n");
|
|
66
|
+
return { title, icon: headlineEmoji("Stopped"), status: words, project: ref.repo, view: {
|
|
64
67
|
text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
65
|
-
link: { label:
|
|
68
|
+
link: { label: "Open the new task", path: chatControlHref("task", successor) },
|
|
66
69
|
} };
|
|
67
70
|
}
|
|
68
71
|
const run = cardRun(store, taskRef);
|
|
69
72
|
if (run !== null) {
|
|
70
|
-
const view = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root);
|
|
73
|
+
const view = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root, viewer);
|
|
71
74
|
const heading = view.entities[1] === undefined ? "" : view.text.slice(view.entities[1].offset, view.entities[1].offset + view.entities[1].length);
|
|
72
75
|
const split = heading.indexOf(" ");
|
|
73
76
|
return { title, icon: heading.slice(0, split), status: heading.slice(split + 1), project: ref.repo, view };
|
|
@@ -75,15 +78,15 @@ function taskLine(store, taskRef, now, root) {
|
|
|
75
78
|
const fact = store.latestTaskFact(taskRef);
|
|
76
79
|
const words = task?.state === "cancelled" ? BEFORE_RUN["task-cancelled"] : BEFORE_RUN[fact?.kind ?? ""] ?? BEFORE_RUN["task-filed"];
|
|
77
80
|
const heading = `${words.icon} ${words.status}`;
|
|
78
|
-
const text = [title, heading, "",
|
|
81
|
+
const text = [title, heading, "", projectLabel(ref.repo)].join("\n");
|
|
79
82
|
return { title, icon: words.icon, status: words.status, project: ref.repo, view: {
|
|
80
83
|
text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
81
84
|
link: { label: "Open task", path: chatControlHref("task", ref.externalId) },
|
|
82
85
|
} };
|
|
83
86
|
}
|
|
84
87
|
/** The card's words now: one task's progress card, or one line per task when several were filed together. */
|
|
85
|
-
export function quietCardView(store, taskRefs, now, root) {
|
|
86
|
-
const lines = taskRefs.map(ref => taskLine(store, ref, now, root)).filter((one) => one !== null);
|
|
88
|
+
export function quietCardView(store, taskRefs, now, root, viewer) {
|
|
89
|
+
const lines = taskRefs.map(ref => taskLine(store, ref, now, root, viewer)).filter((one) => one !== null);
|
|
87
90
|
if (lines.length === 0)
|
|
88
91
|
return null;
|
|
89
92
|
if (lines.length === 1)
|
|
@@ -93,14 +96,76 @@ export function quietCardView(store, taskRefs, now, root) {
|
|
|
93
96
|
const text = [heading, ...lines.map(one => `${one.icon} ${phoneText(one.title, 60)} · ${one.status}`), "", projects.join(", ")].join("\n");
|
|
94
97
|
return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/tasks" } };
|
|
95
98
|
}
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
/** Facts that carry their own controls or must stand alone (a security alert, a decision, a flow card, a
|
|
100
|
+
* screenshot, an acceptance packet) are never folded into a batch. */
|
|
101
|
+
const OWN_MESSAGE_KINDS = new Set(["security-release", "secret-detected", "flow-decision", "flow-card", "acceptance-ready", "acceptance-evidence"]);
|
|
102
|
+
/** Whether this update for a person joins their two-minute batch: anything about one task that needs them,
|
|
103
|
+
* except a fact that must stand alone. */
|
|
104
|
+
export function joinsBatch(row) {
|
|
105
|
+
return row.taskRef !== null && row.recipient === null && needsPerson(row) && !OWN_MESSAGE_KINDS.has(row.kind) && !/^decision:\d+$/.test(row.dedupeKey);
|
|
106
|
+
}
|
|
107
|
+
/** A few words for an update in a list, and how it counts: "has a plan to review", "failed". */
|
|
108
|
+
function updatePhrase(row) {
|
|
109
|
+
if (row.kind === "plan-ready")
|
|
110
|
+
return { phrase: "has a plan to review", headline: "Needs you" };
|
|
111
|
+
if (row.kind === "report-ready")
|
|
112
|
+
return { phrase: "has a report to read", headline: "Update" };
|
|
113
|
+
if (row.kind === "stale-approval" || row.kind === "approval-withdrawn")
|
|
114
|
+
return { phrase: "needs a fresh approval", headline: "Needs you" };
|
|
115
|
+
if (row.pushClass === "merge" || row.kind === "pull-request-ready")
|
|
116
|
+
return { phrase: "is ready to merge", headline: "Update" };
|
|
117
|
+
if (/fail|exhausted|stalled|fenced/.test(row.kind))
|
|
118
|
+
return { phrase: "failed", headline: "Failed" };
|
|
119
|
+
return { phrase: "needs you", headline: "Needs you" };
|
|
120
|
+
}
|
|
121
|
+
/** One task's line in a batch: a finished result in a person's words with its real next step, or another
|
|
122
|
+
* update in its own words with its own link. */
|
|
123
|
+
function lineOf(store, item, now, root, viewer) {
|
|
124
|
+
const ref = store.refById(item.taskRef);
|
|
125
|
+
if (ref === null || ref.repo === null)
|
|
126
|
+
return null;
|
|
127
|
+
const summary = chatTitle(store, ref.externalId);
|
|
128
|
+
const row = item.notification === null ? null : store.notificationById(item.notification);
|
|
129
|
+
if (row !== null && row.kind !== "run-finished") {
|
|
130
|
+
// Never the task's id or a "— revision" suffix; the title once, in front unless the words already name it.
|
|
131
|
+
const words = chatText(phoneText(row.body === "" ? row.subject : `${row.subject}\n\n${row.body}`, 2500), [{ id: ref.externalId, title: summary }]);
|
|
132
|
+
const { phrase, headline } = updatePhrase(row);
|
|
133
|
+
return { also: [], link: row.link === null ? { label: "Open task", path: chatControlHref("task", ref.externalId) } : { label: factLinkLabel(row.link), path: row.link },
|
|
134
|
+
fact: { summary, headline, checks: null, report: false, completedBy: null, update: { words: mentions(words, summary) ? words : `${summary} · ${words}`, phrase } } };
|
|
135
|
+
}
|
|
136
|
+
const run = item.run === null ? null : store.getRun(item.run);
|
|
137
|
+
if (run === null)
|
|
138
|
+
return null;
|
|
139
|
+
const card = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root, viewer);
|
|
140
|
+
const checks = card.facts.checks ?? null;
|
|
141
|
+
const pullRequest = card.facts.pullRequest?.state === "open";
|
|
142
|
+
const fact = {
|
|
143
|
+
summary,
|
|
144
|
+
headline: card.status.headline,
|
|
145
|
+
checks: checks === null ? null : checks.level === "off" && checks.status !== "passed" ? "off"
|
|
146
|
+
: checks.status === "passed" || checks.status === "failed" || checks.status === "not-run" ? checks.status : null,
|
|
147
|
+
report: card.facts.report === true,
|
|
148
|
+
completedBy: card.facts.completedBy ?? null,
|
|
149
|
+
pullRequest,
|
|
150
|
+
// The lead speaks as "I" for what it did: the work it filed or approved, or the completion it recorded.
|
|
151
|
+
lead: card.status.headline === "Complete" ? card.completedByLead : store.leadWorkOf(item.taskRef) !== null,
|
|
152
|
+
};
|
|
153
|
+
// A result ready for a person offers its real next step, then a look first.
|
|
154
|
+
if (asksToFinish(fact))
|
|
155
|
+
return { fact, also: [{ label: READY_ACTIONS.look, path: chatResultHref(ref.externalId, run.id, "changes") }],
|
|
156
|
+
link: pullRequest ? { label: READY_ACTIONS.merge, path: `/t/${encodeURIComponent(ref.externalId)}#merge` } : { label: READY_ACTIONS.complete, path: chatResultHref(ref.externalId, run.id) } };
|
|
157
|
+
return { fact, link: card.link, also: [] };
|
|
158
|
+
}
|
|
159
|
+
/** One person's update message: one task says what happened with its next step; several updates within two
|
|
160
|
+
* minutes become "4 tasks finished: …" (or "3 updates: …") with one Open button. Null when none can be read. */
|
|
161
|
+
export function finishedView(store, batch, now, root, viewer) {
|
|
162
|
+
const lines = batch.items.map(one => lineOf(store, one, now, root, viewer)).filter((one) => one !== null);
|
|
163
|
+
if (lines.length === 0)
|
|
164
|
+
return null;
|
|
165
|
+
const text = batchLine(lines.map(one => one.fact));
|
|
166
|
+
if (lines.length > 1)
|
|
167
|
+
return { text, entities: [], link: { label: "Open", path: "/tasks" } };
|
|
168
|
+
return { text, entities: [], link: lines[0].link, ...(lines[0].also.length === 0 ? {} : { also: lines[0].also }) };
|
|
104
169
|
}
|
|
105
170
|
// ---- the evening digest ----------------------------------------------------------
|
|
106
171
|
const FAILURE = /fail|exhausted|fenced/;
|
|
@@ -118,7 +183,7 @@ export function eveningDigestText(store, account, now, root) {
|
|
|
118
183
|
const since = new Date(now.getTime() - 86_400_000).toISOString();
|
|
119
184
|
const recent = new Date(now.getTime() - 14 * 86_400_000).toISOString();
|
|
120
185
|
const visible = (row) => row.project !== null && store.accountCanAccess(account, row.project);
|
|
121
|
-
const title = (taskId) =>
|
|
186
|
+
const title = (taskId) => chatTitle(store, taskId);
|
|
122
187
|
const finished = new Map(), failed = new Map(), waits = new Map();
|
|
123
188
|
const facts = store.taskFactsSince(recent).filter(visible);
|
|
124
189
|
for (const row of facts) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** How chat sounds: a teammate talking. One or two short sentences, outcome
|
|
2
|
+
* first, plain words, one action at most. Every chat (Telegram, Slack,
|
|
3
|
+
* Discord, Teams) takes its titles and its finished-work lines from here, so a
|
|
4
|
+
* task id, a "— revision" suffix or a person's own act never reaches a chat.
|
|
5
|
+
* The console keeps every exact name and fact. */
|
|
6
|
+
import type { TelegramTransport } from "./telegram.js";
|
|
7
|
+
import type { Store } from "./store.js";
|
|
8
|
+
/** The bot's display name in every chat app. */
|
|
9
|
+
export declare const BOT_NAME = "Toolroll";
|
|
10
|
+
/** Updates for one person landing within this window become one message, edited in place as it grows. */
|
|
11
|
+
export declare const BATCH_MS: number;
|
|
12
|
+
export declare const SHORT_TITLE_MAX = 60;
|
|
13
|
+
/** A short human summary of a task's title, at most 60 characters: no "— revision", and no task ids. Only the
|
|
14
|
+
* task's own id and tokens `isTaskId` names as real tasks are dropped; `node-20` and `utf-8` stay. */
|
|
15
|
+
export declare function shortTitle(title: string | null | undefined, taskId?: string | null, isTaskId?: (token: string) => boolean): string;
|
|
16
|
+
/** A task's title as every chat says it: short, with its own id and any other real task's id dropped. */
|
|
17
|
+
export declare function chatTitle(store: Pick<Store, "getTask">, taskId: string): string;
|
|
18
|
+
/** A title inside a sentence or list: "faster tests", but "API keys" and "iOS" keep their capitals. */
|
|
19
|
+
export declare function inSentence(summary: string): string;
|
|
20
|
+
/** The facts one line of a chat update is made of, read from the shared task status. */
|
|
21
|
+
export type FinishedFact = {
|
|
22
|
+
summary: string;
|
|
23
|
+
headline: string;
|
|
24
|
+
checks: "passed" | "failed" | "not-run" | "off" | null;
|
|
25
|
+
report: boolean;
|
|
26
|
+
completedBy: string | null;
|
|
27
|
+
/** An open pull request carries this result: the ask is "Merge it?" rather than "Mark it complete?". */
|
|
28
|
+
pullRequest?: boolean;
|
|
29
|
+
/** The lead did this (built it, or marked it complete), so the lead says "I". */
|
|
30
|
+
lead?: boolean;
|
|
31
|
+
/** Not a finished result but another update about the task: its own words when it stands alone, and a few
|
|
32
|
+
* words for a list ("has a plan to review"). */
|
|
33
|
+
update?: {
|
|
34
|
+
words: string;
|
|
35
|
+
phrase: string;
|
|
36
|
+
} | null;
|
|
37
|
+
};
|
|
38
|
+
/** The two buttons under a result ready for a person: the real next step, then a look first. */
|
|
39
|
+
export declare const READY_ACTIONS: {
|
|
40
|
+
readonly merge: "Merge";
|
|
41
|
+
readonly complete: "Mark complete";
|
|
42
|
+
readonly look: "Look first";
|
|
43
|
+
};
|
|
44
|
+
/** Whether this line asks for the result's next step, so it carries [Merge] or [Mark complete] and [Look first]. */
|
|
45
|
+
export declare function asksToFinish(fact: FinishedFact): boolean;
|
|
46
|
+
/** One update, said plainly: what happened, then what happens next. The lead says "I" for what it did. */
|
|
47
|
+
export declare function finishedLine(fact: FinishedFact): string;
|
|
48
|
+
/** Several updates in one message: "4 tasks finished: faster tests, cleanup, …" (or "3 updates: …" when not all of
|
|
49
|
+
* them are finished work), then what broke and what happens next. */
|
|
50
|
+
export declare function batchLine(facts: readonly FinishedFact[]): string;
|
|
51
|
+
/**
|
|
52
|
+
* Name the Telegram bot Toolroll. On pairing it always becomes Toolroll; on upgrade only a bot still called
|
|
53
|
+
* StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
|
|
54
|
+
* leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
|
|
55
|
+
*/
|
|
56
|
+
export declare function nameTelegramBot(transport: TelegramTransport, when: "pairing" | "upgrade"): Promise<boolean>;
|
|
57
|
+
/** The last word on any pushed chat text: a task's own id (bare or in brackets), a "— revision" suffix and a
|
|
58
|
+
* "Replaced by <id>" never reach a chat, whatever words the fact was recorded with. A bare id reads as the
|
|
59
|
+
* task's short title when one is given, else "this task". */
|
|
60
|
+
export declare function chatText(text: string, tasks?: readonly (string | null | undefined | {
|
|
61
|
+
id: string;
|
|
62
|
+
title?: string;
|
|
63
|
+
})[]): string;
|
|
64
|
+
/** The one button a plain fact may carry: its label names where its machine-minted console path goes; nothing in
|
|
65
|
+
* it comes from the fact's text. */
|
|
66
|
+
export declare function factLinkLabel(path: string): string;
|
|
67
|
+
/** Whether the words already name this title as a whole phrase (so it need not be said twice). */
|
|
68
|
+
export declare function mentions(text: string, title: string): boolean;
|
|
@@ -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.js
CHANGED
|
@@ -198,7 +198,7 @@ import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
|
|
|
198
198
|
import { beads } from "./beads.js";
|
|
199
199
|
import { githubIssues } from "./issues.js";
|
|
200
200
|
import { installationStatus, renderInstallationStatus, renderTaskWait, repairStaleStatuses, taskWaitSnapshot } from "./lead-status.js";
|
|
201
|
-
import { latestRelease, newerRelease, notifySecurityRelease, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
|
|
201
|
+
import { latestRelease, newerRelease, notifySecurityRelease, notifyVersionLive, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
|
|
202
202
|
import { installMethod } from "./install-method.js";
|
|
203
203
|
import { PACKAGE_VERSION } from "./version.js";
|
|
204
204
|
/**
|
|
@@ -7324,6 +7324,11 @@ async function runWatchLoop(args) {
|
|
|
7324
7324
|
// attributes to this episode by runner and window, and `brief
|
|
7325
7325
|
// --latest-watch` bounds itself to exactly it.
|
|
7326
7326
|
store.startWatchEpisode({ repo, runner, incarnation }, new Date());
|
|
7327
|
+
// A deploy's one chat line, "Toolroll <version> is live.", when this worker last ran an older version.
|
|
7328
|
+
try {
|
|
7329
|
+
notifyVersionLive(store, dirname(context.databaseFile), PACKAGE_VERSION, new Date());
|
|
7330
|
+
}
|
|
7331
|
+
catch { /* A display fact; never blocks the worker. */ }
|
|
7327
7332
|
recordRunnerVersion(dirname(context.databaseFile), runner, PACKAGE_VERSION, new Date());
|
|
7328
7333
|
args.onReady?.();
|
|
7329
7334
|
// Once per worker start, before any pass: statuses an older build left
|
|
@@ -8613,6 +8618,10 @@ async function upCommand(flags, context) {
|
|
|
8613
8618
|
});
|
|
8614
8619
|
};
|
|
8615
8620
|
observeReadiness();
|
|
8621
|
+
try {
|
|
8622
|
+
notifyVersionLive(context.store, dirname(context.databaseFile), PACKAGE_VERSION, clock());
|
|
8623
|
+
}
|
|
8624
|
+
catch { /* A display fact; never blocks the worker. */ }
|
|
8616
8625
|
recordRunnerVersion(dirname(context.databaseFile), runnerName, PACKAGE_VERSION, clock());
|
|
8617
8626
|
const runnerHeartbeat = setInterval(() => {
|
|
8618
8627
|
const beat = heartbeatRunner(store, runnerName, runnerToken, clock());
|
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/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: {
|
|
@@ -7477,6 +7490,12 @@ export declare class Store {
|
|
|
7477
7490
|
* and the evening digest keep every fact either way.
|
|
7478
7491
|
*/
|
|
7479
7492
|
pingAllowed(row: Pick<Notification, "id" | "kind" | "recipient" | "project" | "taskRef">, account: string): boolean;
|
|
7493
|
+
/** Who last completed or cancelled this task, when that is still its latest act: null otherwise. A revision's
|
|
7494
|
+
* completion is recorded on its root, so an act on a task it revises counts too, but only one made after this
|
|
7495
|
+
* revision was filed: settling older work never silences a newer revision, whose failures still reach people. */
|
|
7496
|
+
settledBy(taskRef: number): string | null;
|
|
7497
|
+
/** A release check, a replaced task, or one a revision superseded: its facts stay in the console. */
|
|
7498
|
+
neverPings(taskRef: number): boolean;
|
|
7480
7499
|
/** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
|
|
7481
7500
|
leadQuiet(row: Pick<Notification, "id" | "kind" | "taskRef">, actor?: Actor | null): boolean;
|
|
7482
7501
|
/** A failure with nothing left for the lead to do: its attempts are spent, or the task has failed for good. */
|
|
@@ -7492,6 +7511,11 @@ export declare class Store {
|
|
|
7492
7511
|
} | null;
|
|
7493
7512
|
/** Task-bound facts recorded since an instant, newest last (bounded): what an evening digest reads. */
|
|
7494
7513
|
taskFactsSince(since: string, limit?: number): Notification[];
|
|
7514
|
+
/** The one message this update joins: the destination's batch opened within `windowMs`, or a new one. A task
|
|
7515
|
+
* has one line in it, saying its newest update (`notification`; null for a finished result). */
|
|
7516
|
+
chatBatchFor(destination: string, taskRef: number, run: number | null, now: Date, windowMs: number, notification?: number | null): ChatBatch;
|
|
7517
|
+
chatBatch(id: number): ChatBatch | null;
|
|
7518
|
+
setChatBatchMessage(id: number, message: string, digest: string): void;
|
|
7495
7519
|
/** The message now showing this card, and what it shows. */
|
|
7496
7520
|
setChatCardMessage(id: number, message: string, digest: string): void;
|
|
7497
7521
|
markTelegramDigestSent(now: Date): void;
|
package/dist/store.js
CHANGED
|
@@ -139,7 +139,8 @@ CREATE TABLE IF NOT EXISTS monitoring_status (
|
|
|
139
139
|
* every step, and an optional evening digest at a local HH:MM. `digest_on` is the local day the last evening
|
|
140
140
|
* digest was considered, so each day is judged once. A task's card is the one chat message a destination keeps
|
|
141
141
|
* for it (or for several tasks filed within a minute): `message` is the Telegram message id or the chat app's
|
|
142
|
-
* part id, `digest` the text last shown, so an unchanged card is never re-sent.
|
|
142
|
+
* part id, `digest` the text last shown, so an unchanged card is never re-sent. A `chat_batch` is one person's
|
|
143
|
+
* finished-work message: every result that lands within two minutes of its first joins it, edited in place. */
|
|
143
144
|
const QUIET_CHAT_SCHEMA = `
|
|
144
145
|
CREATE TABLE IF NOT EXISTS notification_preference (
|
|
145
146
|
account TEXT PRIMARY KEY,
|
|
@@ -166,6 +167,21 @@ CREATE TABLE IF NOT EXISTS chat_card_task (
|
|
|
166
167
|
PRIMARY KEY (destination, task_ref)
|
|
167
168
|
);
|
|
168
169
|
CREATE INDEX IF NOT EXISTS chat_card_task_card ON chat_card_task(card);
|
|
170
|
+
CREATE TABLE IF NOT EXISTS chat_batch (
|
|
171
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
172
|
+
destination TEXT NOT NULL,
|
|
173
|
+
opened_at TEXT NOT NULL,
|
|
174
|
+
message TEXT,
|
|
175
|
+
digest TEXT
|
|
176
|
+
);
|
|
177
|
+
CREATE INDEX IF NOT EXISTS chat_batch_destination ON chat_batch(destination, opened_at);
|
|
178
|
+
CREATE TABLE IF NOT EXISTS chat_batch_item (
|
|
179
|
+
batch INTEGER NOT NULL REFERENCES chat_batch(id),
|
|
180
|
+
task_ref INTEGER NOT NULL,
|
|
181
|
+
run INTEGER,
|
|
182
|
+
joined_at TEXT NOT NULL,
|
|
183
|
+
PRIMARY KEY (batch, task_ref)
|
|
184
|
+
);
|
|
169
185
|
`;
|
|
170
186
|
/** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
|
|
171
187
|
* for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
|
|
@@ -3826,6 +3842,8 @@ function initializeStore(db, file) {
|
|
|
3826
3842
|
db.exec(POLICY_SCHEMA);
|
|
3827
3843
|
db.exec(RUN_CHECK_SCHEMA);
|
|
3828
3844
|
db.exec(QUIET_CHAT_SCHEMA);
|
|
3845
|
+
// The update a batch line speaks for: its newest fact about that task (null: a finished result).
|
|
3846
|
+
addColumn(db, "chat_batch_item", "notification", "INTEGER");
|
|
3829
3847
|
db.exec(LEAD_QUIET_SCHEMA);
|
|
3830
3848
|
mergeCheckTables(db);
|
|
3831
3849
|
addColumn(db, "monitoring_status", "target", "TEXT");
|
|
@@ -15092,6 +15110,10 @@ export class Store {
|
|
|
15092
15110
|
.run(now.toISOString(), head.length, head);
|
|
15093
15111
|
return Number(changes);
|
|
15094
15112
|
}
|
|
15113
|
+
notificationById(id) {
|
|
15114
|
+
const row = this.db.prepare("SELECT * FROM notification WHERE id = ?").get(id);
|
|
15115
|
+
return row === undefined ? null : readNotification(row);
|
|
15116
|
+
}
|
|
15095
15117
|
notificationsAfter(id, limit = 100) {
|
|
15096
15118
|
return this.db.prepare("SELECT * FROM notification WHERE id>? ORDER BY id LIMIT ?").all(id, Math.max(1, Math.min(100, limit))).map(readNotification);
|
|
15097
15119
|
}
|
|
@@ -22197,6 +22219,7 @@ export class Store {
|
|
|
22197
22219
|
* and the evening digest keep every fact either way.
|
|
22198
22220
|
*/
|
|
22199
22221
|
pingAllowed(row, account) {
|
|
22222
|
+
// A security alert always pings, ahead of every rule below: own acts, settled, replaced or release-check tasks.
|
|
22200
22223
|
if (SECURITY_KINDS.has(row.kind) || row.kind === "evening-digest")
|
|
22201
22224
|
return true;
|
|
22202
22225
|
if (row.recipient !== null && row.kind === "task-ask")
|
|
@@ -22206,10 +22229,42 @@ export class Store {
|
|
|
22206
22229
|
return false;
|
|
22207
22230
|
if (row.recipient !== null)
|
|
22208
22231
|
return true;
|
|
22232
|
+
// A task this person (or their lead) completed or cancelled is settled for them: nothing more about it.
|
|
22233
|
+
// Release checks and replaced or superseded tasks never ping anyone; a deploy says "is live" instead.
|
|
22234
|
+
if (row.taskRef !== null && (this.settledBy(row.taskRef) === account || this.neverPings(row.taskRef)))
|
|
22235
|
+
return false;
|
|
22209
22236
|
if (this.leadQuiet(row, actor))
|
|
22210
22237
|
return false;
|
|
22211
22238
|
return row.project === null || this.db.prepare("SELECT 1 AS hit FROM project_mute WHERE account = ? AND repo = ?").get(account, row.project) === undefined;
|
|
22212
22239
|
}
|
|
22240
|
+
/** Who last completed or cancelled this task, when that is still its latest act: null otherwise. A revision's
|
|
22241
|
+
* completion is recorded on its root, so an act on a task it revises counts too, but only one made after this
|
|
22242
|
+
* revision was filed: settling older work never silences a newer revision, whose failures still reach people. */
|
|
22243
|
+
settledBy(taskRef) {
|
|
22244
|
+
const ancestors = [];
|
|
22245
|
+
for (let at = 0, from = taskRef; at < 20; at++) {
|
|
22246
|
+
const parent = this.db.prepare(`SELECT p.id FROM task_ref c JOIN task_ref p ON p.backend = c.backend AND p.external_id = c.revision_of AND p.repo IS c.repo
|
|
22247
|
+
WHERE c.id = ?`).get(from);
|
|
22248
|
+
if (parent === undefined || from === Number(parent["id"]) || ancestors.includes(Number(parent["id"])))
|
|
22249
|
+
break;
|
|
22250
|
+
from = Number(parent["id"]);
|
|
22251
|
+
ancestors.push(from);
|
|
22252
|
+
}
|
|
22253
|
+
const filed = this.db.prepare("SELECT t.created_at FROM task_ref r JOIN task t ON t.id = r.external_id AND r.backend = ? WHERE r.id = ?").get(BUILT_IN, taskRef);
|
|
22254
|
+
const since = filed === undefined || ancestors.length === 0 ? null : String(filed["created_at"]);
|
|
22255
|
+
const last = since === null
|
|
22256
|
+
? this.db.prepare("SELECT act, account FROM task_act WHERE task_ref = ? ORDER BY id DESC LIMIT 1").get(taskRef)
|
|
22257
|
+
: this.db.prepare(`SELECT act, account FROM task_act WHERE task_ref = ? OR (task_ref IN (${ancestors.map(() => "?").join(", ")}) AND at >= ?)
|
|
22258
|
+
ORDER BY id DESC LIMIT 1`).get(taskRef, ...ancestors, since);
|
|
22259
|
+
return last !== undefined && (last["act"] === "completed" || last["act"] === "cancelled") ? String(last["account"]) : null;
|
|
22260
|
+
}
|
|
22261
|
+
/** A release check, a replaced task, or one a revision superseded: its facts stay in the console. */
|
|
22262
|
+
neverPings(taskRef) {
|
|
22263
|
+
return this.db.prepare(`SELECT 1 AS hit FROM task_ref r WHERE r.id = ? AND (
|
|
22264
|
+
EXISTS (SELECT 1 FROM run JOIN run_check rc ON rc.run = run.id WHERE run.task_ref = r.id AND rc.release = 1)
|
|
22265
|
+
OR EXISTS (SELECT 1 FROM task_replacement x WHERE x.task_ref = r.id)
|
|
22266
|
+
OR EXISTS (SELECT 1 FROM task_ref c WHERE c.backend = r.backend AND c.revision_of = r.external_id AND c.repo IS r.repo))`).get(taskRef) !== undefined;
|
|
22267
|
+
}
|
|
22213
22268
|
/** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
|
|
22214
22269
|
leadQuiet(row, actor = this.notificationActor(row.id)) {
|
|
22215
22270
|
if (SECURITY_KINDS.has(row.kind))
|
|
@@ -22262,6 +22317,32 @@ export class Store {
|
|
|
22262
22317
|
return this.db.prepare("SELECT * FROM notification WHERE created_at >= ? AND task_ref IS NOT NULL AND project IS NOT NULL ORDER BY id DESC LIMIT ?")
|
|
22263
22318
|
.all(since, limit).map(readNotification).reverse();
|
|
22264
22319
|
}
|
|
22320
|
+
/** The one message this update joins: the destination's batch opened within `windowMs`, or a new one. A task
|
|
22321
|
+
* has one line in it, saying its newest update (`notification`; null for a finished result). */
|
|
22322
|
+
chatBatchFor(destination, taskRef, run, now, windowMs, notification = null) {
|
|
22323
|
+
return this.transact(() => {
|
|
22324
|
+
const open = this.db.prepare("SELECT id FROM chat_batch WHERE destination = ? AND opened_at > ? AND opened_at <= ? ORDER BY id DESC LIMIT 1")
|
|
22325
|
+
.get(destination, new Date(now.getTime() - windowMs).toISOString(), now.toISOString());
|
|
22326
|
+
const batch = open !== undefined ? Number(open["id"])
|
|
22327
|
+
: Number(this.db.prepare("INSERT INTO chat_batch (destination, opened_at) VALUES (?, ?)").run(destination, now.toISOString()).lastInsertRowid);
|
|
22328
|
+
this.db.prepare(`INSERT INTO chat_batch_item (batch, task_ref, run, joined_at, notification) VALUES (?, ?, ?, ?, ?)
|
|
22329
|
+
ON CONFLICT (batch, task_ref) DO UPDATE SET run = excluded.run, notification = excluded.notification`).run(batch, taskRef, run, now.toISOString(), notification);
|
|
22330
|
+
return this.chatBatch(batch);
|
|
22331
|
+
});
|
|
22332
|
+
}
|
|
22333
|
+
chatBatch(id) {
|
|
22334
|
+
const row = this.db.prepare("SELECT * FROM chat_batch WHERE id = ?").get(id);
|
|
22335
|
+
if (row === undefined)
|
|
22336
|
+
return null;
|
|
22337
|
+
const items = this.db.prepare("SELECT task_ref, run, notification FROM chat_batch_item WHERE batch = ? ORDER BY joined_at, task_ref").all(id)
|
|
22338
|
+
.map(one => ({ taskRef: Number(one["task_ref"]), run: one["run"] == null ? null : Number(one["run"]),
|
|
22339
|
+
notification: one["notification"] == null ? null : Number(one["notification"]) }));
|
|
22340
|
+
return { id, destination: String(row["destination"]), openedAt: String(row["opened_at"]), message: row["message"] == null ? null : String(row["message"]),
|
|
22341
|
+
digest: row["digest"] == null ? null : String(row["digest"]), items };
|
|
22342
|
+
}
|
|
22343
|
+
setChatBatchMessage(id, message, digest) {
|
|
22344
|
+
this.db.prepare("UPDATE chat_batch SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
|
|
22345
|
+
}
|
|
22265
22346
|
/** The message now showing this card, and what it shows. */
|
|
22266
22347
|
setChatCardMessage(id, message, digest) {
|
|
22267
22348
|
this.db.prepare("UPDATE chat_card SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
|
package/dist/teams-chat.js
CHANGED
|
@@ -195,7 +195,7 @@ export async function deliverTeamsPart(options) {
|
|
|
195
195
|
const flow = content.flow ? chatFlowButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label, data: { so: one.token }, ...(one.action === "approve" ? { style: "positive" } : {}) })) : [];
|
|
196
196
|
// A teammate's question (v93): its options, then "Answer in words".
|
|
197
197
|
const asked = content.question ? chatQuestionButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
|
|
198
|
-
actions = [...flow, ...asked, ...openUrlAction(options.origin(), content.link)];
|
|
198
|
+
actions = [...flow, ...asked, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
|
|
199
199
|
}
|
|
200
200
|
const target = content.edit ?? row.message;
|
|
201
201
|
const body = actions.length || content.proposal ? teamsCard(text, actions) : { type: "message", text, textFormat: "plain" };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type PhoneTaskLink } from "./telegram-status.js";
|
|
2
2
|
import type { Run, Store } from "./store.js";
|
|
3
|
+
import { type TaskStatus, type TaskStatusFacts } from "./task-status.js";
|
|
3
4
|
/** Offsets are UTF-16, as required by Telegram. Only our two headings are bold;
|
|
4
5
|
* user text stays literal, including angle brackets and Markdown characters. */
|
|
5
6
|
export type ProgressEntity = {
|
|
@@ -7,9 +8,13 @@ export type ProgressEntity = {
|
|
|
7
8
|
offset: number;
|
|
8
9
|
length: number;
|
|
9
10
|
};
|
|
10
|
-
|
|
11
|
+
/** `viewer`: the person whose chat shows the card. Their own act is never told back to them. */
|
|
12
|
+
export declare function telegramProgressCard(store: Store, run: Run, taskId: string, project: string, now?: Date, root?: string, viewer?: string): {
|
|
11
13
|
text: string;
|
|
12
14
|
entities: ProgressEntity[];
|
|
13
15
|
link: PhoneTaskLink;
|
|
14
16
|
next: string;
|
|
17
|
+
status: TaskStatus;
|
|
18
|
+
facts: TaskStatusFacts;
|
|
19
|
+
completedByLead: boolean;
|
|
15
20
|
};
|
|
@@ -8,6 +8,7 @@ import { assignmentOf } from "./assignment.js";
|
|
|
8
8
|
import { chatResultHref, chatControlHref } from "./chat-controls.js";
|
|
9
9
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
10
10
|
import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, taskStatusOf } from "./task-status.js";
|
|
11
|
+
import { chatTitle } from "./chat-voice.js";
|
|
11
12
|
import { manualReviewOnly } from "./proof.js";
|
|
12
13
|
import { failedCheckExit } from "./workspace-ui.js";
|
|
13
14
|
/** The saved assignment for this exact result, or null when the run is not
|
|
@@ -21,7 +22,8 @@ function assignmentFor(store, run, taskId, project, now, root) {
|
|
|
21
22
|
return null;
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
+
/** `viewer`: the person whose chat shows the card. Their own act is never told back to them. */
|
|
26
|
+
export function telegramProgressCard(store, run, taskId, project, now = new Date(), root, viewer) {
|
|
25
27
|
const built = run.finishedAt !== null && (run.outcome === "built" || run.outcome === "no-change");
|
|
26
28
|
const assignment = built ? assignmentFor(store, run, taskId, project, now, root) : null;
|
|
27
29
|
const publication = store.publicationForRun(run.id);
|
|
@@ -72,20 +74,25 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
|
|
|
72
74
|
else {
|
|
73
75
|
facts = { stage: "failed", reason: "The build stopped before it finished. Inspect the blocker, then retry." };
|
|
74
76
|
}
|
|
77
|
+
// "Marked complete by <you>" (or by your lead, which signs in as you) is never told back to you.
|
|
78
|
+
const completer = assignment?.completion?.actor.replace(/^(?:operator|coordinator|lead):/, "") ?? null;
|
|
79
|
+
if (viewer !== undefined && facts.completedBy != null && (completer === viewer || facts.completedBy === viewer))
|
|
80
|
+
facts = { ...facts, completedBy: null };
|
|
75
81
|
const status = taskStatusOf(facts);
|
|
76
82
|
const checkProgress = store.checkProgress(run.id);
|
|
77
83
|
const progress = status.headline === "Building" && checkProgress !== null ? [`● ${checkProgress.line}`] : [];
|
|
78
|
-
|
|
84
|
+
// A short human title: never "— revision" or an id (chat-voice.ts).
|
|
85
|
+
const title = chatTitle(store, taskId);
|
|
79
86
|
const heading = `${headlineEmoji(status.headline)} ${status.headline}`;
|
|
80
87
|
const next = phoneText(status.sentence, 200);
|
|
81
88
|
// A person's acceptance is its own recorded decision; it never changes a check.
|
|
82
89
|
const acceptance = store.proofAcceptance(run.id) === null ? [] : [manualReviewOnly(store.proofVerdictFor(run.id))
|
|
83
90
|
? "Accepted by a person · Recorded checks unchanged" : "Accepted with an exception · Recorded checks unchanged"];
|
|
84
91
|
const rows = [...statusDetailLines(status), ...progress, ...acceptance];
|
|
85
|
-
const text = [title, heading, next, ...(rows.length === 0 ? [] : ["", ...rows]), "",
|
|
92
|
+
const text = [title, heading, next, ...(rows.length === 0 ? [] : ["", ...rows]), "", projectLabel(project)].join("\n");
|
|
86
93
|
const failedChecks = status.details.some(one => one.key === "checks" && (one.mark === "failed" || one.mark === "note"));
|
|
87
94
|
const recovery = operatorHold !== undefined || accessBlocked || (!built && run.outcome !== null);
|
|
88
|
-
return { text, next, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
95
|
+
return { text, next, status, facts, completedByLead: assignment?.completion?.lead === true, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
89
96
|
// Needs you: the button names the action that resolves it.
|
|
90
97
|
link: { label: status.need != null && status.need.key !== "other" && status.need.key !== "review-result" ? status.need.action.label : built ? "Open result" : "Open task", path: recovery
|
|
91
98
|
? chatControlHref("recovery", taskId) : built ? chatResultHref(taskId, run.id, failedChecks ? "checks" : "summary") : chatControlHref("task", taskId) } };
|
|
@@ -72,4 +72,5 @@ export declare function phoneTaskView(store: Store, repos: readonly string[], id
|
|
|
72
72
|
run: number | null;
|
|
73
73
|
};
|
|
74
74
|
/** Concise display only: authorization uses the stored full project identity. */
|
|
75
|
-
|
|
75
|
+
/** Where a fact belongs: its project and, given one, the task's short title — never the task's id. */
|
|
76
|
+
export declare function notificationIdentity(row: Pick<Notification, "project" | "taskId">, title?: string): string;
|
package/dist/telegram-status.js
CHANGED
|
@@ -271,8 +271,9 @@ export function phoneTaskView(store, repos, id, now) {
|
|
|
271
271
|
});
|
|
272
272
|
}
|
|
273
273
|
/** Concise display only: authorization uses the stored full project identity. */
|
|
274
|
-
|
|
274
|
+
/** Where a fact belongs: its project and, given one, the task's short title — never the task's id. */
|
|
275
|
+
export function notificationIdentity(row, title) {
|
|
275
276
|
if (row.project === null)
|
|
276
277
|
return "";
|
|
277
|
-
return `${projectLabel(row.project)}${row.taskId === null ? "" : ` / ${plain(
|
|
278
|
+
return `${projectLabel(row.project)}${row.taskId === null || title === undefined ? "" : ` / ${plain(title, 64)}`} · `;
|
|
278
279
|
}
|
package/dist/telegram.d.ts
CHANGED
|
@@ -227,4 +227,4 @@ export declare function followBridge(store: Store, options: {
|
|
|
227
227
|
/** The digest text: a header with the count and the window, then one
|
|
228
228
|
* fact per entry — its subject, then its body's first line, indented.
|
|
229
229
|
* Plain text, no buttons: nothing in a digest is tappable. */
|
|
230
|
-
export declare function digestText(rows: readonly Notification[], since: string | null, now: Date): string;
|
|
230
|
+
export declare function digestText(rows: readonly Notification[], since: string | null, now: Date, titleOf?: (taskId: string) => string): string;
|
package/dist/telegram.js
CHANGED
|
@@ -29,7 +29,8 @@ import { join } from "node:path";
|
|
|
29
29
|
import { validateNote } from "./decision.js";
|
|
30
30
|
import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_REASONS } from "./store.js";
|
|
31
31
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
32
|
-
import { enqueueEveningDigests, isTaskFact, needsPerson, quietCardView
|
|
32
|
+
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
33
|
+
import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
|
|
33
34
|
import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
|
|
34
35
|
import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
|
|
35
36
|
import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
|
|
@@ -438,6 +439,8 @@ export async function followBridge(store, options) {
|
|
|
438
439
|
await transport("setMyCommands", { commands: TELEGRAM_COMMANDS });
|
|
439
440
|
}
|
|
440
441
|
catch { /* typed commands still work */ }
|
|
442
|
+
// After an upgrade, a bot still called StandingOrders becomes Toolroll, once per start; any other name is kept.
|
|
443
|
+
await nameTelegramBot(transport, "upgrade");
|
|
441
444
|
}
|
|
442
445
|
const startedAt = Date.now();
|
|
443
446
|
const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
|
|
@@ -514,21 +517,25 @@ async function processConversations(store, botId, transport, owner, clock, repor
|
|
|
514
517
|
report.chatRefused = (report.chatRefused ?? 0) + chat.refused;
|
|
515
518
|
report.problems.push(...chat.problems);
|
|
516
519
|
}
|
|
517
|
-
/**
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
520
|
+
/** A fact's own next-action button row under the trusted origin read now; none without one. */
|
|
521
|
+
function factButton(phoneOrigin, link) {
|
|
522
|
+
try {
|
|
523
|
+
return phoneLinkButton(phoneOrigin?.() ?? null, { label: factLinkLabel(link), path: link });
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** A view's buttons on one row: its link, then any others ([Merge] [Look first]); none without a trusted origin. */
|
|
530
|
+
function viewKeyboard(phoneOrigin, view) {
|
|
531
|
+
try {
|
|
532
|
+
const origin = phoneOrigin?.() ?? null;
|
|
533
|
+
const row = [view.link, ...(view.also ?? [])].flatMap(link => phoneLinkButton(origin, link) ?? []);
|
|
534
|
+
return row.length === 0 ? [] : [row];
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
532
539
|
}
|
|
533
540
|
/** Team-conversation traffic to the chats that follow one — after the outbox, under the same delivery switch, never a problem to raise when nothing follows anything. */
|
|
534
541
|
async function deliverTeam(store, botId, transport, clock, report, readProjects, canDeliver, phoneOrigin) {
|
|
@@ -720,7 +727,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
720
727
|
const messageId = store.telegramProgressMessage(binding, progressRun);
|
|
721
728
|
if (messageId === null && onlyExisting)
|
|
722
729
|
return null;
|
|
723
|
-
const card = telegramProgressCard(store, store.getRun(progressRun.id), row.taskId, row.project, clock(), evidenceRoot);
|
|
730
|
+
const card = telegramProgressCard(store, store.getRun(progressRun.id), row.taskId, row.project, clock(), evidenceRoot, binding.approver);
|
|
724
731
|
let button = null;
|
|
725
732
|
try {
|
|
726
733
|
button = phoneLinkButton(phoneOrigin?.() ?? null, card.link);
|
|
@@ -750,7 +757,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
750
757
|
if (problem !== null)
|
|
751
758
|
return { ok: false, error: problem };
|
|
752
759
|
const card = store.chatCardFor(fact.destination, fact.taskRef, store.getTask(fact.taskId)?.createdAt ?? fact.createdAt, clock());
|
|
753
|
-
const view = quietCardView(store, card.tasks, clock(), evidenceRoot);
|
|
760
|
+
const view = quietCardView(store, card.tasks, clock(), evidenceRoot, binding.approver);
|
|
754
761
|
if (view === null)
|
|
755
762
|
return { ok: true, messageId: card.message };
|
|
756
763
|
let button = null;
|
|
@@ -792,14 +799,55 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
792
799
|
if (!needsPerson(fact))
|
|
793
800
|
return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
|
|
794
801
|
const readyRun = fact.kind === "run-finished" ? store.telegramProgressRun(fact) : null;
|
|
795
|
-
if (readyRun !== null) {
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
802
|
+
if (readyRun !== null || (fact.kind !== "run-finished" && joinsBatch(fact))) {
|
|
803
|
+
// Any update for this person joins their open batch: one message, edited in place while it grows.
|
|
804
|
+
const batch = store.chatBatchFor(fact.destination, fact.taskRef, readyRun?.id ?? store.telegramProgressRun(fact)?.id ?? null, clock(), BATCH_MS, readyRun !== null ? null : fact.id);
|
|
805
|
+
// A lone update that is not finished work goes out exactly as it always has; a later one edits it in place.
|
|
806
|
+
const lone = batch.items.length === 1 && batch.items[0].notification !== null;
|
|
807
|
+
if (batch.message === null && lone) {
|
|
808
|
+
const sent = await deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
809
|
+
const placed = store.telegramMessageOf(fact.id, fact.destination);
|
|
810
|
+
if (placed !== null)
|
|
811
|
+
store.setChatBatchMessage(batch.id, placed, "");
|
|
812
|
+
return sent;
|
|
800
813
|
}
|
|
801
|
-
|
|
802
|
-
|
|
814
|
+
const view = finishedView(store, batch, clock(), evidenceRoot, binding.approver);
|
|
815
|
+
// Nothing in the batch reads as a line. Finished work left out on purpose (a release check, a replaced task,
|
|
816
|
+
// the reader's own completion) stays quiet; any other update that needs this person still reaches them alone.
|
|
817
|
+
if (view === null) {
|
|
818
|
+
if (readyRun !== null)
|
|
819
|
+
return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
|
|
820
|
+
return deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
821
|
+
}
|
|
822
|
+
// While a lone update is still the only item, its edit keeps that update's own action button.
|
|
823
|
+
const own = lone && fact.link !== null ? factButton(phoneOrigin, fact.link) : null;
|
|
824
|
+
const keyboard = own !== null ? [own] : viewKeyboard(phoneOrigin, view);
|
|
825
|
+
const shown = createHash("sha256").update(JSON.stringify([view.text, keyboard])).digest("hex");
|
|
826
|
+
if (batch.message !== null) {
|
|
827
|
+
if (batch.digest === shown) {
|
|
828
|
+
store.recordTelegramMessage(fact, binding, batch.message, clock());
|
|
829
|
+
return { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) };
|
|
830
|
+
}
|
|
831
|
+
const problem = (await readAccess()) ?? fence();
|
|
832
|
+
if (problem !== null)
|
|
833
|
+
return { ok: false, error: problem };
|
|
834
|
+
const edited = await editProgress(transport, binding.chatId, batch.message, view.text, keyboard);
|
|
835
|
+
if (edited.ok) {
|
|
836
|
+
store.setChatBatchMessage(batch.id, batch.message, shown);
|
|
837
|
+
store.recordTelegramMessage(fact, binding, batch.message, clock());
|
|
838
|
+
const after = (await readAccess()) ?? fence();
|
|
839
|
+
return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) } : { ok: false, error: after };
|
|
840
|
+
}
|
|
841
|
+
if (edited.retryAfter !== undefined)
|
|
842
|
+
store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
|
|
843
|
+
// Only Telegram's definitive missing/uneditable answer permits a new message.
|
|
844
|
+
if (!edited.replace)
|
|
845
|
+
return edited;
|
|
846
|
+
}
|
|
847
|
+
const sent = await sender(view.text, keyboard.length === 0 ? undefined : keyboard, [fact]);
|
|
848
|
+
const placed = sent.ok ? sent.messageId : store.telegramMessageOf(fact.id, fact.destination);
|
|
849
|
+
if (placed !== null)
|
|
850
|
+
store.setChatBatchMessage(batch.id, placed, shown);
|
|
803
851
|
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
804
852
|
}
|
|
805
853
|
return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
@@ -815,7 +863,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
815
863
|
const updated = progress ? await updateProgress(false) : null;
|
|
816
864
|
outcome = updated !== null ? updated.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, updated.messageId) } : updated
|
|
817
865
|
: rows[0].kind === "acceptance-evidence" ? await imageSender(rows[0]) : batched
|
|
818
|
-
? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock)
|
|
866
|
+
? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock, taskId => chatTitle(store, taskId))
|
|
819
867
|
: await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects);
|
|
820
868
|
}
|
|
821
869
|
const finalProblem = outcome.ok ? (await readAccess()) ?? fence() : null;
|
|
@@ -850,26 +898,29 @@ function isUrgent(notification) {
|
|
|
850
898
|
/** The digest text: a header with the count and the window, then one
|
|
851
899
|
* fact per entry — its subject, then its body's first line, indented.
|
|
852
900
|
* Plain text, no buttons: nothing in a digest is tappable. */
|
|
853
|
-
export function digestText(rows, since, now) {
|
|
901
|
+
export function digestText(rows, since, now, titleOf) {
|
|
854
902
|
const window = since === null ? "" : ` since ${since.slice(0, 16).replace("T", " ")}`;
|
|
855
903
|
const lines = [`digest — ${rows.length} routine fact(s)${window} (as of ${now.toISOString().slice(0, 16).replace("T", " ")})`, ""];
|
|
856
904
|
for (const row of rows) {
|
|
857
|
-
lines.push(digestEntry(row));
|
|
905
|
+
lines.push(digestEntry(row, titleOf));
|
|
858
906
|
}
|
|
859
907
|
return lines.join("\n");
|
|
860
908
|
}
|
|
861
|
-
function digestEntry(row) {
|
|
909
|
+
function digestEntry(row, titleOf) {
|
|
910
|
+
const title = row.taskId === null ? undefined : titleOf?.(row.taskId);
|
|
911
|
+
const task = row.taskId === null ? [] : [{ id: row.taskId, ...(title === undefined ? {} : { title }) }];
|
|
912
|
+
const subject = chatText(row.subject, task);
|
|
862
913
|
const first = row.body.split("\n").map(one => one.trim()).find(one => one !== "");
|
|
863
914
|
const body = first === undefined ? "" : `\n ${first.length > 200 ? `${first.slice(0, 200).replace(/[\uD800-\uDBFF]$/, "")}…` : first}`;
|
|
864
|
-
return `• ${notificationIdentity(row)}${
|
|
915
|
+
return `• ${notificationIdentity(row, title !== undefined && mentions(subject, title) ? undefined : title)}${subject}${chatText(body, task)}`;
|
|
865
916
|
}
|
|
866
|
-
async function deliverDigest(botId, binding, sender, rows, since, clock) {
|
|
867
|
-
const text = digestText(rows, since, clock());
|
|
917
|
+
async function deliverDigest(botId, binding, sender, rows, since, clock, titleOf) {
|
|
918
|
+
const text = digestText(rows, since, clock(), titleOf);
|
|
868
919
|
// Track the exact rows represented by each text part. A split row may bind
|
|
869
920
|
// several messages; a digest message may bind several rows.
|
|
870
921
|
let offset = text.indexOf("\n\n") + 2;
|
|
871
922
|
const spans = rows.map(row => {
|
|
872
|
-
const length = digestEntry(row).length;
|
|
923
|
+
const length = digestEntry(row, titleOf).length;
|
|
873
924
|
const span = { row, start: offset, end: offset + length };
|
|
874
925
|
offset += length + 1;
|
|
875
926
|
return span;
|
|
@@ -917,16 +968,13 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
917
968
|
else
|
|
918
969
|
body = `${packet.text}\n\n${notification.body}`;
|
|
919
970
|
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
button = null;
|
|
928
|
-
}
|
|
929
|
-
}
|
|
971
|
+
// A short title, never the task's id or a "— revision" suffix (chat-voice.ts).
|
|
972
|
+
const title = notification.taskId === null ? undefined : chatTitle(store, notification.taskId);
|
|
973
|
+
const task = notification.taskId === null ? [] : [{ id: notification.taskId, ...(title === undefined ? {} : { title }) }];
|
|
974
|
+
const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : notification.subject}${body === "" ? "" : `\n\n${body}`}`, task);
|
|
975
|
+
// The task's title once: in front, unless the words already name it.
|
|
976
|
+
const parts = split(`${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
|
|
977
|
+
const button = notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
|
|
930
978
|
// A flow card waiting on a decision (v86): Approve, Edit, Send back on the last part, for this visit only.
|
|
931
979
|
const visit = FLOW_DECIDE_KEY.exec(notification.dedupeKey);
|
|
932
980
|
const waiting = visit === null ? null : flowDecisionAt(store, Number(visit[1]), Number(visit[2]));
|
|
@@ -1403,6 +1451,8 @@ function applyMessage(context, update, effects) {
|
|
|
1403
1451
|
text: `paired: this chat now answers as ${approver}\n\nSend /status to check recent work, /task <id> for one task, or /help for your options.`,
|
|
1404
1452
|
link_preview_options: { is_disabled: true },
|
|
1405
1453
|
});
|
|
1454
|
+
// The bot is Toolroll in every chat list from here on (best effort; pairing never waits on it).
|
|
1455
|
+
await nameTelegramBot(transport, "pairing");
|
|
1406
1456
|
});
|
|
1407
1457
|
}
|
|
1408
1458
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.10",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|