toolroll 0.9.10 → 0.9.12
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 +25 -0
- package/dist/assignment.d.ts +3 -0
- package/dist/assignment.js +3 -1
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +20 -18
- package/dist/browser-workspace.d.ts +61 -0
- package/dist/chat-channel.d.ts +9 -7
- package/dist/chat-channel.js +23 -17
- package/dist/chat-delivery.js +21 -0
- package/dist/chat-quiet.js +1 -0
- package/dist/chat-voice.d.ts +3 -1
- package/dist/chat-voice.js +13 -5
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -0
- package/dist/coding-update.d.ts +13 -0
- package/dist/coding-update.js +68 -0
- package/dist/desktop-update.d.ts +7 -0
- package/dist/desktop-update.js +42 -1
- package/dist/flow-triggers.js +17 -1
- package/dist/guides.js +9 -0
- package/dist/lead-status.d.ts +10 -1
- package/dist/lead-status.js +13 -3
- package/dist/lead-voice.d.ts +81 -0
- package/dist/lead-voice.js +275 -0
- package/dist/needs-you.d.ts +7 -0
- package/dist/needs-you.js +9 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +186 -38
- package/dist/serve.d.ts +5 -1
- package/dist/serve.js +447 -226
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +15 -1
- package/dist/store.js +38 -1
- package/dist/surface.js +8 -1
- package/dist/task-status.d.ts +6 -0
- package/dist/task-status.js +19 -2
- package/dist/team-cli.js +1 -1
- package/dist/telegram-mate.d.ts +7 -0
- package/dist/telegram-mate.js +50 -21
- package/dist/telegram-progress.js +7 -0
- package/dist/telegram-settings.d.ts +3 -0
- package/dist/telegram-settings.js +11 -0
- package/dist/telegram.d.ts +14 -0
- package/dist/telegram.js +105 -12
- package/dist/test-temp.js +1 -1
- package/dist/toolroll-update.d.ts +3 -0
- package/dist/toolroll-update.js +13 -2
- package/dist/work-index.d.ts +9 -0
- package/dist/work-index.js +57 -20
- package/dist/work-summary.d.ts +4 -1
- package/dist/workspace-ui.d.ts +25 -0
- package/dist/workspace-ui.js +20 -1
- package/package.json +1 -1
package/dist/flow-triggers.js
CHANGED
|
@@ -446,7 +446,13 @@ function polled(config) {
|
|
|
446
446
|
function startZone(config, definition) {
|
|
447
447
|
return config.zone !== null && definition.stages.some(one => one.id === config.zone) ? config.zone : definition.start;
|
|
448
448
|
}
|
|
449
|
-
/**
|
|
449
|
+
/** The active flow that already made a card for this failing commit in a project, if any. */
|
|
450
|
+
function checksCardOf(store, repo, key) {
|
|
451
|
+
const row = store.handle.prepare(`SELECT f.name FROM flow_trigger_event e JOIN flow_trigger t ON t.id = e.trigger JOIN flow f ON f.id = t.flow
|
|
452
|
+
WHERE f.repo = ? AND f.state = 'active' AND e.key = ? AND e.card IS NOT NULL LIMIT 1`).get(repo, key);
|
|
453
|
+
return row === undefined ? null : `“${String(row["name"])}”`;
|
|
454
|
+
}
|
|
455
|
+
/** One outside thing into one card, at most once per trigger (a failing run, once per project). Keys and control characters never reach a card. */
|
|
450
456
|
function makeCard(store, trigger, config, item, by, now) {
|
|
451
457
|
const flow = store.getFlow(trigger.flow);
|
|
452
458
|
const definition = flow === null ? null : flowDefinitionOf(flow);
|
|
@@ -460,7 +466,17 @@ function makeCard(store, trigger, config, item, by, now) {
|
|
|
460
466
|
store.recordFlowTriggerEvent(trigger.id, item.key, null, note, now);
|
|
461
467
|
return { made: "skipped", note: `${item.source.label}: ${note}`, card: null };
|
|
462
468
|
}
|
|
469
|
+
// Claimed inside the write lock: a worker and a console (or an old and a new worker) checking at once make one card.
|
|
463
470
|
return store.transact(() => {
|
|
471
|
+
if (store.flowTriggerSaw(trigger.id, item.key))
|
|
472
|
+
return { made: "seen", note: null, card: null };
|
|
473
|
+
// One failing run is one fix: a second trigger watching the same checks in this project leaves it to the first.
|
|
474
|
+
const filed = item.key.startsWith("checks:") ? checksCardOf(store, flow.repo, item.key) : null;
|
|
475
|
+
if (filed !== null) {
|
|
476
|
+
const note = `already a card in ${filed}`;
|
|
477
|
+
store.recordFlowTriggerEvent(trigger.id, item.key, null, note, now);
|
|
478
|
+
return { made: "skipped", note: `${item.source.label}: ${note}`, card: null };
|
|
479
|
+
}
|
|
464
480
|
const card = store.addFlowCard({ flow: flow.id, title: text.title, description: text.description, stage: startZone(config, definition), by, source: item.source }, now);
|
|
465
481
|
store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now);
|
|
466
482
|
return { made: "added", note: null, card };
|
package/dist/guides.js
CHANGED
|
@@ -95,6 +95,15 @@ login, so your housekeeping never pings them.
|
|
|
95
95
|
or for a security alert.
|
|
96
96
|
- Hand a task to a person: \`task ask <id> --person <name> --why "<what
|
|
97
97
|
they need to do>"\` — one message, and the task is theirs from then on.
|
|
98
|
+
- Tell your person what you are doing, at milestones: \`lead say "<one
|
|
99
|
+
short line>" [--task <id>]\` when you start work, when you are handling a
|
|
100
|
+
failure, and when you are done. Plain words ("Fixing the release check's
|
|
101
|
+
failing test"); several within two minutes become one message.
|
|
102
|
+
- Take on a task you are fixing: \`assignment claim <id>\`. It reads "Your
|
|
103
|
+
lead is on it" instead of waiting for them and leaves Needs you until you
|
|
104
|
+
complete it, hand it over with \`task ask\`, or go two hours without
|
|
105
|
+
acting on it (then it is back with them, and says so). Claim a failure
|
|
106
|
+
before you start on it, and \`lead say\` what you are doing.
|
|
98
107
|
- Re-filing (a release check re-gated as \`release-<name>b\`): \`task add
|
|
99
108
|
"<title>" --id <new> --replaces <old>\`, or \`task state <old> cancelled
|
|
100
109
|
--replaced-by <new>\`. The old task reads "Replaced by <new>", never
|
package/dist/lead-status.d.ts
CHANGED
|
@@ -76,6 +76,14 @@ export type InstallationStatus = {
|
|
|
76
76
|
headline: string;
|
|
77
77
|
sentence: string;
|
|
78
78
|
}[];
|
|
79
|
+
/** What the person's lead is doing now and when it last acted (lead-voice.ts), as one line. */
|
|
80
|
+
lead: {
|
|
81
|
+
owner: string;
|
|
82
|
+
doing: string;
|
|
83
|
+
at: string;
|
|
84
|
+
task: string | null;
|
|
85
|
+
line: string;
|
|
86
|
+
} | null;
|
|
79
87
|
};
|
|
80
88
|
/** One indexed observation for `task wait`. `watchedRun` is the attempt the
|
|
81
89
|
* caller first saw. Each poll follows the task's newest attempt from there, so
|
|
@@ -93,6 +101,7 @@ export declare function repairStaleStatuses(store: Store, now: Date): {
|
|
|
93
101
|
exitsRecorded: number;
|
|
94
102
|
readyForReview: number;
|
|
95
103
|
};
|
|
96
|
-
|
|
104
|
+
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead. */
|
|
105
|
+
export declare function installationStatus(store: Store, now: Date, viewer?: string | null): InstallationStatus;
|
|
97
106
|
export declare function renderInstallationStatus(status: InstallationStatus): string[];
|
|
98
107
|
export declare function renderTaskWait(snapshot: TaskWaitSnapshot, outcome?: string): string;
|
package/dist/lead-status.js
CHANGED
|
@@ -5,6 +5,8 @@ import { windowLabel } from "./provider-limits.js";
|
|
|
5
5
|
import { openAuthPauses, signInCommand, signInReason } from "./provider-auth.js";
|
|
6
6
|
import { BUILT_IN, checkSuitesOf } from "./store.js";
|
|
7
7
|
import { workIndexPage, workReadyForReview } from "./work-index.js";
|
|
8
|
+
import { currentActor } from "./actor.js";
|
|
9
|
+
import { leadActivity, leadActivityLine } from "./lead-voice.js";
|
|
8
10
|
const maybeNumber = (value) => value === null || value === undefined || !Number.isInteger(Number(value)) ? null : Number(value);
|
|
9
11
|
const CHECK_COLUMNS = "rc.status AS check_status, rc.exit_code AS check_exit_code, rc.suites_json AS check_suites_json";
|
|
10
12
|
function checkOf(row) {
|
|
@@ -177,7 +179,8 @@ export function repairStaleStatuses(store, now) {
|
|
|
177
179
|
const exitsRecorded = store.recordFinishedRunExits(now);
|
|
178
180
|
return { exitsRecorded, readyForReview: workReadyForReview(store, now, { principal: "operator", repos: null, includeUnplaced: true }, 0).count };
|
|
179
181
|
}
|
|
180
|
-
|
|
182
|
+
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead. */
|
|
183
|
+
export function installationStatus(store, now, viewer = currentActor()?.account ?? null) {
|
|
181
184
|
const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.external_id AS task
|
|
182
185
|
FROM run INDEXED BY work_unfinished
|
|
183
186
|
JOIN task_ref AS ref ON ref.id = run.task_ref
|
|
@@ -251,8 +254,13 @@ export function installationStatus(store, now) {
|
|
|
251
254
|
observedAt: String(row["observed_at"]),
|
|
252
255
|
})),
|
|
253
256
|
signIn: pauses.map(one => ({ provider: one.provider, reason: signInReason(one), command: signInCommand(one), since: one.openedAt })),
|
|
254
|
-
tasks: workIndexPage(store, now, { principal: "operator", repos: null, includeUnplaced: true }, { limit: 8 }).items
|
|
257
|
+
tasks: workIndexPage(store, now, { principal: "operator", repos: null, includeUnplaced: true, viewer }, { limit: 8 }).items
|
|
255
258
|
.map(one => ({ task: one.rootId, title: one.title, headline: one.status.label, sentence: one.status.detail })),
|
|
259
|
+
lead: (() => {
|
|
260
|
+
// Only the asker's own lead: another person's lead is never "Your lead".
|
|
261
|
+
const activity = leadActivity(store, viewer);
|
|
262
|
+
return activity === null ? null : { owner: activity.owner, doing: activity.doing, at: activity.at, task: activity.taskId, line: leadActivityLine(activity, now) };
|
|
263
|
+
})(),
|
|
256
264
|
};
|
|
257
265
|
}
|
|
258
266
|
export function renderInstallationStatus(status) {
|
|
@@ -260,6 +268,8 @@ export function renderInstallationStatus(status) {
|
|
|
260
268
|
// A paused provider comes first: it is the one thing a person must do.
|
|
261
269
|
for (const one of status.signIn)
|
|
262
270
|
lines.push(`${one.reason} — run \`${one.command}\`. Its tasks wait until then.`);
|
|
271
|
+
if (status.lead != null)
|
|
272
|
+
lines.push(status.lead.line);
|
|
263
273
|
// Each task's one headline, the same words as the console and chat cards.
|
|
264
274
|
if (status.tasks.length > 0) {
|
|
265
275
|
lines.push("Tasks:");
|
|
@@ -295,7 +305,7 @@ export function renderInstallationStatus(status) {
|
|
|
295
305
|
lines.push(`Plan windows: ${provider} — ${detail}`);
|
|
296
306
|
}
|
|
297
307
|
}
|
|
298
|
-
return lines.slice(0, 12 + status.signIn.length + (status.tasks.length === 0 ? 0 : status.tasks.length + 1));
|
|
308
|
+
return lines.slice(0, 12 + status.signIn.length + (status.lead == null ? 0 : 1) + (status.tasks.length === 0 ? 0 : status.tasks.length + 1));
|
|
299
309
|
}
|
|
300
310
|
export function renderTaskWait(snapshot, outcome = snapshot.outcome) {
|
|
301
311
|
const check = snapshot.check.status === "unknown" ? "checks unknown" : snapshot.check.status === "not-run" ? "checks not run" : `checks ${snapshot.check.status}${snapshot.check.exitCode === null ? "" : ` (exit ${snapshot.check.exitCode})`}`;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** The lead's voice: what the person's lead (an agent under their lead token) is doing, in their chat and on the
|
|
2
|
+
* console. `lead say` posts one short message from "Your lead"; several within two minutes are one message, edited
|
|
3
|
+
* in place as it grows. `assignment claim` marks a task the lead's: it reads "Your lead is on it" and leaves Needs
|
|
4
|
+
* you until the lead completes it, hands it to a person, or two hours pass with no lead act on it.
|
|
5
|
+
*
|
|
6
|
+
* Every fact is an append-only ledger row under "lead for <owner>"; no table of its own. The chat message is an
|
|
7
|
+
* ordinary notification addressed to the owner, so every chat delivers it with the usual receipts. */
|
|
8
|
+
import { type Actor } from "./actor.js";
|
|
9
|
+
import { type Notification, type Store } from "./store.js";
|
|
10
|
+
/** Who the person's chat says the lead's messages are from. */
|
|
11
|
+
export declare const LEAD_NAME = "Your lead";
|
|
12
|
+
export declare const LEAD_SAY_KIND = "lead-say";
|
|
13
|
+
/** Repaints the owner's task card when the lead takes a task on: it never makes a new message in quiet chat. */
|
|
14
|
+
export declare const LEAD_ON_IT_KIND = "lead-on-it";
|
|
15
|
+
/** Repaints the owner's task card when a claim lapses: the card reads its real state again, and says so. */
|
|
16
|
+
export declare const LEAD_LAPSED_KIND = "lead-lapsed";
|
|
17
|
+
export declare const LEAD_SAID = "lead said";
|
|
18
|
+
export declare const LEAD_CLAIMED = "lead is on it";
|
|
19
|
+
/** A claim lapses after this long without a lead act on the task. */
|
|
20
|
+
export declare const LEAD_IDLE_MS: number;
|
|
21
|
+
/** The lead's acts that hand the task back: done, handed to a person, or cancelled. */
|
|
22
|
+
export declare const LEAD_ENDS: readonly ["task completed", "task handed to a person", "task cancelled"];
|
|
23
|
+
export declare const LEAD_SAY_MAX = 300;
|
|
24
|
+
/** The work index's reading of a claim, per row of its `classified` projection: 0 none, 1 on it, 2 lapsed. Only the
|
|
25
|
+
* viewer's own lead counts (`$leadOf`, "lead for <viewer>"; null for nobody): another person's lead is not yours. */
|
|
26
|
+
export declare const LEAD_CLAIM_SQL: string;
|
|
27
|
+
export type LeadClaim = {
|
|
28
|
+
state: "on-it" | "lapsed";
|
|
29
|
+
owner: string;
|
|
30
|
+
since: string;
|
|
31
|
+
lastActedAt: string;
|
|
32
|
+
};
|
|
33
|
+
export type LeadActivity = {
|
|
34
|
+
owner: string;
|
|
35
|
+
doing: string;
|
|
36
|
+
at: string;
|
|
37
|
+
taskId: string | null;
|
|
38
|
+
};
|
|
39
|
+
type Failure = {
|
|
40
|
+
ok: false;
|
|
41
|
+
reason: "usage" | "unknown-task" | "refused";
|
|
42
|
+
message: string;
|
|
43
|
+
};
|
|
44
|
+
/** One line of the lead's words: plain, short, no control characters. */
|
|
45
|
+
export declare function leadWordsProblem(text: string): string | null;
|
|
46
|
+
/** `lead say`: record the words as the lead's act, and post them in the owner's chat. Within two minutes of the
|
|
47
|
+
* first, a later say joins it: the new row carries every line, and the earlier one stops waiting to be sent (a
|
|
48
|
+
* chat that already showed it edits that message in place). */
|
|
49
|
+
export declare function leadSay(store: Store, actor: Actor, text: string, taskId: string | null, now: Date): {
|
|
50
|
+
ok: true;
|
|
51
|
+
joined: boolean;
|
|
52
|
+
notification: number | null;
|
|
53
|
+
} | Failure;
|
|
54
|
+
/** The words a chat shows for a lead-say row: "Your lead", then each line, plain, with no task id. */
|
|
55
|
+
export declare function leadSayText(row: Pick<Notification, "subject" | "body">): string;
|
|
56
|
+
/** The earlier rows of a lead-say message (newest first): a chat that showed one edits it rather than sending again. */
|
|
57
|
+
export declare function leadSayEarlier(store: Store, row: Pick<Notification, "id" | "kind" | "dedupeKey" | "recipient">): number[];
|
|
58
|
+
/** `assignment claim` under the lead token: the task is the lead's until it completes it, hands it on, or goes
|
|
59
|
+
* quiet on it for two hours. Repeating a claim is safe and counts as a lead act. */
|
|
60
|
+
export declare function leadClaim(store: Store, actor: Actor, taskId: string, now: Date): {
|
|
61
|
+
ok: true;
|
|
62
|
+
root: string;
|
|
63
|
+
claim: LeadClaim;
|
|
64
|
+
} | Failure;
|
|
65
|
+
/** Whether `viewer`'s own lead has this task now: on it, lapsed after two quiet hours (it says so), or null (never
|
|
66
|
+
* claimed by their lead, handed on by it since, or completed or cancelled by anyone). Another person's lead never counts. */
|
|
67
|
+
export declare function leadClaimOf(store: Store, taskId: string, now: Date, viewer: string | null | undefined): LeadClaim | null;
|
|
68
|
+
/** A claim that lapsed repaints its owner's card once per lapse: the card reads the task's real state again and
|
|
69
|
+
* says it is back with them. Run beside the evening digests on every chat pass; claims are found through their
|
|
70
|
+
* repaint rows (an indexed key range), so nothing scans the ledger. */
|
|
71
|
+
export declare function enqueueLeadLapses(store: Store, now: Date): number;
|
|
72
|
+
/** The lead's act on a task that a claim counts (a retry, a state change, a steer): one ledger row as the lead. */
|
|
73
|
+
export declare function noteLeadWork(store: Store, actor: Actor, taskId: string, what: string, now: Date): void;
|
|
74
|
+
/** What `owner`'s own lead is doing now and when it last acted, both from its one newest act: its words when that
|
|
75
|
+
* was a say, else the act in words. Nobody (null) has no lead line: another person's lead is never "Your lead". */
|
|
76
|
+
export declare function leadActivity(store: Store, owner: string | null | undefined): LeadActivity | null;
|
|
77
|
+
/** "just now", "3 min ago", "2 h ago", "yesterday", "4 days ago". */
|
|
78
|
+
export declare function agoWords(at: string, now: Date): string;
|
|
79
|
+
/** The one line `toolroll status` and the console say: "Your lead: fixing 0.9.12's release check · 3 min ago". */
|
|
80
|
+
export declare function leadActivityLine(activity: LeadActivity, now: Date): string;
|
|
81
|
+
export {};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/** The lead's voice: what the person's lead (an agent under their lead token) is doing, in their chat and on the
|
|
2
|
+
* console. `lead say` posts one short message from "Your lead"; several within two minutes are one message, edited
|
|
3
|
+
* in place as it grows. `assignment claim` marks a task the lead's: it reads "Your lead is on it" and leaves Needs
|
|
4
|
+
* you until the lead completes it, hands it to a person, or two hours pass with no lead act on it.
|
|
5
|
+
*
|
|
6
|
+
* Every fact is an append-only ledger row under "lead for <owner>"; no table of its own. The chat message is an
|
|
7
|
+
* ordinary notification addressed to the owner, so every chat delivers it with the usual receipts. */
|
|
8
|
+
import { actorLabel, withActor } from "./actor.js";
|
|
9
|
+
import { chatControlHref } from "./chat-controls.js";
|
|
10
|
+
import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
|
|
11
|
+
import { BUILT_IN } from "./store.js";
|
|
12
|
+
import { LEAD_LAPSED } from "./task-status.js";
|
|
13
|
+
import { phoneText as plain } from "./telegram-status.js";
|
|
14
|
+
/** Who the person's chat says the lead's messages are from. */
|
|
15
|
+
export const LEAD_NAME = "Your lead";
|
|
16
|
+
export const LEAD_SAY_KIND = "lead-say";
|
|
17
|
+
/** Repaints the owner's task card when the lead takes a task on: it never makes a new message in quiet chat. */
|
|
18
|
+
export const LEAD_ON_IT_KIND = "lead-on-it";
|
|
19
|
+
/** Repaints the owner's task card when a claim lapses: the card reads its real state again, and says so. */
|
|
20
|
+
export const LEAD_LAPSED_KIND = "lead-lapsed";
|
|
21
|
+
export const LEAD_SAID = "lead said";
|
|
22
|
+
export const LEAD_CLAIMED = "lead is on it";
|
|
23
|
+
/** A claim lapses after this long without a lead act on the task. */
|
|
24
|
+
export const LEAD_IDLE_MS = 2 * 3_600_000;
|
|
25
|
+
/** The lead's acts that hand the task back: done, handed to a person, or cancelled. */
|
|
26
|
+
export const LEAD_ENDS = ["task completed", "task handed to a person", "task cancelled"];
|
|
27
|
+
/** Acts by anyone that end a claim: a completed or cancelled task is nobody's to be on. */
|
|
28
|
+
const DONE_ACTS_SQL = "'completed','cancelled'";
|
|
29
|
+
export const LEAD_SAY_MAX = 300;
|
|
30
|
+
/** Lines one batched message keeps; the oldest go first. */
|
|
31
|
+
const BATCH_LINES = 8;
|
|
32
|
+
const ENDS_SQL = LEAD_ENDS.map(one => `'${one}'`).join(",");
|
|
33
|
+
/** The work index's reading of a claim, per row of its `classified` projection: 0 none, 1 on it, 2 lapsed. Only the
|
|
34
|
+
* viewer's own lead counts (`$leadOf`, "lead for <viewer>"; null for nobody): another person's lead is not yours. */
|
|
35
|
+
export const LEAD_CLAIM_SQL = `COALESCE((SELECT CASE
|
|
36
|
+
WHEN EXISTS(SELECT 1 FROM action_ledger e WHERE e.task_id IN (SELECT m.id FROM ordered m WHERE m.root_ref = c.root_ref)
|
|
37
|
+
AND e.id > claim.id AND e.actor = claim.actor AND e.action IN (${ENDS_SQL})) THEN 0
|
|
38
|
+
WHEN EXISTS(SELECT 1 FROM task_act t WHERE t.task_ref IN (SELECT m.ref_id FROM ordered m WHERE m.root_ref = c.root_ref)
|
|
39
|
+
AND t.act IN (${DONE_ACTS_SQL}) AND t.at >= claim.at) THEN 0
|
|
40
|
+
WHEN (SELECT MAX(a.at) FROM action_ledger a WHERE a.task_id IN (SELECT m.id FROM ordered m WHERE m.root_ref = c.root_ref)
|
|
41
|
+
AND a.id >= claim.id AND a.actor = claim.actor) > $leadIdleSince THEN 1 ELSE 2 END
|
|
42
|
+
FROM action_ledger claim WHERE claim.id = (SELECT MAX(id) FROM action_ledger WHERE task_id = c.root_id AND action = '${LEAD_CLAIMED}' AND actor = $leadOf)), 0)`;
|
|
43
|
+
/** A task family's root and every version, following revisions both ways. */
|
|
44
|
+
function familyOf(store, taskId) {
|
|
45
|
+
const root = store.handle.prepare(`WITH RECURSIVE up(id, depth) AS (SELECT ?, 0 UNION ALL
|
|
46
|
+
SELECT r.revision_of, up.depth + 1 FROM up JOIN task_ref r ON r.backend = ? AND r.external_id = up.id WHERE r.revision_of IS NOT NULL AND up.depth < 64)
|
|
47
|
+
SELECT id FROM up ORDER BY depth DESC LIMIT 1`).get(taskId, BUILT_IN);
|
|
48
|
+
const top = root === undefined ? taskId : String(root["id"]);
|
|
49
|
+
const ids = store.handle.prepare(`WITH RECURSIVE down(id, depth) AS (SELECT ?, 0 UNION ALL
|
|
50
|
+
SELECT r.external_id, down.depth + 1 FROM down JOIN task_ref r ON r.backend = ? AND r.revision_of = down.id WHERE down.depth < 64)
|
|
51
|
+
SELECT DISTINCT id FROM down`).all(top, BUILT_IN).map(row => String(row["id"]));
|
|
52
|
+
return { root: top, ids };
|
|
53
|
+
}
|
|
54
|
+
/** One line of the lead's words: plain, short, no control characters. */
|
|
55
|
+
export function leadWordsProblem(text) {
|
|
56
|
+
const words = text.trim();
|
|
57
|
+
if (words === "")
|
|
58
|
+
return "Say something: `toolroll lead say \"<what you're doing>\"`.";
|
|
59
|
+
if (words.length > LEAD_SAY_MAX)
|
|
60
|
+
return `Keep it short: at most ${LEAD_SAY_MAX} characters.`;
|
|
61
|
+
if (/[\u0000-\u001f\u007f]/.test(words))
|
|
62
|
+
return "One line of plain text.";
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/** The task the lead names, if its owner can see it. */
|
|
66
|
+
function admittedTask(store, owner, taskId) {
|
|
67
|
+
const ref = store.lookupRef(taskId);
|
|
68
|
+
if (ref === null || store.getTask(taskId) === null)
|
|
69
|
+
return { ok: false, reason: "unknown-task", message: `No task \`${taskId}\`.` };
|
|
70
|
+
if (ref.repo !== null && !store.accountCanAccess(owner, ref.repo))
|
|
71
|
+
return { ok: false, reason: "unknown-task", message: `No task \`${taskId}\`.` };
|
|
72
|
+
return { refId: ref.id, repo: ref.repo };
|
|
73
|
+
}
|
|
74
|
+
/** `lead say`: record the words as the lead's act, and post them in the owner's chat. Within two minutes of the
|
|
75
|
+
* first, a later say joins it: the new row carries every line, and the earlier one stops waiting to be sent (a
|
|
76
|
+
* chat that already showed it edits that message in place). */
|
|
77
|
+
export function leadSay(store, actor, text, taskId, now) {
|
|
78
|
+
if (!actor.lead)
|
|
79
|
+
return { ok: false, reason: "refused", message: "Only the lead speaks as the lead: pass its lead token." };
|
|
80
|
+
const problem = leadWordsProblem(text);
|
|
81
|
+
if (problem !== null)
|
|
82
|
+
return { ok: false, reason: "usage", message: problem };
|
|
83
|
+
const task = taskId === null ? null : admittedTask(store, actor.account, taskId);
|
|
84
|
+
if (task !== null && "ok" in task)
|
|
85
|
+
return task;
|
|
86
|
+
// The task's own id never reaches a chat: it reads as the task's short title (chat-voice.ts).
|
|
87
|
+
const said = taskId === null ? text.trim() : chatText(text.trim(), [{ id: taskId, title: chatTitle(store, taskId) }]);
|
|
88
|
+
return store.transact(() => {
|
|
89
|
+
const stamp = now.toISOString();
|
|
90
|
+
const ledger = store.recordAction({ at: stamp, actor: actorLabel(actor), repo: task?.repo ?? null, taskId, runId: null,
|
|
91
|
+
action: LEAD_SAID, outcome: "recorded", source: "work", detail: text.trim() });
|
|
92
|
+
const open = store.handle.prepare(`SELECT id, dedupe_key, body FROM notification WHERE kind = ? AND recipient = ? ORDER BY id DESC LIMIT 1`)
|
|
93
|
+
.get(LEAD_SAY_KIND, actor.account);
|
|
94
|
+
const opened = open === undefined ? null : /^lead-say:(.+):\d+$/.exec(String(open["dedupe_key"]))?.[1] ?? null;
|
|
95
|
+
const joined = open !== undefined && opened !== null && Date.parse(opened) > now.getTime() - BATCH_MS && Date.parse(opened) <= now.getTime();
|
|
96
|
+
const lines = [...(joined ? String(open["body"]).split("\n") : []), said].slice(-BATCH_LINES);
|
|
97
|
+
if (joined)
|
|
98
|
+
store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL").run(stamp, Number(open["id"]));
|
|
99
|
+
const key = `lead-say:${joined ? opened : stamp}:${ledger}`;
|
|
100
|
+
store.enqueueNotification({ dedupeKey: key, kind: LEAD_SAY_KIND, subject: LEAD_NAME, body: lines.join("\n"),
|
|
101
|
+
recipient: actor.account, source: { installation: true }, ...(taskId === null ? {} : { link: chatControlHref("task", taskId) }) }, now);
|
|
102
|
+
const row = store.handle.prepare("SELECT id FROM notification WHERE dedupe_key = ?").get(key);
|
|
103
|
+
return { ok: true, joined, notification: row === undefined ? null : Number(row["id"]) };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** The words a chat shows for a lead-say row: "Your lead", then each line, plain, with no task id. */
|
|
107
|
+
export function leadSayText(row) {
|
|
108
|
+
return chatText([plain(row.subject, 60), "", ...row.body.split("\n").map(line => plain(line, LEAD_SAY_MAX)).filter(line => line !== "")].join("\n"));
|
|
109
|
+
}
|
|
110
|
+
/** The earlier rows of a lead-say message (newest first): a chat that showed one edits it rather than sending again. */
|
|
111
|
+
export function leadSayEarlier(store, row) {
|
|
112
|
+
if (row.kind !== LEAD_SAY_KIND || row.recipient === null)
|
|
113
|
+
return [];
|
|
114
|
+
const opened = /^(lead-say:.+:)\d+$/.exec(row.dedupeKey)?.[1];
|
|
115
|
+
if (opened === undefined)
|
|
116
|
+
return [];
|
|
117
|
+
return store.handle.prepare(`SELECT id FROM notification WHERE kind = ? AND recipient = ? AND id < ? AND substr(dedupe_key, 1, ?) = ? ORDER BY id DESC`)
|
|
118
|
+
.all(LEAD_SAY_KIND, row.recipient, row.id, opened.length, opened).map(one => Number(one["id"]));
|
|
119
|
+
}
|
|
120
|
+
/** `assignment claim` under the lead token: the task is the lead's until it completes it, hands it on, or goes
|
|
121
|
+
* quiet on it for two hours. Repeating a claim is safe and counts as a lead act. */
|
|
122
|
+
export function leadClaim(store, actor, taskId, now) {
|
|
123
|
+
if (!actor.lead)
|
|
124
|
+
return { ok: false, reason: "refused", message: "Only the lead takes a task on as the lead: pass its lead token." };
|
|
125
|
+
const task = admittedTask(store, actor.account, taskId);
|
|
126
|
+
if ("ok" in task)
|
|
127
|
+
return task;
|
|
128
|
+
const { root } = familyOf(store, taskId);
|
|
129
|
+
store.transact(() => {
|
|
130
|
+
const ledger = store.recordAction({ at: now.toISOString(), actor: actorLabel(actor), repo: task.repo, taskId: root, runId: null,
|
|
131
|
+
action: LEAD_CLAIMED, outcome: "recorded", source: "work" });
|
|
132
|
+
// The owner's card for this task repaints to "Your lead is on it": quiet chat edits its card, and every-update
|
|
133
|
+
// chat edits the attempt's own card (the Failed alert), or follows it up when that message is gone.
|
|
134
|
+
store.enqueueNotification({ dedupeKey: `lead-on-it:${ledger}`, kind: LEAD_ON_IT_KIND, subject: "Your lead is on it", body: "",
|
|
135
|
+
link: chatControlHref("task", taskId), source: cardSource(store, root) ?? { taskRef: task.refId } }, now);
|
|
136
|
+
});
|
|
137
|
+
return { ok: true, root, claim: leadClaimOf(store, taskId, now, actor.account) };
|
|
138
|
+
}
|
|
139
|
+
/** Where a task's chat card lives: the family's newest attempt (the card every-update chat edits), else its newest
|
|
140
|
+
* version. Null for a task with no reference. */
|
|
141
|
+
function cardSource(store, root) {
|
|
142
|
+
const refs = familyOf(store, root).ids.map(id => store.lookupRef(id)).filter(ref => ref !== null).sort((a, b) => b.id - a.id);
|
|
143
|
+
const newest = refs[0];
|
|
144
|
+
if (newest === undefined)
|
|
145
|
+
return null;
|
|
146
|
+
const runs = refs.flatMap(ref => store.runsFor(ref.id)).filter(one => one.role === "builder" && one.contestant === null);
|
|
147
|
+
const run = runs.reduce((top, one) => top === null || one.id > top ? one.id : top, null);
|
|
148
|
+
return run === null ? { taskRef: newest.id } : { run };
|
|
149
|
+
}
|
|
150
|
+
/** Whether anyone completed or cancelled the task family (since `since`, when given), or its newest version is
|
|
151
|
+
* cancelled however that happened: a finished task is nobody's to be on. */
|
|
152
|
+
function familyEnded(store, ids, since) {
|
|
153
|
+
const refs = ids.map(id => store.lookupRef(id)).filter(ref => ref !== null).sort((a, b) => b.id - a.id);
|
|
154
|
+
if (refs[0] === undefined)
|
|
155
|
+
return false;
|
|
156
|
+
if (store.getTask(refs[0].externalId)?.state === "cancelled")
|
|
157
|
+
return true;
|
|
158
|
+
return store.handle.prepare(`SELECT 1 AS hit FROM task_act WHERE task_ref IN (${refs.map(() => "?").join(",")}) AND act IN (${DONE_ACTS_SQL})
|
|
159
|
+
AND at >= ? LIMIT 1`).get(...refs.map(ref => ref.id), since ?? "") !== undefined;
|
|
160
|
+
}
|
|
161
|
+
/** Whether `viewer`'s own lead has this task now: on it, lapsed after two quiet hours (it says so), or null (never
|
|
162
|
+
* claimed by their lead, handed on by it since, or completed or cancelled by anyone). Another person's lead never counts. */
|
|
163
|
+
export function leadClaimOf(store, taskId, now, viewer) {
|
|
164
|
+
if (viewer == null || viewer === "")
|
|
165
|
+
return null;
|
|
166
|
+
try {
|
|
167
|
+
const { root, ids } = familyOf(store, taskId);
|
|
168
|
+
const claim = store.handle.prepare("SELECT id, at, actor FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? ORDER BY id DESC LIMIT 1")
|
|
169
|
+
.get(root, LEAD_CLAIMED, `lead for ${viewer}`);
|
|
170
|
+
if (claim === undefined)
|
|
171
|
+
return null;
|
|
172
|
+
const family = `task_id IN (${ids.map(() => "?").join(",")}) AND id >= ? AND actor = ?`;
|
|
173
|
+
const args = [...ids, Number(claim["id"]), String(claim["actor"])];
|
|
174
|
+
// Completed, handed on or cancelled by the lead since its claim: the claim is over.
|
|
175
|
+
if (store.handle.prepare(`SELECT 1 AS hit FROM action_ledger WHERE ${family} AND action IN (${ENDS_SQL}) LIMIT 1`).get(...args) !== undefined)
|
|
176
|
+
return null;
|
|
177
|
+
if (familyEnded(store, ids, String(claim["at"])))
|
|
178
|
+
return null;
|
|
179
|
+
const latest = store.handle.prepare(`SELECT MAX(at) AS at FROM action_ledger WHERE ${family}`).get(...args);
|
|
180
|
+
const lastActedAt = String(latest?.["at"] ?? claim["at"]);
|
|
181
|
+
return { state: Date.parse(lastActedAt) > now.getTime() - LEAD_IDLE_MS ? "on-it" : "lapsed",
|
|
182
|
+
owner: String(claim["actor"]).replace(/^lead for /, ""), since: String(claim["at"]), lastActedAt };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// An older store being read by a newer build: no claim is the safe reading.
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** A claim that lapsed repaints its owner's card once per lapse: the card reads the task's real state again and
|
|
190
|
+
* says it is back with them. Run beside the evening digests on every chat pass; claims are found through their
|
|
191
|
+
* repaint rows (an indexed key range), so nothing scans the ledger. */
|
|
192
|
+
export function enqueueLeadLapses(store, now) {
|
|
193
|
+
let count = 0;
|
|
194
|
+
try {
|
|
195
|
+
const since = new Date(now.getTime() - 14 * 86_400_000).toISOString();
|
|
196
|
+
const claims = store.handle.prepare(`SELECT DISTINCT n.task_id, a.account FROM notification n JOIN notification_actor a ON a.notification = n.id AND a.lead = 1
|
|
197
|
+
WHERE n.dedupe_key >= 'lead-on-it:' AND n.dedupe_key < 'lead-on-it;' AND n.created_at >= ? AND n.task_id IS NOT NULL`).all(since);
|
|
198
|
+
for (const row of claims) {
|
|
199
|
+
const owner = String(row["account"]);
|
|
200
|
+
const taskId = String(row["task_id"]);
|
|
201
|
+
const claim = leadClaimOf(store, taskId, now, owner);
|
|
202
|
+
if (claim?.state !== "lapsed")
|
|
203
|
+
continue;
|
|
204
|
+
const { root, ids } = familyOf(store, taskId);
|
|
205
|
+
// A completed or cancelled task never repaints as back with them.
|
|
206
|
+
if (store.getTask(root) === null || familyEnded(store, ids, null))
|
|
207
|
+
continue;
|
|
208
|
+
const source = cardSource(store, root);
|
|
209
|
+
if (source === null)
|
|
210
|
+
continue;
|
|
211
|
+
// The owner's lead let it go: only its owner's card hears of it (store.pingAllowed).
|
|
212
|
+
if (withActor({ account: owner, lead: true }, () => store.enqueueNotification({ dedupeKey: `lead-lapsed:${root}:${owner}:${claim.lastActedAt}`,
|
|
213
|
+
kind: LEAD_LAPSED_KIND, subject: LEAD_LAPSED, body: "", link: chatControlHref("task", root), source }, now)))
|
|
214
|
+
count++;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// An older store being read by a newer build: nothing to repaint.
|
|
219
|
+
}
|
|
220
|
+
return count;
|
|
221
|
+
}
|
|
222
|
+
/** The lead's act on a task that a claim counts (a retry, a state change, a steer): one ledger row as the lead. */
|
|
223
|
+
export function noteLeadWork(store, actor, taskId, what, now) {
|
|
224
|
+
if (!actor.lead)
|
|
225
|
+
return;
|
|
226
|
+
const ref = store.lookupRef(taskId);
|
|
227
|
+
if (ref === null)
|
|
228
|
+
return;
|
|
229
|
+
store.recordAction({ at: now.toISOString(), actor: actorLabel(actor), repo: ref.repo, taskId, runId: null, action: `lead ${what}`, outcome: "recorded", source: "work" });
|
|
230
|
+
}
|
|
231
|
+
const ACT_WORDS = {
|
|
232
|
+
"task filed": "filed", "task approved": "approved", "task cancelled": "cancelled", "task completed": "completed",
|
|
233
|
+
"task handed to a person": "handed on", [LEAD_CLAIMED]: "working on",
|
|
234
|
+
};
|
|
235
|
+
/** What `owner`'s own lead is doing now and when it last acted, both from its one newest act: its words when that
|
|
236
|
+
* was a say, else the act in words. Nobody (null) has no lead line: another person's lead is never "Your lead". */
|
|
237
|
+
export function leadActivity(store, owner) {
|
|
238
|
+
if (owner == null || owner === "")
|
|
239
|
+
return null;
|
|
240
|
+
try {
|
|
241
|
+
const last = store.handle.prepare("SELECT id, at, actor, task_id, action, detail FROM action_ledger WHERE actor = ? ORDER BY id DESC LIMIT 1").get(`lead for ${owner}`);
|
|
242
|
+
if (last === undefined)
|
|
243
|
+
return null;
|
|
244
|
+
const task = last["task_id"] == null ? null : String(last["task_id"]);
|
|
245
|
+
const title = task === null ? "" : (() => { try {
|
|
246
|
+
return ` ${chatTitle(store, task)}`;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return "";
|
|
250
|
+
} })();
|
|
251
|
+
const doing = last["action"] === LEAD_SAID && last["detail"] != null ? chatText(String(last["detail"]), task === null ? [] : [{ id: task, title: title.trim() }])
|
|
252
|
+
: `${ACT_WORDS[String(last["action"])] ?? String(last["action"]).replace(/^lead /, "")}${title}`;
|
|
253
|
+
return { owner, doing: doing.replace(/[.\s]+$/u, ""), at: String(last["at"]), taskId: task };
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** "just now", "3 min ago", "2 h ago", "yesterday", "4 days ago". */
|
|
260
|
+
export function agoWords(at, now) {
|
|
261
|
+
const minutes = Math.max(0, Math.floor((now.getTime() - Date.parse(at)) / 60_000));
|
|
262
|
+
if (!Number.isFinite(minutes) || minutes < 1)
|
|
263
|
+
return "just now";
|
|
264
|
+
if (minutes < 60)
|
|
265
|
+
return `${minutes} min ago`;
|
|
266
|
+
const hours = Math.floor(minutes / 60);
|
|
267
|
+
if (hours < 24)
|
|
268
|
+
return `${hours} h ago`;
|
|
269
|
+
const days = Math.floor(hours / 24);
|
|
270
|
+
return days === 1 ? "yesterday" : `${days} days ago`;
|
|
271
|
+
}
|
|
272
|
+
/** The one line `toolroll status` and the console say: "Your lead: fixing 0.9.12's release check · 3 min ago". */
|
|
273
|
+
export function leadActivityLine(activity, now) {
|
|
274
|
+
return `${LEAD_NAME}: ${activity.doing} · ${agoWords(activity.at, now)}`;
|
|
275
|
+
}
|
package/dist/needs-you.d.ts
CHANGED
|
@@ -29,6 +29,13 @@ type Need = {
|
|
|
29
29
|
withReason?: (reason: string) => string;
|
|
30
30
|
};
|
|
31
31
|
export declare const NEEDS: Readonly<Record<NeedKey, Need>>;
|
|
32
|
+
/** What a waiting task asks of a person, in three kinds the Tasks list groups
|
|
33
|
+
* by: a choice to make, a result to accept or send back, or something in the
|
|
34
|
+
* way to clear. Decide comes first. */
|
|
35
|
+
export type Ask = "decide" | "review" | "unblock";
|
|
36
|
+
export declare const ASKS: readonly Ask[];
|
|
37
|
+
export declare const ASK_LABEL: Readonly<Record<Ask, string>>;
|
|
38
|
+
export declare const NEED_ASK: Readonly<Record<NeedKey, Ask>>;
|
|
32
39
|
export declare const WAITS: Readonly<Record<WaitKey, (context: NeedContext) => string>>;
|
|
33
40
|
/** Words that name the machine's internals; no Needs you or Waiting sentence carries them. */
|
|
34
41
|
export declare const BANNED_WORDS: readonly ["witness", "unproven", "quiescence", "quiescent", "digest", "scope digest"];
|
package/dist/needs-you.js
CHANGED
|
@@ -34,6 +34,15 @@ export const NEEDS = {
|
|
|
34
34
|
"earlier-version": { sentence: () => "An earlier version of this task still needs you. Finish it first.", action: { code: "inspect-task", label: "Open earlier version" } },
|
|
35
35
|
other: { sentence: () => "Open the task to see what it needs from you.", action: { code: "inspect-task", label: "Open the task" }, useReason: always },
|
|
36
36
|
};
|
|
37
|
+
export const ASKS = ["decide", "review", "unblock"];
|
|
38
|
+
export const ASK_LABEL = { decide: "Decide", review: "Review", unblock: "Unblock" };
|
|
39
|
+
export const NEED_ASK = {
|
|
40
|
+
approval: "decide", card: "decide", answer: "decide", questions: "decide", "choose-project": "decide", "define-task": "decide",
|
|
41
|
+
"fix-request": "decide", "choose-agent": "decide",
|
|
42
|
+
"review-result": "review", rebuild: "review",
|
|
43
|
+
"sign-in": "unblock", "confirm-stopped": "unblock", "check-stopped": "unblock", hold: "unblock", "fix-dependency": "unblock",
|
|
44
|
+
"add-requirement": "unblock", "connect-builder": "unblock", "start-builder": "unblock", vanished: "unblock", "earlier-version": "unblock", other: "unblock",
|
|
45
|
+
};
|
|
37
46
|
export const WAITS = {
|
|
38
47
|
"build-stopping": context => `Waiting for ${build(context)} to stop. Nothing is needed from you.`,
|
|
39
48
|
"other-computer": context => `Waiting for the computer that ran ${build(context)} to confirm it stopped.`,
|
package/dist/operate.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ export type OperateOptions = {
|
|
|
50
50
|
gitRunner?: CommandRunner;
|
|
51
51
|
/** Injected by tests: the Telegram Bot API. Production dials the real one. */
|
|
52
52
|
telegramTransport?: TelegramTransport;
|
|
53
|
+
/** Injected by tests: the environment checked for a fenced network before Telegram is reached (default: this process's, with the real transport). */
|
|
54
|
+
telegramEnv?: Record<string, string | undefined>;
|
|
53
55
|
publishExec?: PublishExec;
|
|
54
56
|
/** Injected by tests: the stop fence a watch would set. */
|
|
55
57
|
shouldStop?: () => boolean;
|