toolroll 0.9.8 → 0.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/dist/assignment-brief.d.ts +2 -0
- package/dist/assignment-brief.js +2 -1
- package/dist/assignment-presentation.js +3 -1
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +5 -0
- package/dist/assignment.d.ts +9 -0
- package/dist/assignment.js +42 -8
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-crew.js +4 -0
- package/dist/browser-workspace.d.ts +40 -0
- package/dist/builder.d.ts +2 -0
- package/dist/builder.js +114 -8
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -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/check-progress.js +4 -3
- package/dist/checkout-cleanup.d.ts +40 -2
- package/dist/checkout-cleanup.js +129 -9
- package/dist/desktop-update.d.ts +52 -0
- package/dist/desktop-update.js +147 -5
- package/dist/discord-chat.js +1 -0
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +8 -0
- package/dist/first-run.d.ts +1 -1
- package/dist/first-run.js +7 -5
- package/dist/lead-context.d.ts +1 -1
- package/dist/lead-context.js +4 -2
- package/dist/lead-status.d.ts +10 -1
- package/dist/lead-status.js +21 -24
- package/dist/needs-you.d.ts +55 -0
- package/dist/needs-you.js +83 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +122 -15
- package/dist/plane-review.js +4 -0
- package/dist/process-recovery-eligibility.d.ts +1 -0
- package/dist/project-delete.js +1 -0
- package/dist/project-learning.d.ts +3 -3
- package/dist/project-learning.js +28 -17
- package/dist/proof.d.ts +3 -1
- package/dist/proof.js +4 -2
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +30 -0
- package/dist/result-completion.d.ts +4 -0
- package/dist/result-completion.js +11 -0
- package/dist/review-context.d.ts +3 -0
- package/dist/review-context.js +45 -5
- package/dist/run-closeout.d.ts +89 -0
- package/dist/run-closeout.js +302 -0
- package/dist/serve.d.ts +15 -1
- package/dist/serve.js +129 -14
- package/dist/setup-guide.d.ts +2 -2
- package/dist/shared-deps.d.ts +94 -0
- package/dist/shared-deps.js +365 -0
- package/dist/slack-chat.js +2 -0
- package/dist/storage.js +2 -0
- package/dist/store.d.ts +60 -0
- package/dist/store.js +207 -30
- package/dist/task-status.d.ts +18 -4
- package/dist/task-status.js +35 -18
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-progress.d.ts +6 -1
- package/dist/telegram-progress.js +13 -5
- package/dist/telegram-status.d.ts +2 -1
- package/dist/telegram-status.js +6 -5
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +93 -43
- package/dist/test-temp.d.ts +19 -0
- package/dist/test-temp.js +98 -0
- package/dist/toolroll-update-ui.js +7 -2
- package/dist/toolroll-update.d.ts +21 -1
- package/dist/toolroll-update.js +135 -5
- package/dist/work-index.d.ts +11 -0
- package/dist/work-index.js +103 -16
- package/dist/work-summary.d.ts +3 -1
- package/dist/workspace-ui.js +1 -1
- package/dist/worktree.d.ts +16 -0
- package/dist/worktree.js +47 -0
- package/package.json +1 -1
|
@@ -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,26 @@ 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 }],
|
|
89
|
-
|
|
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 }],
|
|
96
|
+
// Needs you: the button names the action that resolves it.
|
|
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
|
|
90
98
|
? chatControlHref("recovery", taskId) : built ? chatResultHref(taskId, run.id, failedChecks ? "checks" : "summary") : chatControlHref("task", taskId) } };
|
|
91
99
|
}
|
|
@@ -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
|
@@ -3,7 +3,7 @@ import { manualReviewOnly } from "./proof.js";
|
|
|
3
3
|
/** Read-only phone views over the same dispatch and proof records as chat.
|
|
4
4
|
* No provider calls, repository access, new workflow state, or inferred success. */
|
|
5
5
|
import { diagnoseTaskDispatch, withDispatchDiagnoses } from "./dispatch.js";
|
|
6
|
-
import {
|
|
6
|
+
import { familyCompleted } from "./result-completion.js";
|
|
7
7
|
import { plainReasonOf, stageOfDispatch, taskStatusOf } from "./task-status.js";
|
|
8
8
|
import { CHAT_CONTROLS, chatControlHref, chatResultHref } from "./chat-controls.js";
|
|
9
9
|
export function phoneCommand(text) {
|
|
@@ -54,8 +54,8 @@ export function projectLabel(repo) {
|
|
|
54
54
|
}
|
|
55
55
|
/** The shared headline (task-status.ts) for a dispatch diagnosis on the phone. */
|
|
56
56
|
function headlineFor(store, id, d) {
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
// Any version of a completed family reads Complete, as taskWaitSnapshot says.
|
|
58
|
+
const completed = d.code === "complete" && familyCompleted(store, id);
|
|
59
59
|
const read = stageOfDispatch(d, { completed });
|
|
60
60
|
const finished = read.stage === "finished" || read.stage === "complete" || read.stage === "failed";
|
|
61
61
|
return taskStatusOf({ stage: read.stage, ...(read.need === undefined ? {} : { need: read.need }), reason: finished ? null : plainReasonOf(read.stage, d.code, d.detail),
|
|
@@ -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
|
/**
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Name prefixes of what tests, journeys and scripts leave in the temp folder. */
|
|
2
|
+
export declare const TEST_TEMP_PREFIXES: readonly string[];
|
|
3
|
+
export declare function isTestTemp(name: string): boolean;
|
|
4
|
+
/** Where they collect: the temp folder (and /tmp, where some scripts write). */
|
|
5
|
+
export declare function tempRoots(): string[];
|
|
6
|
+
export type TempLeftover = {
|
|
7
|
+
path: string;
|
|
8
|
+
touchedAt: number;
|
|
9
|
+
};
|
|
10
|
+
/** Every test temp folder in `roots`, and which of them nothing has touched for a day. */
|
|
11
|
+
export declare function testTempFolders(roots: readonly string[], now: Date): {
|
|
12
|
+
all: TempLeftover[];
|
|
13
|
+
stale: TempLeftover[];
|
|
14
|
+
};
|
|
15
|
+
/** Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes. */
|
|
16
|
+
export declare function removeStaleTestTemp(roots: readonly string[], now: Date): {
|
|
17
|
+
removed: string[];
|
|
18
|
+
failed: string[];
|
|
19
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test temp folders (`toolroll storage`, `storage clean`): what the unit tests, journeys and scripts make in the
|
|
3
|
+
* system temp folder. The tests delete their own (each run gets one temp root that its global teardown removes:
|
|
4
|
+
* test/temp-root.ts); a run that was killed, and older versions, leave folders behind. `toolroll storage` counts them
|
|
5
|
+
* and `storage clean` removes the ones nothing has touched for a day.
|
|
6
|
+
*
|
|
7
|
+
* Every prefix the tests and scripts give tmpdir() is covered here (test-temp.test.ts reads the sources to keep it
|
|
8
|
+
* so), and Playwright's own browser profiles.
|
|
9
|
+
*/
|
|
10
|
+
import { lstatSync, readdirSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
const DAY_MS = 86_400_000;
|
|
14
|
+
/** Name prefixes of what tests, journeys and scripts leave in the temp folder. */
|
|
15
|
+
export const TEST_TEMP_PREFIXES = [
|
|
16
|
+
"so-", "standing-orders-", "toolroll-", "no-wt", "epoch", "cancel-floor",
|
|
17
|
+
"attest-", "budgets-", "chat-v66-", "check-levels-", "coding-context-", "console-url-", "console-v2-", "e2-attest-",
|
|
18
|
+
"flow-goal-", "flow-scratch", "flows-chat-", "invoke-attest-", "knowledge-test-", "learning-", "peek-", "phone-origin-",
|
|
19
|
+
"proposal-repo-", "race-", "refresh-migration-", "refresh-refusal-", "release-check-", "shared-chat-", "skills-test-",
|
|
20
|
+
"task-status-surfaces-", "template-repo-", "template-test-", "v14-migrate-", "v17-migrate-", "watch-anchor-",
|
|
21
|
+
"needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-",
|
|
22
|
+
// Playwright's browser profiles and artifacts.
|
|
23
|
+
"playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
|
|
24
|
+
];
|
|
25
|
+
export function isTestTemp(name) {
|
|
26
|
+
return TEST_TEMP_PREFIXES.some(prefix => name.startsWith(prefix));
|
|
27
|
+
}
|
|
28
|
+
/** Where they collect: the temp folder (and /tmp, where some scripts write). */
|
|
29
|
+
export function tempRoots() {
|
|
30
|
+
const roots = [tmpdir()];
|
|
31
|
+
if (process.platform !== "win32" && !roots.includes("/tmp"))
|
|
32
|
+
roots.push("/tmp");
|
|
33
|
+
return roots;
|
|
34
|
+
}
|
|
35
|
+
/** When anything last happened in a folder: it, or what is directly in it, last changed. */
|
|
36
|
+
function touchedAt(path) {
|
|
37
|
+
let newest = lstatSync(path).mtimeMs;
|
|
38
|
+
let names = [];
|
|
39
|
+
try {
|
|
40
|
+
names = readdirSync(path);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return newest;
|
|
44
|
+
}
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
try {
|
|
47
|
+
newest = Math.max(newest, lstatSync(join(path, name)).mtimeMs);
|
|
48
|
+
}
|
|
49
|
+
catch { /* gone */ }
|
|
50
|
+
}
|
|
51
|
+
return newest;
|
|
52
|
+
}
|
|
53
|
+
/** Every test temp folder in `roots`, and which of them nothing has touched for a day. */
|
|
54
|
+
export function testTempFolders(roots, now) {
|
|
55
|
+
const all = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
for (const root of roots) {
|
|
58
|
+
let names = [];
|
|
59
|
+
try {
|
|
60
|
+
names = readdirSync(root);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
if (!isTestTemp(name))
|
|
67
|
+
continue;
|
|
68
|
+
const path = join(root, name);
|
|
69
|
+
try {
|
|
70
|
+
const stat = lstatSync(path);
|
|
71
|
+
const key = `${stat.dev}:${stat.ino}`;
|
|
72
|
+
if (seen.has(key) || stat.isSymbolicLink())
|
|
73
|
+
continue;
|
|
74
|
+
seen.add(key);
|
|
75
|
+
all.push({ path, touchedAt: stat.isDirectory() ? touchedAt(path) : stat.mtimeMs });
|
|
76
|
+
}
|
|
77
|
+
catch { /* gone */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { all, stale: all.filter(one => now.getTime() - one.touchedAt > DAY_MS) };
|
|
81
|
+
}
|
|
82
|
+
/** Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes. */
|
|
83
|
+
export function removeStaleTestTemp(roots, now) {
|
|
84
|
+
const removed = [];
|
|
85
|
+
const failed = [];
|
|
86
|
+
for (const one of testTempFolders(roots, now).stale) {
|
|
87
|
+
try {
|
|
88
|
+
if (now.getTime() - (lstatSync(one.path).isDirectory() ? touchedAt(one.path) : lstatSync(one.path).mtimeMs) <= DAY_MS)
|
|
89
|
+
continue;
|
|
90
|
+
rmSync(one.path, { recursive: true, force: true, maxRetries: 3 });
|
|
91
|
+
removed.push(one.path);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
failed.push(one.path);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { removed, failed };
|
|
98
|
+
}
|
|
@@ -12,7 +12,7 @@ export const UPDATES_CSS = `.updates{max-width:640px;min-width:0;overflow-wrap:a
|
|
|
12
12
|
`.updates ol.update-steps li[data-state=failed]{color:var(--so-danger);font-weight:600}.updates ol.update-steps li[data-state=failed]::before{background:var(--so-danger);border-color:var(--so-danger)}` +
|
|
13
13
|
`.updates ol.update-steps .step-detail{grid-column:2;font-weight:400;color:var(--so-muted);font-size:.8125rem;margin-top:2px}` +
|
|
14
14
|
`.updates .whats-new ul{margin:10px 0 0;padding-left:18px}.updates .whats-new li{margin:4px 0}.updates .whats-new form{margin:14px 0 0}` +
|
|
15
|
-
`.updates .update-problem{color:var(--so-danger)}.updates .stamp{margin:10px 0 0}.updates code{white-space:nowrap}` +
|
|
15
|
+
`.updates .update-problem{color:var(--so-danger)}.updates .stamp{margin:10px 0 0}.updates code{white-space:nowrap}.updates [data-update-outcome=stopped] code,.updates .step-detail code{white-space:normal;overflow-wrap:anywhere}` +
|
|
16
16
|
`@media (prefers-reduced-motion:no-preference){.updates ol.update-steps li[data-state=now]::before{animation:update-pulse 1.6s ease-in-out infinite}}@keyframes update-pulse{50%{box-shadow:0 0 0 7px color-mix(in srgb,var(--so-info) 6%,transparent)}}` +
|
|
17
17
|
`@media (max-width:600px){.updates .update-actions{display:grid}.updates .update-actions button{width:100%;min-height:44px}.updates .step-up{max-width:none}.updates .step-up input{min-height:44px}}`;
|
|
18
18
|
/** The steps, each done, current, failed or waiting. Also the live region's fragment. */
|
|
@@ -23,7 +23,8 @@ export function updateStepsHtml(j, running) {
|
|
|
23
23
|
const items = UPDATE_STEPS.map((step, i) => {
|
|
24
24
|
const state = j.phase === "complete" || i < last ? "done" : i > last ? "waiting" : failed ? "failed" : finished ? "waiting" : "now";
|
|
25
25
|
// Only where it adds something: which work it waits for, or what went wrong.
|
|
26
|
-
const detail =
|
|
26
|
+
const detail = state === "now" && step === "draining" && j.waiting ? `<span class="step-detail">${e(j.waiting.on)}.${j.waiting.action ? ` If nothing of it is running, run <code>${e(j.waiting.action)}</code>.` : ""}</span>`
|
|
27
|
+
: (state === "now" && step === "draining") || (state === "failed" && j.phase === "rolling-back") ? `<span class="step-detail">${e(j.detail)}</span>` : "";
|
|
27
28
|
return `<li data-step="${step}" data-state="${state}">${e(STEP_WORDS[step])}${detail}</li>`;
|
|
28
29
|
}).join("");
|
|
29
30
|
const title = j.phase === "scheduled" ? `Update to ${e(j.to.version)} scheduled for ${e(clock(j.at ?? j.startedAt))}`
|
|
@@ -38,6 +39,10 @@ function outcomeHtml(j) {
|
|
|
38
39
|
return j.kind === "rollback" ? `<div class="card" data-update-outcome="rolled-back"><h2>Back on ${e(j.to.version)}</h2><p class="meta">${e(j.detail)}</p></div>` : "";
|
|
39
40
|
if (j.phase === "cancelled")
|
|
40
41
|
return `<div class="card" data-update-outcome="cancelled"><h2>Update cancelled</h2><p class="meta">Nothing changed.</p></div>`;
|
|
42
|
+
// Stopped on a finished run it can't show has ended: what is in the way and the one command, nothing more.
|
|
43
|
+
if (j.phase === "refused" && j.waiting)
|
|
44
|
+
return `<div class="card" data-update-outcome="stopped"><h2>Update to ${e(j.to.version)} stopped: run #${e(j.waiting.run)} is in the way</h2>` +
|
|
45
|
+
`<p>${e(j.waiting.on)}. New work resumed.</p>` + (j.waiting.action ? `<p class="meta">If nothing of it is running, run <code>${e(j.waiting.action)}</code>, then update again.</p>` : `<p class="meta">Update again once it has stopped.</p>`) + `</div>`;
|
|
41
46
|
const title = j.phase === "refused" ? `Didn't update to ${e(j.to.version)}` : j.phase === "restored" ? `Update to ${e(j.to.version)} didn't finish` : "The update needs attention";
|
|
42
47
|
return `<div class="card" data-update-outcome="${e(j.phase)}"><h2>${title}</h2><p class="${j.phase === "needs-attention" ? "update-problem" : ""}" role="alert">${e(j.detail)}</p>` +
|
|
43
48
|
`<details><summary>Steps</summary>${updateStepsHtml(j, false).replace(/<h2>.*?<\/h2>/, "")}</details></div>`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { type LingeringRun, type UpdateWaiting } from "./desktop-update.js";
|
|
2
3
|
import { type SupervisorRunner } from "./daemon.js";
|
|
3
4
|
export declare const PROVENANCE_REPOSITORY = "https://github.com/ap9000/toolroll";
|
|
4
5
|
export declare const PROVENANCE_WORKFLOW = ".github/workflows/publish.yml";
|
|
@@ -103,6 +104,11 @@ export type RuntimeUpdateJournal = {
|
|
|
103
104
|
* the restored version and would be lost), and it keeps a fresh copy aside before every attempt until then. */
|
|
104
105
|
restoredDatabase?: boolean;
|
|
105
106
|
seen?: boolean;
|
|
107
|
+
/** The finished run the update is waiting on (or stopped waiting on): what is in the way and the command that
|
|
108
|
+
* clears it. Absent while it waits on ordinary running work. */
|
|
109
|
+
waiting?: LingeringRun & {
|
|
110
|
+
since: string;
|
|
111
|
+
};
|
|
106
112
|
};
|
|
107
113
|
export type PackageRelease = {
|
|
108
114
|
version: string;
|
|
@@ -160,6 +166,8 @@ export type UpdateOutcome = {
|
|
|
160
166
|
journal: RuntimeUpdateJournal | null;
|
|
161
167
|
};
|
|
162
168
|
export declare const runtimeUpdateTerminal: (phase: RuntimePhase) => boolean;
|
|
169
|
+
/** How long a launched or resuming updater is expected to take to start and hold the lock. */
|
|
170
|
+
export declare const UPDATER_START_MS: number;
|
|
163
171
|
export declare function readRuntimeUpdate(stateDir: string, file?: string): RuntimeUpdateJournal | null;
|
|
164
172
|
/** The update `--rollback` returns from: the last one that completed, whatever was attempted since. */
|
|
165
173
|
export declare function lastCompletedUpdate(stateDir: string): RuntimeUpdateJournal | null;
|
|
@@ -245,6 +253,18 @@ export type RuntimeUpdateStatus = {
|
|
|
245
253
|
} | null;
|
|
246
254
|
};
|
|
247
255
|
export declare function runtimeUpdateStatus(stateDir: string): RuntimeUpdateStatus;
|
|
256
|
+
/** An update that is waiting, what on, and the one action that clears it: for `toolroll status` and the console.
|
|
257
|
+
* `inTheWay` asks whether a finished run still holds it up; a stopped update whose run has since cleared says nothing. */
|
|
258
|
+
export type { UpdateWaiting };
|
|
259
|
+
export declare function updateWaitingOf(stateDir: string, inTheWay: (run: number) => boolean): UpdateWaiting | null;
|
|
260
|
+
/** An updater that ended while letting work finish (a crash, a kill, the Mac restarting) leaves new work paused with
|
|
261
|
+
* nobody left to lift the pause. With no updater holding the update, the pause is lifted here and the update says it
|
|
262
|
+
* stopped. Nothing was switched before the backup, so nothing else needs putting back. The background worker's
|
|
263
|
+
* reconcile, `toolroll status` and the console run this. Returns what it said, or null. */
|
|
264
|
+
export declare function releaseStalledUpdate(stateDir: string, now: Date): string | null;
|
|
265
|
+
/** What `toolroll status` and the console show: a stalled `toolroll update` released first, then this installation's
|
|
266
|
+
* waiting update, `toolroll update`'s or the desktop app's. */
|
|
267
|
+
export declare function waitingUpdate(databaseFile: string, inTheWay: (run: number) => boolean, now: Date, desktopStates?: readonly string[]): UpdateWaiting | null;
|
|
248
268
|
export declare function markWhatsNewSeen(stateDir: string): void;
|
|
249
269
|
type Exec = (command: string, args: string[], options?: {
|
|
250
270
|
cwd?: string;
|
|
@@ -282,8 +302,8 @@ export declare function launchRuntimeUpdate(args: {
|
|
|
282
302
|
run?: SupervisorRunner;
|
|
283
303
|
platform?: NodeJS.Platform;
|
|
284
304
|
npmBin?: () => Promise<string | null>;
|
|
305
|
+
now?: () => Date;
|
|
285
306
|
}): Promise<void>;
|
|
286
307
|
/** The job's last act: its definition goes, so nothing can start it again. Only the definition for this id. */
|
|
287
308
|
export declare function retireUpdateJob(id: string, home?: string): void;
|
|
288
309
|
export declare const nextScheduledAt: typeof nextAt;
|
|
289
|
-
export {};
|