toolroll 0.8.1 → 0.9.0
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 +49 -0
- package/dist/agent-onboard.d.ts +7 -0
- package/dist/agent-onboard.js +23 -5
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -20
- package/dist/browser-workspace.d.ts +10 -0
- package/dist/chat-delivery.js +51 -3
- package/dist/chat-quiet.d.ts +28 -0
- package/dist/chat-quiet.js +161 -0
- package/dist/checkout-cleanup.d.ts +84 -0
- package/dist/checkout-cleanup.js +245 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -0
- package/dist/digest-times.d.ts +3 -0
- package/dist/digest-times.js +7 -0
- package/dist/dispose.js +2 -0
- package/dist/export.js +1 -1
- package/dist/first-run.d.ts +4 -1
- package/dist/first-run.js +2 -1
- package/dist/integrations-ui.d.ts +13 -0
- package/dist/integrations-ui.js +65 -0
- package/dist/integrations.d.ts +113 -0
- package/dist/integrations.js +526 -0
- package/dist/mcp.js +26 -0
- package/dist/operate.d.ts +5 -6
- package/dist/operate.js +254 -25
- package/dist/project-delete.js +1 -0
- package/dist/prompt.d.ts +7 -2
- package/dist/prompt.js +36 -7
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +9 -2
- package/dist/pull-request-flow.d.ts +140 -0
- package/dist/pull-request-flow.js +440 -0
- package/dist/pull-request-schema.d.ts +5 -0
- package/dist/pull-request-schema.js +21 -0
- package/dist/pull-request-ui.d.ts +23 -0
- package/dist/pull-request-ui.js +55 -0
- package/dist/pulls.d.ts +5 -0
- package/dist/pulls.js +11 -0
- package/dist/serve.d.ts +6 -0
- package/dist/serve.js +323 -26
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage-ui.d.ts +10 -0
- package/dist/storage-ui.js +67 -0
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +62 -7
- package/dist/store.d.ts +66 -5
- package/dist/store.js +160 -19
- package/dist/surface.js +2 -0
- package/dist/telegram-progress.d.ts +1 -0
- package/dist/telegram-progress.js +1 -1
- package/dist/telegram.js +95 -15
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +1 -1
- package/dist/toolroll-update.d.ts +26 -6
- package/dist/toolroll-update.js +188 -50
- package/dist/worktree.d.ts +24 -10
- package/dist/worktree.js +84 -44
- package/package.json +1 -1
|
@@ -201,6 +201,11 @@ export type BrowserSettingsView = {
|
|
|
201
201
|
removable: boolean;
|
|
202
202
|
}[];
|
|
203
203
|
} | null;
|
|
204
|
+
/** Quiet chat: how chats reach this person, and their evening digest time (null: off). */
|
|
205
|
+
chat?: {
|
|
206
|
+
mode: 'quiet' | 'all';
|
|
207
|
+
digestAt: string | null;
|
|
208
|
+
} | null;
|
|
204
209
|
digest: {
|
|
205
210
|
every: string;
|
|
206
211
|
held: string | null;
|
|
@@ -327,6 +332,11 @@ export type BrowserProjectRow = {
|
|
|
327
332
|
/** When it was last opened here; null for a project only seen in the queue. */
|
|
328
333
|
openedAt: string | null;
|
|
329
334
|
knowledgeHref: string;
|
|
335
|
+
/** Settings → Projects → Pull requests for this project, and whether they are on. */
|
|
336
|
+
pullRequests?: {
|
|
337
|
+
href: string;
|
|
338
|
+
on: boolean;
|
|
339
|
+
};
|
|
330
340
|
/** What waits, runs, queues or finished today; null when not scanned. */
|
|
331
341
|
peek: {
|
|
332
342
|
label: string;
|
package/dist/chat-delivery.js
CHANGED
|
@@ -9,6 +9,7 @@ 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, readyPingView } from "./chat-quiet.js";
|
|
12
13
|
import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
|
|
13
14
|
import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
|
|
14
15
|
import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
|
|
@@ -574,6 +575,33 @@ export async function planRoomMessages(options) {
|
|
|
574
575
|
}
|
|
575
576
|
}
|
|
576
577
|
}
|
|
578
|
+
/** Quiet mode: the task's one card (or its group's) in this person's chat, planned once and then repainted in
|
|
579
|
+
* place whenever what it says changes. */
|
|
580
|
+
function planQuietCard(options, binding, notification, now) {
|
|
581
|
+
const { state, store, identity } = options;
|
|
582
|
+
if (notification.taskRef === null || notification.taskId === null)
|
|
583
|
+
return;
|
|
584
|
+
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
|
+
if (view === null)
|
|
587
|
+
return;
|
|
588
|
+
const content = { text: view.text, link: view.link, ...(card.tasks.length === 1 ? { task: notification.taskId } : {}) };
|
|
589
|
+
const shown = chatHash(JSON.stringify(content));
|
|
590
|
+
if (card.message !== null) {
|
|
591
|
+
if (card.digest === shown)
|
|
592
|
+
return;
|
|
593
|
+
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(card.message));
|
|
594
|
+
store.setChatCardMessage(card.id, card.message, shown);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const id = chatHash(`${state.channel}:card:${binding.id}:${card.id}`);
|
|
598
|
+
state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
|
|
599
|
+
ts: "", thread: "", payload: "{}", created: now.toISOString() });
|
|
600
|
+
state.plan(id, [content], now);
|
|
601
|
+
const part = state.prepare("SELECT id FROM chat_part WHERE event=?").get(id);
|
|
602
|
+
if (part !== undefined)
|
|
603
|
+
store.setChatCardMessage(card.id, String(part.id), shown);
|
|
604
|
+
}
|
|
577
605
|
/** One progress card per exact result; separate urgent facts retain their own review link. */
|
|
578
606
|
export async function planChatNotifications(options) {
|
|
579
607
|
const state = options.state, { store, identity } = options, bindings = state.bindings(identity.installation).filter(one => state.live(one));
|
|
@@ -582,6 +610,11 @@ export async function planChatNotifications(options) {
|
|
|
582
610
|
!options.current())
|
|
583
611
|
return;
|
|
584
612
|
const registry = await options.readProjects();
|
|
613
|
+
// An evening digest someone asked for is due: record it as their own notification, delivered below.
|
|
614
|
+
try {
|
|
615
|
+
enqueueEveningDigests(store, nowOf(options), options.evidenceRoot);
|
|
616
|
+
}
|
|
617
|
+
catch { /* Retried on the next pass. */ }
|
|
585
618
|
const cursor = Number(state
|
|
586
619
|
.prepare("SELECT notification FROM chat_runtime WHERE installation=?")
|
|
587
620
|
.get(identity.installation)?.notification ?? 0);
|
|
@@ -602,7 +635,22 @@ export async function planChatNotifications(options) {
|
|
|
602
635
|
: (notification.taskId || notification.kind === "flow-decision") && notification.project !== null && repos.includes(notification.project))) {
|
|
603
636
|
const run = store.telegramProgressRun(notification);
|
|
604
637
|
const id = chatHash(`${options.state.channel}:notice:${binding.id}:${notification.id}`), now = nowOf(options);
|
|
605
|
-
|
|
638
|
+
// Only when I'm needed (the default): the task's one card is edited in place, and a new message
|
|
639
|
+
// follows only when this person is needed. Every step keeps the branches below unchanged.
|
|
640
|
+
const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
|
|
641
|
+
if (quiet) {
|
|
642
|
+
planQuietCard(options, binding, notification, now);
|
|
643
|
+
if (!needsPerson(notification))
|
|
644
|
+
return;
|
|
645
|
+
if (notification.kind === "run-finished" && run) {
|
|
646
|
+
const ping = readyPingView(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot);
|
|
647
|
+
state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
|
|
648
|
+
ts: "", thread: "", payload: "{}", created: now.toISOString() });
|
|
649
|
+
state.plan(id, [{ text: ping.text, task: notification.taskId, run: run.id, link: ping.link }], now);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (!quiet && run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
|
|
606
654
|
const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot);
|
|
607
655
|
const content = {
|
|
608
656
|
text: card.text,
|
|
@@ -661,7 +709,7 @@ export async function planChatNotifications(options) {
|
|
|
661
709
|
ts: "", thread: "", payload: "{}", created: now.toISOString() });
|
|
662
710
|
state.plan(id, questionParts(store, notification, binding), now);
|
|
663
711
|
}
|
|
664
|
-
else if (notification.pushClass !== null || personal) {
|
|
712
|
+
else if (notification.pushClass !== null || personal || quiet) {
|
|
665
713
|
state.enqueue({
|
|
666
714
|
id,
|
|
667
715
|
installation: identity.installation,
|
|
@@ -680,7 +728,7 @@ export async function planChatNotifications(options) {
|
|
|
680
728
|
...(notification.taskId ? { task: notification.taskId } : {}),
|
|
681
729
|
...(run ? { run: run.id } : {}),
|
|
682
730
|
...(notification.link
|
|
683
|
-
? { link: { label: personal ? "Open" : "Review", path: notification.link } }
|
|
731
|
+
? { link: { label: notification.kind === "pull-request-ready" ? "Merge" : personal ? "Open" : "Review", path: notification.link } }
|
|
684
732
|
: {}),
|
|
685
733
|
},
|
|
686
734
|
], now);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ProgressEntity } from "./telegram-progress.js";
|
|
2
|
+
import { type PhoneTaskLink } from "./telegram-status.js";
|
|
3
|
+
import { type Notification, type Run, type Store } from "./store.js";
|
|
4
|
+
export type QuietView = {
|
|
5
|
+
text: string;
|
|
6
|
+
entities: ProgressEntity[];
|
|
7
|
+
link: PhoneTaskLink;
|
|
8
|
+
};
|
|
9
|
+
export declare function needsPerson(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient">): boolean;
|
|
10
|
+
/** A fact about one placed task: in quiet mode it lands on that task's card. */
|
|
11
|
+
export declare function isTaskFact(row: Pick<Notification, "taskRef" | "taskId" | "project">): row is typeof row & {
|
|
12
|
+
taskRef: number;
|
|
13
|
+
taskId: string;
|
|
14
|
+
project: string;
|
|
15
|
+
};
|
|
16
|
+
/** 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
|
+
/** The one ping for a Ready result: its state and title, what to do next, and a link to that exact result. */
|
|
19
|
+
export declare function readyPingView(store: Store, run: Run, taskId: string, project: string, now: Date, root?: string): QuietView;
|
|
20
|
+
/** This computer's local day and time, which is what a person means by "the evening". */
|
|
21
|
+
export declare function localDay(now: Date): string;
|
|
22
|
+
export declare function localTime(now: Date): string;
|
|
23
|
+
/** One person's evening: what finished and what failed in the last day, and what waits for them now. Null when
|
|
24
|
+
* nothing happened, so a quiet day sends nothing. */
|
|
25
|
+
export declare function eveningDigestText(store: Store, account: string, now: Date, root?: string): string | null;
|
|
26
|
+
/** Record each due evening digest once per person per local day, as one notification addressed to that person:
|
|
27
|
+
* every chat they paired delivers it with the usual receipts, and the console keeps it. */
|
|
28
|
+
export declare function enqueueEveningDigests(store: Store, now: Date, root?: string): number;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/** Quiet chat: a task's whole life is one chat message edited in place, and a
|
|
2
|
+
* new message arrives only when a person is needed. Every transport (Telegram,
|
|
3
|
+
* Slack, Discord, Teams) asks the same questions here: does this fact need a
|
|
4
|
+
* person, what does the task's card say now, and what does the Ready ping say.
|
|
5
|
+
* Delivery receipts, claims and retries stay with each transport; the
|
|
6
|
+
* console's activity keeps every fact whatever a chat showed. */
|
|
7
|
+
import { assignmentOf } from "./assignment.js";
|
|
8
|
+
import { chatControlHref } from "./chat-controls.js";
|
|
9
|
+
import { telegramProgressCard } from "./telegram-progress.js";
|
|
10
|
+
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
11
|
+
import { isLifecycleNotification } from "./store.js";
|
|
12
|
+
/** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
|
|
13
|
+
* attention fact, a result Ready for review, a security alert or release, and anything addressed to one person
|
|
14
|
+
* (a sign-in or plan-limit pause, an evening digest). */
|
|
15
|
+
const NEEDED_KINDS = new Set([
|
|
16
|
+
"acceptance-ready", "acceptance-evidence", "flow-decision", "flow-card", "plan-ready", "report-ready",
|
|
17
|
+
"stale-approval", "security-release", "secret-detected",
|
|
18
|
+
]);
|
|
19
|
+
export function needsPerson(row) {
|
|
20
|
+
if (row.recipient !== null)
|
|
21
|
+
return true;
|
|
22
|
+
if (/^decision:\d+$/.test(row.dedupeKey))
|
|
23
|
+
return true;
|
|
24
|
+
if (row.pushClass !== null && row.pushClass !== "progress")
|
|
25
|
+
return true;
|
|
26
|
+
if (NEEDED_KINDS.has(row.kind))
|
|
27
|
+
return true;
|
|
28
|
+
// A built or unchanged result is Ready: the one lifecycle step that asks for a person.
|
|
29
|
+
return isLifecycleNotification(row) && row.kind === "run-finished";
|
|
30
|
+
}
|
|
31
|
+
/** A fact about one placed task: in quiet mode it lands on that task's card. */
|
|
32
|
+
export function isTaskFact(row) {
|
|
33
|
+
return row.taskRef !== null && row.taskId !== null && row.project !== null;
|
|
34
|
+
}
|
|
35
|
+
/** The attempt a task's card follows: its newest builder attempt, outside any contest. */
|
|
36
|
+
function cardRun(store, taskRef) {
|
|
37
|
+
return store.runsFor(taskRef).find(run => run.role === "builder" && run.contestant === null) ?? null;
|
|
38
|
+
}
|
|
39
|
+
/** Before any attempt starts, the card says where the task stands in the words of its last lifecycle fact. */
|
|
40
|
+
const BEFORE_RUN = {
|
|
41
|
+
"task-filed": { icon: "📝", status: "Filed" },
|
|
42
|
+
"scope-approved": { icon: "✓", status: "Approved · Waiting to start" },
|
|
43
|
+
"approval-withdrawn": { icon: "📝", status: "Approval withdrawn" },
|
|
44
|
+
"task-held": { icon: "⏸", status: "Paused" },
|
|
45
|
+
"task-released": { icon: "✓", status: "Waiting to start" },
|
|
46
|
+
"task-queued": { icon: "✓", status: "Waiting to start" },
|
|
47
|
+
"task-requeued": { icon: "✓", status: "Waiting to start" },
|
|
48
|
+
"task-cancelled": { icon: "⏹", status: "Cancelled" },
|
|
49
|
+
};
|
|
50
|
+
function taskLine(store, taskRef, now, root) {
|
|
51
|
+
const ref = store.refById(taskRef);
|
|
52
|
+
if (ref === null || ref.repo === null)
|
|
53
|
+
return null;
|
|
54
|
+
const task = store.getTask(ref.externalId);
|
|
55
|
+
const title = phoneText(task?.title ?? ref.externalId, 88);
|
|
56
|
+
const run = cardRun(store, taskRef);
|
|
57
|
+
if (run !== null) {
|
|
58
|
+
const view = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root);
|
|
59
|
+
const heading = view.entities[1] === undefined ? "" : view.text.slice(view.entities[1].offset, view.entities[1].offset + view.entities[1].length);
|
|
60
|
+
const split = heading.indexOf(" ");
|
|
61
|
+
return { title, icon: heading.slice(0, split), status: heading.slice(split + 1), project: ref.repo, view };
|
|
62
|
+
}
|
|
63
|
+
const fact = store.latestTaskFact(taskRef);
|
|
64
|
+
const words = task?.state === "cancelled" ? BEFORE_RUN["task-cancelled"] : BEFORE_RUN[fact?.kind ?? ""] ?? BEFORE_RUN["task-filed"];
|
|
65
|
+
const heading = `${words.icon} ${words.status}`;
|
|
66
|
+
const text = [title, heading, "", `${projectLabel(ref.repo)} · ${ref.externalId}`].join("\n");
|
|
67
|
+
return { title, icon: words.icon, status: words.status, project: ref.repo, view: {
|
|
68
|
+
text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
69
|
+
link: { label: "Open task", path: chatControlHref("task", ref.externalId) },
|
|
70
|
+
} };
|
|
71
|
+
}
|
|
72
|
+
/** The card's words now: one task's progress card, or one line per task when several were filed together. */
|
|
73
|
+
export function quietCardView(store, taskRefs, now, root) {
|
|
74
|
+
const lines = taskRefs.map(ref => taskLine(store, ref, now, root)).filter((one) => one !== null);
|
|
75
|
+
if (lines.length === 0)
|
|
76
|
+
return null;
|
|
77
|
+
if (lines.length === 1)
|
|
78
|
+
return lines[0].view;
|
|
79
|
+
const heading = `${lines.length} tasks`;
|
|
80
|
+
const projects = [...new Set(lines.map(one => projectLabel(one.project)))];
|
|
81
|
+
const text = [heading, ...lines.map(one => `${one.icon} ${phoneText(one.title, 60)} · ${one.status}`), "", projects.join(", ")].join("\n");
|
|
82
|
+
return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/tasks" } };
|
|
83
|
+
}
|
|
84
|
+
/** The one ping for a Ready result: its state and title, what to do next, and a link to that exact result. */
|
|
85
|
+
export function readyPingView(store, run, taskId, project, now, root) {
|
|
86
|
+
const card = telegramProgressCard(store, run, taskId, project, now, root);
|
|
87
|
+
const [title = "", shown = ""] = card.entities.map(one => card.text.slice(one.offset, one.offset + one.length));
|
|
88
|
+
// A saved result is Ready even in the moment before its worker lets go of the task.
|
|
89
|
+
const saved = shown.endsWith(" Result saved");
|
|
90
|
+
const heading = saved ? "✅ Ready" : shown;
|
|
91
|
+
const next = card.next !== "" ? card.next : saved ? "Next: open the result, then mark it complete or request changes." : "";
|
|
92
|
+
const first = `${heading} · ${title}`;
|
|
93
|
+
return { text: next === "" ? first : `${first}\n${next}`, entities: [{ type: "bold", offset: 0, length: heading.length }], link: card.link };
|
|
94
|
+
}
|
|
95
|
+
// ---- the evening digest ----------------------------------------------------------
|
|
96
|
+
const FAILURE = /fail|exhausted|fenced/;
|
|
97
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
98
|
+
/** This computer's local day and time, which is what a person means by "the evening". */
|
|
99
|
+
export function localDay(now) {
|
|
100
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
101
|
+
}
|
|
102
|
+
export function localTime(now) {
|
|
103
|
+
return `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
104
|
+
}
|
|
105
|
+
/** One person's evening: what finished and what failed in the last day, and what waits for them now. Null when
|
|
106
|
+
* nothing happened, so a quiet day sends nothing. */
|
|
107
|
+
export function eveningDigestText(store, account, now, root) {
|
|
108
|
+
const since = new Date(now.getTime() - 86_400_000).toISOString();
|
|
109
|
+
const recent = new Date(now.getTime() - 14 * 86_400_000).toISOString();
|
|
110
|
+
const visible = (row) => row.project !== null && store.accountCanAccess(account, row.project);
|
|
111
|
+
const title = (taskId) => phoneText(store.getTask(taskId)?.title ?? taskId, 80);
|
|
112
|
+
const finished = new Map(), failed = new Map(), waits = new Map();
|
|
113
|
+
const facts = store.taskFactsSince(recent).filter(visible);
|
|
114
|
+
for (const row of facts) {
|
|
115
|
+
if (row.createdAt < since || row.taskId === null)
|
|
116
|
+
continue;
|
|
117
|
+
if (isLifecycleNotification(row) && row.kind === "run-finished")
|
|
118
|
+
finished.set(row.taskId, title(row.taskId));
|
|
119
|
+
else if (FAILURE.test(row.kind))
|
|
120
|
+
failed.set(row.taskId, title(row.taskId));
|
|
121
|
+
}
|
|
122
|
+
for (const taskId of new Set(facts.map(row => row.taskId).filter((one) => one !== null))) {
|
|
123
|
+
let state = null;
|
|
124
|
+
try {
|
|
125
|
+
const project = facts.find(row => row.taskId === taskId).project;
|
|
126
|
+
state = assignmentOf(store, taskId, now, { principal: "operator", repos: [project] }, root)?.state ?? null;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
state = null;
|
|
130
|
+
}
|
|
131
|
+
if (state === "ready-to-check")
|
|
132
|
+
waits.set(taskId, `${title(taskId)} · Ready for review`);
|
|
133
|
+
else if (state === "needs-decision")
|
|
134
|
+
waits.set(taskId, `${title(taskId)} · Needs your decision`);
|
|
135
|
+
}
|
|
136
|
+
if (finished.size + failed.size + waits.size === 0)
|
|
137
|
+
return null;
|
|
138
|
+
const section = (name, items) => items.size === 0 ? [] : ["", `${name} (${items.size})`, ...[...items.values()].slice(0, 12).map(one => `• ${one}`), ...(items.size > 12 ? [`• and ${items.size - 12} more`] : [])];
|
|
139
|
+
return [...section("Finished", finished), ...section("Waiting for you", waits), ...section("Failed", failed)].slice(1).join("\n");
|
|
140
|
+
}
|
|
141
|
+
/** Record each due evening digest once per person per local day, as one notification addressed to that person:
|
|
142
|
+
* every chat they paired delivers it with the usual receipts, and the console keeps it. */
|
|
143
|
+
export function enqueueEveningDigests(store, now, root) {
|
|
144
|
+
const day = localDay(now), time = localTime(now);
|
|
145
|
+
let queued = 0;
|
|
146
|
+
for (const person of store.eveningDigestPeople()) {
|
|
147
|
+
if (person.digestAt > time || (person.digestOn !== null && person.digestOn >= day))
|
|
148
|
+
continue;
|
|
149
|
+
store.transact(() => {
|
|
150
|
+
if (!store.claimEveningDigestDay(person.account, day))
|
|
151
|
+
return;
|
|
152
|
+
const body = eveningDigestText(store, person.account, now, root);
|
|
153
|
+
if (body === null)
|
|
154
|
+
return;
|
|
155
|
+
if (store.enqueueNotification({ dedupeKey: `digest:evening:${person.account}:${day}`, kind: "evening-digest", subject: "Evening digest", body,
|
|
156
|
+
link: "/tasks", source: { installation: true }, recipient: person.account }, now))
|
|
157
|
+
queued++;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return queued;
|
|
161
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type CheckoutCleanup } from "./storage.js";
|
|
2
|
+
import type { Store } from "./store.js";
|
|
3
|
+
import { type WorktreePool } from "./worktree.js";
|
|
4
|
+
/** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
|
|
5
|
+
export declare const CANDIDATE_KEEP_MS: number;
|
|
6
|
+
/** The worker's pass removes at most this many; a clean-up by hand has no cap. */
|
|
7
|
+
export declare const AUTO_MAX = 20;
|
|
8
|
+
export type KeepWhy = "in use" | "task not finished" | "waiting for review" | "release candidate" | "has changes" | "has commits" | "unreadable" | "git refused" | "not due yet" | "cleanup is off" | "no task";
|
|
9
|
+
export type CheckoutItem = {
|
|
10
|
+
path: string;
|
|
11
|
+
repo: string;
|
|
12
|
+
branch: string;
|
|
13
|
+
taskId: string | null;
|
|
14
|
+
bytes: number;
|
|
15
|
+
releasedAt: string | null;
|
|
16
|
+
/** null: it goes. */
|
|
17
|
+
why: KeepWhy | null;
|
|
18
|
+
/** With "not due yet" or "release candidate": when it may go. */
|
|
19
|
+
dueAt?: string;
|
|
20
|
+
};
|
|
21
|
+
export type CheckoutPlan = {
|
|
22
|
+
at: string;
|
|
23
|
+
cleanup: CheckoutCleanup;
|
|
24
|
+
manual: boolean;
|
|
25
|
+
go: CheckoutItem[];
|
|
26
|
+
stay: CheckoutItem[];
|
|
27
|
+
/** Every checkout on disk. */
|
|
28
|
+
count: number;
|
|
29
|
+
totalBytes: number;
|
|
30
|
+
freeBytes: number;
|
|
31
|
+
waitingReview: number;
|
|
32
|
+
withChanges: number;
|
|
33
|
+
};
|
|
34
|
+
type Status = {
|
|
35
|
+
keep: KeepWhy | null;
|
|
36
|
+
taskId: string;
|
|
37
|
+
finishedAt: string | null;
|
|
38
|
+
dueAt?: string;
|
|
39
|
+
};
|
|
40
|
+
/** Where every task stands for cleanup, by task_ref id and by each branch it or its runs used. */
|
|
41
|
+
export declare function taskStatuses(store: Store, now: Date): {
|
|
42
|
+
byRef: Map<number, Status>;
|
|
43
|
+
byBranch: Map<string, Status>;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* What a clean-up would remove now, how much space it would free, and what stays and why. `manual` is a clean-up by
|
|
47
|
+
* hand (it doesn't wait for the setting); otherwise it is the worker's pass under the setting. Reads only.
|
|
48
|
+
*/
|
|
49
|
+
export declare function checkoutPlan(store: Store, pool: WorktreePool, now: Date, options: {
|
|
50
|
+
manual: boolean;
|
|
51
|
+
repo?: string;
|
|
52
|
+
}): Promise<CheckoutPlan>;
|
|
53
|
+
/** Names exactly what a preview would remove, so a confirmation removes that and nothing else. */
|
|
54
|
+
export declare function previewDigest(plan: Pick<CheckoutPlan, "go">): string;
|
|
55
|
+
export type CleanResult = {
|
|
56
|
+
removed: CheckoutItem[];
|
|
57
|
+
kept: {
|
|
58
|
+
path: string;
|
|
59
|
+
why: KeepWhy;
|
|
60
|
+
}[];
|
|
61
|
+
freed: number;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Remove what may go: the worker's pass (`manual` false: under the setting, at most AUTO_MAX, one repository) or a
|
|
65
|
+
* clean-up by hand (no cap, every repository). Each checkout is asked again right before it goes; each removal is in
|
|
66
|
+
* the ledger.
|
|
67
|
+
*/
|
|
68
|
+
export declare function cleanCheckouts(store: Store, pool: WorktreePool, now: () => Date, options: {
|
|
69
|
+
manual: boolean;
|
|
70
|
+
actor: string;
|
|
71
|
+
repo?: string;
|
|
72
|
+
only?: ReadonlySet<string>;
|
|
73
|
+
}): Promise<CleanResult>;
|
|
74
|
+
/** Throw away a checkout kept for its changes, on purpose; its branch stays. In the ledger. */
|
|
75
|
+
export declare function discardCheckout(store: Store, pool: WorktreePool, path: string, now: Date, actor: string): Promise<{
|
|
76
|
+
ok: true;
|
|
77
|
+
bytes: number;
|
|
78
|
+
} | {
|
|
79
|
+
ok: false;
|
|
80
|
+
message: string;
|
|
81
|
+
}>;
|
|
82
|
+
/** "Waiting for review", "Has changes" … as a sentence fragment for a person. */
|
|
83
|
+
export declare function whyWords(item: Pick<CheckoutItem, "why" | "dueAt">): string;
|
|
84
|
+
export {};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkout cleanup (Settings → Storage, `toolroll storage clean`): which task checkouts can go, and removing them.
|
|
3
|
+
*
|
|
4
|
+
* A finished task's clean checkout goes as the setting says: when its task is complete or cancelled (the default),
|
|
5
|
+
* 2 days or a week after that, or never (only a clean-up by hand). A clean-up by hand doesn't wait for the setting.
|
|
6
|
+
* Only the working copy goes: its branch, and so every commit, stays, and a later lease of the branch makes a new one.
|
|
7
|
+
*
|
|
8
|
+
* Always kept: a checkout in use, one whose task isn't finished (on hold, failed, a revision under way), a result
|
|
9
|
+
* still waiting for review, a release candidate for a week after it was done (a deploy installs from it), a checkout
|
|
10
|
+
* with changes (files Toolroll wrote itself aside), and one with commits on no branch. A checkout no task names
|
|
11
|
+
* (adopted after a crash) goes only in a clean-up by hand. Every removal is in the ledger.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { basename } from "node:path";
|
|
16
|
+
import { taskBranches } from "./names.js";
|
|
17
|
+
import { COMPLETION_ACTION } from "./result-completion.js";
|
|
18
|
+
import { CLEANUP_CHOICES, bytesWords, diskBytes } from "./storage.js";
|
|
19
|
+
import { worktreePath } from "./worktree.js";
|
|
20
|
+
const DAY_MS = 86_400_000;
|
|
21
|
+
/** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
|
|
22
|
+
export const CANDIDATE_KEEP_MS = 7 * DAY_MS;
|
|
23
|
+
/** The worker's pass removes at most this many; a clean-up by hand has no cap. */
|
|
24
|
+
export const AUTO_MAX = 20;
|
|
25
|
+
/** Git inspections at once. */
|
|
26
|
+
const INSPECT_AT_ONCE = 8;
|
|
27
|
+
const STRENGTH = { "in use": 5, "task not finished": 4, "waiting for review": 3, "release candidate": 2 };
|
|
28
|
+
const strength = (one) => one === undefined ? -1 : one.keep === null ? 0 : STRENGTH[one.keep] ?? 1;
|
|
29
|
+
const stronger = (a, b) => strength(b) > strength(a) || (strength(b) === 0 && strength(a) === 0 && (b.finishedAt ?? "") > (a.finishedAt ?? "")) ? b : a;
|
|
30
|
+
/** Where every task stands for cleanup, by task_ref id and by each branch it or its runs used. */
|
|
31
|
+
export function taskStatuses(store, now) {
|
|
32
|
+
const db = store.handle;
|
|
33
|
+
const refs = db.prepare(`SELECT r.id AS ref, r.external_id AS id, r.revision_of AS revision_of, t.state AS state, t.updated_at AS updated_at,
|
|
34
|
+
EXISTS (SELECT 1 FROM hold h WHERE h.task_ref = r.id) AS held,
|
|
35
|
+
EXISTS (SELECT 1 FROM run live WHERE live.task_ref = r.id AND live.finished_at IS NULL) AS live,
|
|
36
|
+
EXISTS (SELECT 1 FROM task_scope s WHERE s.task_id = r.external_id AND s.candidate IS NOT NULL) AS candidate
|
|
37
|
+
FROM task_ref r LEFT JOIN task t ON t.id = r.external_id AND r.backend = 'built-in'`).all();
|
|
38
|
+
const runs = db.prepare("SELECT id, task_ref, branch FROM run").all();
|
|
39
|
+
const refOfRun = new Map(runs.map(row => [Number(row["id"]), Number(row["task_ref"])]));
|
|
40
|
+
// The completion names its family's root; its run names the result it completes.
|
|
41
|
+
const completed = new Map();
|
|
42
|
+
const completedRef = new Map();
|
|
43
|
+
for (const row of db.prepare("SELECT task_id, run_id, at FROM action_ledger WHERE action = ?").all(COMPLETION_ACTION)) {
|
|
44
|
+
const at = String(row["at"]);
|
|
45
|
+
if (row["task_id"] !== null) {
|
|
46
|
+
const id = String(row["task_id"]);
|
|
47
|
+
if ((completed.get(id) ?? "") < at)
|
|
48
|
+
completed.set(id, at);
|
|
49
|
+
}
|
|
50
|
+
const ref = row["run_id"] === null ? undefined : refOfRun.get(Number(row["run_id"]));
|
|
51
|
+
if (ref !== undefined && (completedRef.get(ref) ?? "") < at)
|
|
52
|
+
completedRef.set(ref, at);
|
|
53
|
+
}
|
|
54
|
+
const byRef = new Map();
|
|
55
|
+
const byId = new Map();
|
|
56
|
+
const parentOf = new Map();
|
|
57
|
+
for (const row of refs) {
|
|
58
|
+
const ref = Number(row["ref"]);
|
|
59
|
+
const id = String(row["id"]);
|
|
60
|
+
const state = row["state"] === null ? null : String(row["state"]);
|
|
61
|
+
const updated = row["updated_at"] === null ? null : String(row["updated_at"]);
|
|
62
|
+
let status;
|
|
63
|
+
if (Number(row["live"]) === 1)
|
|
64
|
+
status = { keep: "in use", taskId: id, finishedAt: null };
|
|
65
|
+
else if (state === null || Number(row["held"]) === 1 || (state !== "done" && state !== "cancelled"))
|
|
66
|
+
status = { keep: "task not finished", taskId: id, finishedAt: null };
|
|
67
|
+
else if (state === "cancelled")
|
|
68
|
+
status = { keep: null, taskId: id, finishedAt: updated };
|
|
69
|
+
else {
|
|
70
|
+
const completedAt = [completed.get(id), completedRef.get(ref)].filter((one) => one !== undefined).sort().at(-1) ?? null;
|
|
71
|
+
if (Number(row["candidate"]) === 1) {
|
|
72
|
+
const since = completedAt ?? updated ?? now.toISOString();
|
|
73
|
+
const until = new Date(Date.parse(since) + CANDIDATE_KEEP_MS).toISOString();
|
|
74
|
+
status = until > now.toISOString() ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since };
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
status = completedAt === null ? { keep: "waiting for review", taskId: id, finishedAt: null } : { keep: null, taskId: id, finishedAt: completedAt };
|
|
78
|
+
}
|
|
79
|
+
byRef.set(ref, status);
|
|
80
|
+
byId.set(id, stronger(byId.get(id), status));
|
|
81
|
+
if (row["revision_of"] !== null)
|
|
82
|
+
parentOf.set(id, String(row["revision_of"]));
|
|
83
|
+
}
|
|
84
|
+
// A revision under way keeps what its ancestors worked on: it builds on their branch.
|
|
85
|
+
for (const [id, status] of byId) {
|
|
86
|
+
if (status.keep === null || status.keep === "release candidate")
|
|
87
|
+
continue;
|
|
88
|
+
const seen = new Set([id]);
|
|
89
|
+
for (let parent = parentOf.get(id); parent !== undefined && !seen.has(parent); parent = parentOf.get(parent)) {
|
|
90
|
+
seen.add(parent);
|
|
91
|
+
const lifted = { ...status, taskId: parent };
|
|
92
|
+
byId.set(parent, stronger(byId.get(parent), lifted));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const row of refs)
|
|
96
|
+
byRef.set(Number(row["ref"]), stronger(byRef.get(Number(row["ref"])), { ...byId.get(String(row["id"])), taskId: String(row["id"]) }));
|
|
97
|
+
const byBranch = new Map();
|
|
98
|
+
const note = (branch, status) => byBranch.set(branch, stronger(byBranch.get(branch), status));
|
|
99
|
+
for (const row of refs)
|
|
100
|
+
for (const branch of taskBranches(String(row["id"])))
|
|
101
|
+
note(branch, byRef.get(Number(row["ref"])));
|
|
102
|
+
for (const row of runs) {
|
|
103
|
+
if (row["branch"] === null)
|
|
104
|
+
continue;
|
|
105
|
+
const status = byRef.get(Number(row["task_ref"]));
|
|
106
|
+
if (status !== undefined)
|
|
107
|
+
note(String(row["branch"]), status);
|
|
108
|
+
}
|
|
109
|
+
return { byRef, byBranch };
|
|
110
|
+
}
|
|
111
|
+
/** Where one checkout's task stands: by its task, its branch, or (branch unknown) the name a lease of a branch would give it. */
|
|
112
|
+
function statusOf(row, statuses) {
|
|
113
|
+
let found = stronger(row.taskRef === null ? undefined : statuses.byRef.get(row.taskRef), statuses.byBranch.get(row.branch));
|
|
114
|
+
if (found === undefined) {
|
|
115
|
+
const name = basename(row.path);
|
|
116
|
+
for (const [branch, status] of statuses.byBranch)
|
|
117
|
+
if (basename(worktreePath("/", row.repo, branch)) === name)
|
|
118
|
+
found = stronger(found, status);
|
|
119
|
+
}
|
|
120
|
+
return found;
|
|
121
|
+
}
|
|
122
|
+
/** Why a checkout stays before anyone looks at its files, or null: it may go (as far as its task and the setting say). */
|
|
123
|
+
function keptFor(pool, row, statuses, now, cleanup) {
|
|
124
|
+
const status = statusOf(row, statuses);
|
|
125
|
+
const taskId = status?.taskId ?? null;
|
|
126
|
+
if (row.releasedAt === null || row.runner !== null || pool.inUse(row.path).held)
|
|
127
|
+
return { why: "in use", taskId };
|
|
128
|
+
if (status !== undefined && status.keep !== null)
|
|
129
|
+
return { why: status.keep, taskId, ...(status.dueAt === undefined ? {} : { dueAt: status.dueAt }) };
|
|
130
|
+
if (cleanup === "manual")
|
|
131
|
+
return { why: null, taskId };
|
|
132
|
+
// A checkout no task names (adopted after a crash, say) has no task to finish: only a clean-up by hand takes it.
|
|
133
|
+
if (status === undefined)
|
|
134
|
+
return { why: "no task", taskId };
|
|
135
|
+
if (cleanup === "finished")
|
|
136
|
+
return { why: null, taskId };
|
|
137
|
+
if (cleanup === "never")
|
|
138
|
+
return { why: "cleanup is off", taskId };
|
|
139
|
+
const days = CLEANUP_CHOICES.find(one => one.value === cleanup).days;
|
|
140
|
+
const since = [status.finishedAt, row.releasedAt].filter((one) => one !== null).sort().at(-1);
|
|
141
|
+
const dueAt = new Date(Date.parse(since) + days * DAY_MS).toISOString();
|
|
142
|
+
return dueAt > now.toISOString() ? { why: "not due yet", taskId, dueAt } : { why: null, taskId };
|
|
143
|
+
}
|
|
144
|
+
async function eachAtMost(items, limit, work) {
|
|
145
|
+
const out = new Array(items.length);
|
|
146
|
+
let next = 0;
|
|
147
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
148
|
+
while (next < items.length) {
|
|
149
|
+
const at = next++;
|
|
150
|
+
out[at] = await work(items[at]);
|
|
151
|
+
}
|
|
152
|
+
}));
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* What a clean-up would remove now, how much space it would free, and what stays and why. `manual` is a clean-up by
|
|
157
|
+
* hand (it doesn't wait for the setting); otherwise it is the worker's pass under the setting. Reads only.
|
|
158
|
+
*/
|
|
159
|
+
export async function checkoutPlan(store, pool, now, options) {
|
|
160
|
+
const cleanup = store.checkoutCleanup();
|
|
161
|
+
const rows = store.listWorktrees().filter(row => existsSync(row.path) && (options.repo === undefined || row.repo === options.repo));
|
|
162
|
+
const sizes = diskBytes(rows.map(row => row.path));
|
|
163
|
+
const statuses = taskStatuses(store, now);
|
|
164
|
+
const items = await eachAtMost(rows, INSPECT_AT_ONCE, async (row) => {
|
|
165
|
+
const kept = keptFor(pool, row, statuses, now, options.manual ? "manual" : cleanup);
|
|
166
|
+
let why = kept.why;
|
|
167
|
+
if (why === null) {
|
|
168
|
+
const found = await pool.inspect(row.path);
|
|
169
|
+
why = found === "clean" ? null : found;
|
|
170
|
+
}
|
|
171
|
+
return { path: row.path, repo: row.repo, branch: row.branch, taskId: kept.taskId, bytes: sizes.get(row.path) ?? 0, releasedAt: row.releasedAt, why, ...(kept.dueAt === undefined ? {} : { dueAt: kept.dueAt }) };
|
|
172
|
+
});
|
|
173
|
+
const go = items.filter(one => one.why === null).sort((a, b) => b.bytes - a.bytes);
|
|
174
|
+
const stay = items.filter(one => one.why !== null).sort((a, b) => (a.why ?? "").localeCompare(b.why ?? "") || b.bytes - a.bytes);
|
|
175
|
+
return {
|
|
176
|
+
at: now.toISOString(), cleanup, manual: options.manual, go, stay,
|
|
177
|
+
count: items.length, totalBytes: items.reduce((sum, one) => sum + one.bytes, 0), freeBytes: go.reduce((sum, one) => sum + one.bytes, 0),
|
|
178
|
+
waitingReview: stay.filter(one => one.why === "waiting for review").length, withChanges: stay.filter(one => one.why === "has changes").length,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** Names exactly what a preview would remove, so a confirmation removes that and nothing else. */
|
|
182
|
+
export function previewDigest(plan) {
|
|
183
|
+
return createHash("sha256").update(plan.go.map(one => one.path).sort().join("\u0000"), "utf8").digest("hex").slice(0, 32);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Remove what may go: the worker's pass (`manual` false: under the setting, at most AUTO_MAX, one repository) or a
|
|
187
|
+
* clean-up by hand (no cap, every repository). Each checkout is asked again right before it goes; each removal is in
|
|
188
|
+
* the ledger.
|
|
189
|
+
*/
|
|
190
|
+
export async function cleanCheckouts(store, pool, now, options) {
|
|
191
|
+
const cleanup = store.checkoutCleanup();
|
|
192
|
+
if (!options.manual && cleanup === "never")
|
|
193
|
+
return { removed: [], kept: [], freed: 0 };
|
|
194
|
+
// `only`: the checkouts a person saw in the preview; nothing they didn't see goes.
|
|
195
|
+
const rows = store.listWorktrees().filter(row => existsSync(row.path) && (options.repo === undefined || row.repo === options.repo) && (options.only === undefined || options.only.has(row.path)));
|
|
196
|
+
let statuses = taskStatuses(store, now());
|
|
197
|
+
const sizes = new Map();
|
|
198
|
+
const wanted = (row, fresh) => {
|
|
199
|
+
// Right before a checkout goes, where its task stands is read afresh, and its size taken for the ledger.
|
|
200
|
+
if (fresh)
|
|
201
|
+
statuses = taskStatuses(store, now());
|
|
202
|
+
const go = keptFor(pool, row, statuses, now(), options.manual ? "manual" : cleanup).why === null;
|
|
203
|
+
if (go && fresh)
|
|
204
|
+
sizes.set(row.path, diskBytes([row.path]).get(row.path) ?? 0);
|
|
205
|
+
return go;
|
|
206
|
+
};
|
|
207
|
+
const pruned = await pool.prune(rows, wanted, options.manual ? Infinity : AUTO_MAX);
|
|
208
|
+
const removed = [];
|
|
209
|
+
for (const row of pruned.removed) {
|
|
210
|
+
const taskId = row.taskRef === null ? null : store.externalIdFor(row.taskRef);
|
|
211
|
+
const bytes = sizes.get(row.path) ?? 0;
|
|
212
|
+
removed.push({ path: row.path, repo: row.repo, branch: row.branch, taskId, bytes, releasedAt: row.releasedAt, why: null });
|
|
213
|
+
store.recordAction({ at: now().toISOString(), actor: options.actor, repo: row.repo, taskId, runId: null, action: "checkout removed", outcome: "removed",
|
|
214
|
+
source: options.manual ? "request" : "work", detail: `${basename(row.path)} (released ${row.releasedAt?.slice(0, 10) ?? "?"}, about ${bytesWords(bytes)}); branch ${row.branch} kept` });
|
|
215
|
+
}
|
|
216
|
+
return { removed, kept: pruned.kept, freed: removed.reduce((sum, one) => sum + one.bytes, 0) };
|
|
217
|
+
}
|
|
218
|
+
/** Throw away a checkout kept for its changes, on purpose; its branch stays. In the ledger. */
|
|
219
|
+
export async function discardCheckout(store, pool, path, now, actor) {
|
|
220
|
+
const bytes = existsSync(path) ? diskBytes([path]).get(path) ?? 0 : 0;
|
|
221
|
+
const done = await pool.discardChanges(path);
|
|
222
|
+
if (!done.ok)
|
|
223
|
+
return done;
|
|
224
|
+
const taskId = done.row.taskRef === null ? null : store.externalIdFor(done.row.taskRef);
|
|
225
|
+
store.recordAction({ at: now.toISOString(), actor, repo: done.row.repo, taskId, runId: null, action: "checkout discarded", outcome: "removed", source: "request",
|
|
226
|
+
detail: `${basename(path)} and its uncommitted changes (about ${bytesWords(bytes)}); branch ${done.row.branch} kept` });
|
|
227
|
+
return { ok: true, bytes };
|
|
228
|
+
}
|
|
229
|
+
/** "Waiting for review", "Has changes" … as a sentence fragment for a person. */
|
|
230
|
+
export function whyWords(item) {
|
|
231
|
+
switch (item.why) {
|
|
232
|
+
case null: return "goes";
|
|
233
|
+
case "in use": return "in use";
|
|
234
|
+
case "task not finished": return "its task isn't finished";
|
|
235
|
+
case "waiting for review": return "its result is waiting for review";
|
|
236
|
+
case "release candidate": return `release candidate, kept until ${item.dueAt?.slice(0, 10) ?? "a week after"}`;
|
|
237
|
+
case "has changes": return "has changes";
|
|
238
|
+
case "has commits": return "has commits on no branch";
|
|
239
|
+
case "unreadable": return "git couldn't read it";
|
|
240
|
+
case "git refused": return "git wouldn't remove it";
|
|
241
|
+
case "not due yet": return `due ${item.dueAt?.slice(0, 10) ?? "later"}`;
|
|
242
|
+
case "cleanup is off": return "automatic cleanup is off";
|
|
243
|
+
case "no task": return "no task names it; only a clean-up by hand removes it";
|
|
244
|
+
}
|
|
245
|
+
}
|