toolroll 0.9.11 → 0.9.13
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 +24 -0
- package/dist/assignment-ui.js +1 -1
- 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 +25 -23
- package/dist/browser-crew.js +1 -1
- package/dist/browser-workspace.d.ts +79 -0
- package/dist/chat-delivery.js +17 -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.js +80 -4
- package/dist/result-acts.d.ts +44 -0
- package/dist/result-acts.js +36 -0
- package/dist/result-review.js +1 -1
- package/dist/serve.d.ts +6 -1
- package/dist/serve.js +500 -239
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.js +8 -0
- 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-progress.js +7 -0
- package/dist/telegram.js +34 -3
- 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 +29 -0
- package/dist/workspace-ui.js +32 -3
- package/package.json +1 -1
package/dist/browser-crew.js
CHANGED
|
@@ -19,7 +19,7 @@ export function browserWorkActionHref(item) {
|
|
|
19
19
|
if (action.code === 'open-pr' && /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+$/.test(item.publicationUrl ?? ''))
|
|
20
20
|
return item.publicationUrl;
|
|
21
21
|
if (runId !== null && (action.code === 'open-pr' || action.code === 'retry-review')) {
|
|
22
|
-
return `/review?result=${encodeURIComponent(taskId)}&run=${runId}
|
|
22
|
+
return `/review?result=${encodeURIComponent(taskId)}&run=${runId}`;
|
|
23
23
|
}
|
|
24
24
|
if (runId !== null && (action.code === 'inspect-run' || action.code === 'reconcile-run'))
|
|
25
25
|
return `/r/${runId}`;
|
|
@@ -7,8 +7,11 @@ export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
|
|
|
7
7
|
import type { Store } from './store.js';
|
|
8
8
|
import type { WorkSummaryAccess } from './work-summary.js';
|
|
9
9
|
import type { StatusTone } from './workspace-ui.js';
|
|
10
|
+
import type { Ask } from './needs-you.js';
|
|
11
|
+
import type { WorkIndexGroup } from './work-index.js';
|
|
10
12
|
import type { AssignmentCard } from './assignment-ui.js';
|
|
11
13
|
import type { TaskStatus } from './task-status.js';
|
|
14
|
+
import type { ResultActs } from './result-acts.js';
|
|
12
15
|
import type { FirstRunStep, FirstTaskSuggestion, JourneyStep } from './first-run.js';
|
|
13
16
|
export type BrowserProject = {
|
|
14
17
|
name: string;
|
|
@@ -103,6 +106,14 @@ export type BrowserTasksView = {
|
|
|
103
106
|
count: number;
|
|
104
107
|
active: boolean;
|
|
105
108
|
})[];
|
|
109
|
+
/** Tasks waiting on a person in this view's scope ("3 need you" beside the title). */
|
|
110
|
+
needsYou: number;
|
|
111
|
+
/** All and Needs you list their rows in these groups, in this order (empty ones left out); null: one plain list. */
|
|
112
|
+
groups: {
|
|
113
|
+
key: BrowserTaskGroup;
|
|
114
|
+
label: string;
|
|
115
|
+
count: number;
|
|
116
|
+
}[] | null;
|
|
106
117
|
rows: {
|
|
107
118
|
id: string;
|
|
108
119
|
title: string;
|
|
@@ -114,6 +125,9 @@ export type BrowserTasksView = {
|
|
|
114
125
|
tone: StatusTone;
|
|
115
126
|
token: string;
|
|
116
127
|
};
|
|
128
|
+
/** What a Needs you row asks (its chip); null for every other row. */
|
|
129
|
+
ask: Ask | null;
|
|
130
|
+
group: BrowserTaskGroup;
|
|
117
131
|
action: BrowserLink | null;
|
|
118
132
|
detail: string | null;
|
|
119
133
|
problem: string | null;
|
|
@@ -133,6 +147,7 @@ export type BrowserTasksView = {
|
|
|
133
147
|
limits: BrowserLimits | null;
|
|
134
148
|
};
|
|
135
149
|
/** One limit: a plan's usage window ("Claude · 5-hour, 48%") or a monthly budget ("shop · Budget, $4.20 of $10"). */
|
|
150
|
+
export type BrowserTaskGroup = WorkIndexGroup;
|
|
136
151
|
export type BrowserLimitTile = {
|
|
137
152
|
key: string;
|
|
138
153
|
name: string;
|
|
@@ -436,6 +451,7 @@ export type BrowserResultPanel = {
|
|
|
436
451
|
* Accept that records the decision (null when it is not offered here). */
|
|
437
452
|
youCheck: {
|
|
438
453
|
lines: string[];
|
|
454
|
+
items: BrowserCheckItem[];
|
|
439
455
|
accept: {
|
|
440
456
|
action: string;
|
|
441
457
|
run: number;
|
|
@@ -465,6 +481,39 @@ export type BrowserResultPanel = {
|
|
|
465
481
|
/** The feedback section holds only the closed form (no notes, revisions or history). */
|
|
466
482
|
requestQuiet: boolean;
|
|
467
483
|
};
|
|
484
|
+
/** One "You check this one" item and the evidence to judge it by: the
|
|
485
|
+
* changed lines it cites (or, citing none, the change's own first lines),
|
|
486
|
+
* its screenshots, and the agent's note. Wrapped text, never sideways. */
|
|
487
|
+
export type BrowserCheckItem = {
|
|
488
|
+
id: string;
|
|
489
|
+
statement: string;
|
|
490
|
+
words: string;
|
|
491
|
+
note: string | null;
|
|
492
|
+
excerpts: {
|
|
493
|
+
path: string;
|
|
494
|
+
cited: boolean;
|
|
495
|
+
lines: {
|
|
496
|
+
kind: "addition" | "deletion" | "context";
|
|
497
|
+
line: number | null;
|
|
498
|
+
text: string;
|
|
499
|
+
}[];
|
|
500
|
+
more: number;
|
|
501
|
+
}[];
|
|
502
|
+
shots: {
|
|
503
|
+
src: string;
|
|
504
|
+
href: string;
|
|
505
|
+
caption: string;
|
|
506
|
+
}[];
|
|
507
|
+
};
|
|
508
|
+
/** The result's one decision: what Accept is called, why it isn't plain
|
|
509
|
+
* Accept, and what pressing it does. */
|
|
510
|
+
export type BrowserResultDecision = {
|
|
511
|
+
label: "Accept" | "Accept without checks";
|
|
512
|
+
ready: boolean;
|
|
513
|
+
why: string | null;
|
|
514
|
+
effect: string;
|
|
515
|
+
sentence: string;
|
|
516
|
+
};
|
|
468
517
|
/** A Needs you action: a link, (Confirm it stopped) a form behind the password, (Build again) one
|
|
469
518
|
* button, or (on the result itself) Accept, which records the person's acceptance; `note` asks why
|
|
470
519
|
* when the evidence disagrees. */
|
|
@@ -531,11 +580,30 @@ export type BrowserResultView = {
|
|
|
531
580
|
receipt: string;
|
|
532
581
|
run: number;
|
|
533
582
|
} | null;
|
|
583
|
+
/** Words for the result's Accept: Mark complete when offered, else the Needs you acceptance. */
|
|
584
|
+
decision: BrowserResultDecision | null;
|
|
534
585
|
checks: {
|
|
535
586
|
detail: string;
|
|
536
587
|
problem: boolean;
|
|
537
588
|
logHref: string | null;
|
|
538
589
|
} | null;
|
|
590
|
+
/** The one ink act that resolves the result, the one outline act beside it, and why it can't be accepted yet (result-acts.ts). */
|
|
591
|
+
acts: ResultActs;
|
|
592
|
+
/** Run checks on this result's commit: the project's check, when it didn't run. */
|
|
593
|
+
runChecks: {
|
|
594
|
+
action: string;
|
|
595
|
+
level: "quick" | "full";
|
|
596
|
+
returnTo: string;
|
|
597
|
+
} | null;
|
|
598
|
+
/** The raw run record, under Details: its facts and the full record. */
|
|
599
|
+
record: {
|
|
600
|
+
build: number;
|
|
601
|
+
href: string;
|
|
602
|
+
facts: {
|
|
603
|
+
label: string;
|
|
604
|
+
value: string;
|
|
605
|
+
}[];
|
|
606
|
+
} | null;
|
|
539
607
|
/** The signed scope; null when none was filed. */
|
|
540
608
|
intent: {
|
|
541
609
|
approval: string;
|
|
@@ -889,6 +957,11 @@ export type BrowserWorkspace = {
|
|
|
889
957
|
refreshSeconds?: number;
|
|
890
958
|
/** Providers whose sign-in stopped working: their work waits (one per provider). */
|
|
891
959
|
signIn?: BrowserSignIn[];
|
|
960
|
+
/** A demo database: the notice that says so, scrolling with the page (`short`: its one line on a phone). */
|
|
961
|
+
demo?: {
|
|
962
|
+
text: string;
|
|
963
|
+
short: string;
|
|
964
|
+
};
|
|
892
965
|
/** A newer Toolroll exists: a quiet notice for an operator, until they dismiss this version. */
|
|
893
966
|
update?: BrowserUpdateNotice;
|
|
894
967
|
/** Chat's first run, until the first Ready result: the three steps and first tasks to try. */
|
|
@@ -945,6 +1018,12 @@ export type BrowserHome = {
|
|
|
945
1018
|
}[];
|
|
946
1019
|
catchUp: BrowserCatchUpItem[];
|
|
947
1020
|
allHref: string;
|
|
1021
|
+
/** What this person's lead is doing now and when it last acted (lead-voice.ts); its task when it named one. */
|
|
1022
|
+
lead?: {
|
|
1023
|
+
doing: string;
|
|
1024
|
+
at: string;
|
|
1025
|
+
href: string | null;
|
|
1026
|
+
} | null;
|
|
948
1027
|
};
|
|
949
1028
|
/** `sandbox`: the demo command, offered beside the sign-in command while no agent is signed in. `intro`: how it works,
|
|
950
1029
|
* once, above the composer with the suggestions. `lead`: the one line saying what runs the lead, and where to change it.
|
package/dist/chat-delivery.js
CHANGED
|
@@ -11,6 +11,7 @@ import { triggerConfigOf } from "./flow-triggers.js";
|
|
|
11
11
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
12
12
|
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
13
13
|
import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
|
|
14
|
+
import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText } from "./lead-voice.js";
|
|
14
15
|
import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
|
|
15
16
|
import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
|
|
16
17
|
import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
|
|
@@ -651,6 +652,7 @@ export async function planChatNotifications(options) {
|
|
|
651
652
|
enqueueEveningDigests(store, nowOf(options), options.evidenceRoot);
|
|
652
653
|
}
|
|
653
654
|
catch { /* Retried on the next pass. */ }
|
|
655
|
+
enqueueLeadLapses(store, nowOf(options));
|
|
654
656
|
const cursor = Number(state
|
|
655
657
|
.prepare("SELECT notification FROM chat_runtime WHERE installation=?")
|
|
656
658
|
.get(identity.installation)?.notification ?? 0);
|
|
@@ -676,6 +678,21 @@ export async function planChatNotifications(options) {
|
|
|
676
678
|
// Only when I'm needed (the default): the task's one card is edited in place, and a new message
|
|
677
679
|
// follows only when this person is needed. Every step keeps the branches below unchanged.
|
|
678
680
|
const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
|
|
681
|
+
if (notification.kind === LEAD_SAY_KIND) {
|
|
682
|
+
// The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
|
|
683
|
+
const content = { text: leadSayText(notification),
|
|
684
|
+
...(notification.link ? { link: { label: "Open", path: notification.link } } : {}) };
|
|
685
|
+
const earlier = leadSayEarlier(store, notification).map(one => state.prepare("SELECT id FROM chat_part WHERE event=?")
|
|
686
|
+
.get(chatHash(`${options.state.channel}:notice:${binding.id}:${one}`))).find(one => one !== undefined);
|
|
687
|
+
if (earlier !== undefined) {
|
|
688
|
+
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(earlier.id));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
|
|
692
|
+
ts: "", thread: "", payload: "{}", created: now.toISOString() });
|
|
693
|
+
state.plan(id, [content], now);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
679
696
|
if (quiet) {
|
|
680
697
|
planQuietCard(options, binding, notification, now);
|
|
681
698
|
if (!needsPerson(notification))
|
package/dist/chat-quiet.js
CHANGED
|
@@ -149,6 +149,7 @@ function lineOf(store, item, now, root, viewer) {
|
|
|
149
149
|
pullRequest,
|
|
150
150
|
// The lead speaks as "I" for what it did: the work it filed or approved, or the completion it recorded.
|
|
151
151
|
lead: card.status.headline === "Complete" ? card.completedByLead : store.leadWorkOf(item.taskRef) !== null,
|
|
152
|
+
...(card.facts.lead === "on-it" ? { leadOnIt: true } : {}),
|
|
152
153
|
};
|
|
153
154
|
// A result ready for a person offers its real next step, then a look first.
|
|
154
155
|
if (asksToFinish(fact))
|
package/dist/chat-voice.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export type FinishedFact = {
|
|
|
34
34
|
words: string;
|
|
35
35
|
phrase: string;
|
|
36
36
|
} | null;
|
|
37
|
+
/** The person's lead took it on (lead-voice.ts): "Your lead is on it", never "It waits for you". */
|
|
38
|
+
leadOnIt?: boolean;
|
|
37
39
|
};
|
|
38
40
|
/** The two buttons under a result ready for a person: the real next step, then a look first. */
|
|
39
41
|
export declare const READY_ACTIONS: {
|
|
@@ -53,7 +55,7 @@ export declare function batchLine(facts: readonly FinishedFact[]): string;
|
|
|
53
55
|
* StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
|
|
54
56
|
* leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
|
|
55
57
|
*/
|
|
56
|
-
export declare function nameTelegramBot(transport: TelegramTransport, when: "pairing" | "upgrade"): Promise<boolean>;
|
|
58
|
+
export declare function nameTelegramBot(transport: TelegramTransport, when: "pairing" | "upgrade", signal?: AbortSignal): Promise<boolean>;
|
|
57
59
|
/** The last word on any pushed chat text: a task's own id (bare or in brackets), a "— revision" suffix and a
|
|
58
60
|
* "Replaced by <id>" never reach a chat, whatever words the fact was recorded with. A bare id reads as the
|
|
59
61
|
* task's short title when one is given, else "this task". */
|
package/dist/chat-voice.js
CHANGED
|
@@ -53,6 +53,9 @@ export function finishedLine(fact) {
|
|
|
53
53
|
return fact.update.words;
|
|
54
54
|
const name = fact.summary, lower = inSentence(fact.summary);
|
|
55
55
|
const ready = fact.lead === true ? `I finished ${lower}` : `${name} is ready`;
|
|
56
|
+
// What waited on the person and the lead has taken on (Needs you reads Waiting then).
|
|
57
|
+
if (fact.leadOnIt === true && (fact.headline === "Needs you" || fact.headline === "Waiting" || fact.headline === "Stopped"))
|
|
58
|
+
return `${name}: your lead is on it.`;
|
|
56
59
|
switch (fact.headline) {
|
|
57
60
|
case "Ready for review":
|
|
58
61
|
if (fact.report)
|
|
@@ -63,6 +66,8 @@ export function finishedLine(fact) {
|
|
|
63
66
|
return `${ready}. Checks are off for this project, so look it over first.`;
|
|
64
67
|
return `${ready}, but no tests ran. Look it over first.`;
|
|
65
68
|
case "Failed":
|
|
69
|
+
if (fact.leadOnIt === true)
|
|
70
|
+
return fact.checks === "failed" ? `${name} is built, but its tests failed. Your lead is on it.` : `${name} stopped before it finished. Your lead is on it.`;
|
|
66
71
|
if (fact.checks === "failed")
|
|
67
72
|
return fact.lead === true
|
|
68
73
|
? `I built ${lower}, but its tests failed. It waits for you: retry or ask for changes.`
|
|
@@ -103,8 +108,9 @@ export function batchLine(facts) {
|
|
|
103
108
|
const shown = names.slice(0, 3).join(", ");
|
|
104
109
|
const first = `${facts.length} ${finishedOnly ? "tasks finished" : "updates"}: ${shown}${names.length > 3 ? ", …" : "."}`;
|
|
105
110
|
// What happens next: anything that broke or needs a decision waits for the person.
|
|
106
|
-
|
|
107
|
-
const
|
|
111
|
+
// What the lead is on waits for nobody.
|
|
112
|
+
const failed = facts.filter(one => one.headline === "Failed" && one.leadOnIt !== true);
|
|
113
|
+
const needs = facts.filter(one => one.headline === "Needs you" && one.leadOnIt !== true).length;
|
|
108
114
|
const broke = failed.length !== 1 ? `${failed.length} failed`
|
|
109
115
|
: failed[0].checks === "failed" ? `tests failed on ${inSentence(failed[0].summary)}` : `${inSentence(failed[0].summary)} stopped before it finished`;
|
|
110
116
|
const waits = [
|
|
@@ -123,16 +129,18 @@ const OLD_NAME = /standing\s*-?\s*orders/i;
|
|
|
123
129
|
* StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
|
|
124
130
|
* leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
|
|
125
131
|
*/
|
|
126
|
-
export async function nameTelegramBot(transport, when) {
|
|
132
|
+
export async function nameTelegramBot(transport, when, signal) {
|
|
127
133
|
try {
|
|
128
|
-
const current = await transport("getMyName", {});
|
|
134
|
+
const current = await transport("getMyName", {}, signal);
|
|
129
135
|
const name = current.ok ? current.result?.name : undefined;
|
|
130
136
|
const shown = typeof name === "string" ? name : null;
|
|
131
137
|
if (shown === BOT_NAME)
|
|
132
138
|
return false;
|
|
133
139
|
if (when === "upgrade" && (shown === null || !OLD_NAME.test(shown)))
|
|
134
140
|
return false;
|
|
135
|
-
|
|
141
|
+
if (signal?.aborted)
|
|
142
|
+
return false;
|
|
143
|
+
const set = await transport("setMyName", { name: BOT_NAME }, signal);
|
|
136
144
|
return set.ok;
|
|
137
145
|
}
|
|
138
146
|
catch {
|
package/dist/cli.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type ParseResult = {
|
|
|
39
39
|
error: string;
|
|
40
40
|
};
|
|
41
41
|
type Write = (line: string) => void;
|
|
42
|
-
export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll lead token mint your lead agent's credential: its own work pings nobody\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll flows list | show <id> | create | edit | trigger | script | card | archive\n set up and inspect flows (writes preview until --yes)\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
|
|
42
|
+
export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll lead token mint your lead agent's credential: its own work pings nobody\n toolroll lead say \"<text>\" (the lead) one short message in its person's chat\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll flows list | show <id> | create | edit | trigger | script | card | archive\n set up and inspect flows (writes preview until --yes)\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
|
|
43
43
|
export declare function parseArgs(argv: readonly string[]): ParseResult;
|
|
44
44
|
/** The commands that operate the queue rather than report on the world. */
|
|
45
45
|
/** Every verb the CLI routes OUTSIDE runOperate. Exported (with
|
package/dist/cli.js
CHANGED
|
@@ -66,6 +66,7 @@ Usage
|
|
|
66
66
|
toolroll connect save a private connection to your central service
|
|
67
67
|
toolroll lead named leads on the connected service
|
|
68
68
|
toolroll lead token mint your lead agent's credential: its own work pings nobody
|
|
69
|
+
toolroll lead say "<text>" (the lead) one short message in its person's chat
|
|
69
70
|
toolroll conversation shared and private conversations on that service
|
|
70
71
|
toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)
|
|
71
72
|
|
package/dist/coding-update.d.ts
CHANGED
|
@@ -10,6 +10,19 @@ export declare function activeCodingUpdateWork(orders: Database): {
|
|
|
10
10
|
/** An idle provider may remain loaded during drain. The normal server stop
|
|
11
11
|
* must close it and establish native/tool exit before either app swap. */
|
|
12
12
|
export declare function assertCodingUpdateStopped(orders: Database): void;
|
|
13
|
+
/** What a stale owner record named when an updater released it, for its ledger. */
|
|
14
|
+
export type ReleasedCodingOwner = {
|
|
15
|
+
pid: number;
|
|
16
|
+
nativePid: number | null;
|
|
17
|
+
};
|
|
18
|
+
/** A runtime killed before its close (deploys over 0.9.11, Oct 2) leaves the
|
|
19
|
+
* owner record naming a dead process, and every later swap refuses. Only an
|
|
20
|
+
* updater that has itself proved each process of the stopped service gone may
|
|
21
|
+
* release it here, and only while nothing the catalog records still lives:
|
|
22
|
+
* the agent, its tools, a session or an unconfirmed message. Returns what it
|
|
23
|
+
* released, or null when the record was already released by an ordinary stop;
|
|
24
|
+
* anything unproven keeps the ordinary refusal. */
|
|
25
|
+
export declare function releaseStaleCodingOwner(orders: Database, stoppedPids: readonly number[], alive?: (pid: number, group: boolean) => boolean): ReleasedCodingOwner | null;
|
|
13
26
|
/** Back up through SQLite so committed WAL records are included. This is a
|
|
14
27
|
* retained recovery artifact; updates never restore the live catalog. */
|
|
15
28
|
export declare function backupCodingCatalog(sourceFile: string, target: string, updateId: string): Promise<void>;
|
package/dist/coding-update.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { chmodSync, closeSync, fsyncSync, lstatSync, openSync } from 'node:fs';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
3
4
|
import { sqliteRuntime } from './sqlite-runtime.js';
|
|
5
|
+
import { processMayBeAlive } from './process-liveness.js';
|
|
4
6
|
const gatePrefix = 'so_coding_update_';
|
|
5
7
|
const pausedMessage = 'Toolroll is updating. Current work can finish; new coding work will resume after the update.';
|
|
6
8
|
export function codingCatalogPath(orders) {
|
|
@@ -100,6 +102,72 @@ export function assertCodingUpdateStopped(orders) {
|
|
|
100
102
|
throw Error('The coding server has not verified agent and tool shutdown. No app was replaced.');
|
|
101
103
|
});
|
|
102
104
|
}
|
|
105
|
+
/** A runtime killed before its close (deploys over 0.9.11, Oct 2) leaves the
|
|
106
|
+
* owner record naming a dead process, and every later swap refuses. Only an
|
|
107
|
+
* updater that has itself proved each process of the stopped service gone may
|
|
108
|
+
* release it here, and only while nothing the catalog records still lives:
|
|
109
|
+
* the agent, its tools, a session or an unconfirmed message. Returns what it
|
|
110
|
+
* released, or null when the record was already released by an ordinary stop;
|
|
111
|
+
* anything unproven keeps the ordinary refusal. */
|
|
112
|
+
export function releaseStaleCodingOwner(orders, stoppedPids, alive = processMayBeAlive) {
|
|
113
|
+
return withCatalog(orders, null, false, db => {
|
|
114
|
+
db.exec('BEGIN IMMEDIATE');
|
|
115
|
+
try {
|
|
116
|
+
const owner = db.prepare('SELECT token,pid,native_pid,clean FROM coding_owner WHERE singleton=1').get();
|
|
117
|
+
const pid = Number(owner['pid']), nativePid = owner['native_pid'] === null ? null : Number(owner['native_pid']);
|
|
118
|
+
if (owner['token'] === '' && pid === 0 && nativePid === null && owner['clean'] === 1) {
|
|
119
|
+
db.exec('ROLLBACK');
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const refuse = (why) => { throw Error(`The coding server has not verified agent and tool shutdown (${why}). No app was replaced.`); };
|
|
123
|
+
// A record its own server released while recovery was still required stays as it is: that needs a person's check.
|
|
124
|
+
if (owner['token'] === '' || !Number.isSafeInteger(pid) || pid <= 0)
|
|
125
|
+
refuse('its saved record needs a recovery check');
|
|
126
|
+
if (!stoppedPids.includes(pid))
|
|
127
|
+
refuse(`process ${pid} is not one this update stopped`);
|
|
128
|
+
if (alive(pid, false))
|
|
129
|
+
refuse(`process ${pid} is still running`);
|
|
130
|
+
// The agent runs in its own process group: a service killed by launchd can leave it behind.
|
|
131
|
+
if (nativePid !== null && alive(nativePid, true))
|
|
132
|
+
refuse(`the agent process ${nativePid} is still running`);
|
|
133
|
+
const custody = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='coding_custody'").get()
|
|
134
|
+
? db.prepare('SELECT payload FROM coding_custody WHERE singleton=1').get() : undefined;
|
|
135
|
+
if (custody) {
|
|
136
|
+
let c;
|
|
137
|
+
try {
|
|
138
|
+
c = JSON.parse(String(custody['payload']));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
c = {};
|
|
142
|
+
}
|
|
143
|
+
const processes = Array.isArray(c.descendants) ? c.descendants : null;
|
|
144
|
+
if (c.host !== hostname() || c.container || c.observationUnknown !== false || processes === null)
|
|
145
|
+
refuse("the agent's tool processes could not be accounted for");
|
|
146
|
+
const recorded = [...(c.pid === null ? [] : [{ pid: c.pid, group: c.group }]), ...processes];
|
|
147
|
+
for (const one of recorded) {
|
|
148
|
+
if (!Number.isSafeInteger(one.pid) || Number(one.pid) <= 0)
|
|
149
|
+
refuse("the agent's tool processes could not be accounted for");
|
|
150
|
+
if (alive(Number(one.pid), one.group === true))
|
|
151
|
+
refuse(`the agent's process ${String(one.pid)} is still running`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const sessions = Number(db.prepare("SELECT count(*) n FROM coding_session WHERE json_extract(document,'$.status') IN ('starting','working','needs-input','stopping','uncertain') OR json_extract(document,'$.turnId') IS NOT NULL").get()?.['n']);
|
|
155
|
+
const deliveries = Number(db.prepare("SELECT count(*) n FROM coding_submission WHERE status IN ('preparing','pending','uncertain')").get()?.['n']);
|
|
156
|
+
if (sessions > 0 || deliveries > 0)
|
|
157
|
+
refuse(`${sessions} coding session(s) and ${deliveries} unconfirmed message(s) were still active`);
|
|
158
|
+
db.prepare("UPDATE coding_owner SET token='',pid=0,native_pid=NULL,clean=1 WHERE singleton=1 AND token=?").run(owner['token']);
|
|
159
|
+
db.exec('COMMIT');
|
|
160
|
+
return { pid, nativePid };
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
try {
|
|
164
|
+
db.exec('ROLLBACK');
|
|
165
|
+
}
|
|
166
|
+
catch { /* already ended */ }
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
103
171
|
function snapshot(db) {
|
|
104
172
|
const data = {};
|
|
105
173
|
// Include every catalog table, including current/future custody records.
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { type DesktopBundle } from "./desktop-bundle.js";
|
|
3
|
+
import { type ReleasedCodingOwner } from "./coding-update.js";
|
|
3
4
|
type Phase = "prepared" | "draining" | "backing-up" | "stopping" | "installing" | "verifying" | "rolling-back" | "releasing" | "complete" | "restored" | "cancelled" | "needs-attention";
|
|
4
5
|
export type UpdateJournal = {
|
|
5
6
|
version: 1;
|
|
@@ -31,6 +32,10 @@ export type UpdateJournal = {
|
|
|
31
32
|
waiting?: LingeringRun & {
|
|
32
33
|
since: string;
|
|
33
34
|
};
|
|
35
|
+
/** The service's supervisor and controller, recorded before it is stopped: the processes proved gone afterwards. */
|
|
36
|
+
stoppedPids?: number[];
|
|
37
|
+
/** A stale coding owner record this update released after proving those processes gone. */
|
|
38
|
+
codingOwnerReleased?: ReleasedCodingOwner;
|
|
34
39
|
};
|
|
35
40
|
type ServiceState = {
|
|
36
41
|
state: string;
|
|
@@ -49,6 +54,8 @@ export type UpdateHooks = {
|
|
|
49
54
|
/** Fault injection for state-machine tests, never selectable by a CLI flag. */
|
|
50
55
|
checkpoint?: (phase: Phase) => void;
|
|
51
56
|
otherControllers?: (bundle: DesktopBundle, stateDir: string) => Promise<boolean>;
|
|
57
|
+
/** Whether a recorded service process still runs (tests). */
|
|
58
|
+
processAlive?: (pid: number) => boolean;
|
|
52
59
|
};
|
|
53
60
|
export declare const updateTerminal: (phase: string) => boolean;
|
|
54
61
|
/** Durable receipts are private and independent of the app being replaced. */
|
package/dist/desktop-update.js
CHANGED
|
@@ -10,7 +10,8 @@ import { run } from "./exec.js";
|
|
|
10
10
|
import { readSchemaVersion, SCHEMA_VERSION, Store } from "./store.js";
|
|
11
11
|
import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused } from "./desktop-update-gate.js";
|
|
12
12
|
import { currentDesktopAccess } from "./desktop-access.js";
|
|
13
|
-
import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists } from "./coding-update.js";
|
|
13
|
+
import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner } from "./coding-update.js";
|
|
14
|
+
import { processMayBeAlive } from "./process-liveness.js";
|
|
14
15
|
/** Loaded on first use (as backup.ts and store.ts do), so modules that only import this one (the console,
|
|
15
16
|
* and tests that load it in a browser-like environment) never need `node:sqlite` itself. */
|
|
16
17
|
function sqlite() {
|
|
@@ -76,6 +77,8 @@ export function readUpdateJournal(stateDir, retainedReceipt) {
|
|
|
76
77
|
throw Error("The recorded replacement state is invalid. Nothing was changed.");
|
|
77
78
|
if (j.codingCatalogExpected !== undefined && typeof j.codingCatalogExpected !== "boolean")
|
|
78
79
|
throw Error("The recorded coding catalog presence is invalid. Nothing was changed.");
|
|
80
|
+
if (j.stoppedPids !== undefined && (!Array.isArray(j.stoppedPids) || !j.stoppedPids.every(pid => Number.isSafeInteger(pid) && pid > 1)))
|
|
81
|
+
throw Error("The recorded service processes are invalid. Nothing was changed.");
|
|
79
82
|
if ((j.codingBackupPath === undefined) !== (j.codingBackupHash === undefined) || (j.codingBackupPath && (dirname(j.codingBackupPath) !== j.workDir || !/^coding\.backup(?:\.[a-f0-9-]{36})?\.sqlite$/.test(basename(j.codingBackupPath)))) || (j.codingBackupHash && !/^[a-f0-9]{64}$/.test(j.codingBackupHash)))
|
|
80
83
|
throw Error("The retained coding backup paths or identity are invalid. Nothing was changed.");
|
|
81
84
|
for (const directory of [dirname(j.workDir), j.workDir]) {
|
|
@@ -537,6 +540,34 @@ function assertDesktopCodingStopped(j, db) {
|
|
|
537
540
|
assertCodingCatalogPresent(j);
|
|
538
541
|
assertCodingUpdateStopped(db);
|
|
539
542
|
}
|
|
543
|
+
/** The service's own processes, from its supervisor's status file: read before the stop, never after. */
|
|
544
|
+
function servicePids(stateDir) {
|
|
545
|
+
try {
|
|
546
|
+
const status = JSON.parse(readFileSync(join(stateDir, "controller-supervisor.json"), "utf8"));
|
|
547
|
+
return [status.supervisorPid, status.controllerPid].filter((pid) => Number.isSafeInteger(pid) && Number(pid) > 1);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/** An older app killed before its close leaves the coding owner record behind. Once the stopped service's processes
|
|
554
|
+
* are proved gone, it is released here (and recorded in the ledger); anything unproven keeps the ordinary refusal. */
|
|
555
|
+
function releaseDesktopCodingOwner(j, db, alive) {
|
|
556
|
+
assertCodingCatalogPresent(j);
|
|
557
|
+
const released = releaseStaleCodingOwner(db, j.stoppedPids ?? [], (pid, group) => alive(pid) || processMayBeAlive(pid, group));
|
|
558
|
+
if (released === null)
|
|
559
|
+
return;
|
|
560
|
+
j.codingOwnerReleased = released;
|
|
561
|
+
save(j, j.phase, j.detail);
|
|
562
|
+
const ledger = connect(j.databaseFile);
|
|
563
|
+
try {
|
|
564
|
+
ledger.prepare("INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail) VALUES (?,'desktop update',NULL,NULL,NULL,'coding owner released','released','policy',?)")
|
|
565
|
+
.run(new Date().toISOString(), `${j.old.version} stopped without releasing the coding workspace; process ${released.pid}${released.nativePid === null ? "" : ` and agent ${released.nativePid}`} proved gone`);
|
|
566
|
+
}
|
|
567
|
+
finally {
|
|
568
|
+
ledger.close();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
540
571
|
function assertCodingBackup(j) {
|
|
541
572
|
assertCodingCatalogPresent(j);
|
|
542
573
|
if (j.codingBackupPath || j.codingBackupHash) {
|
|
@@ -815,15 +846,25 @@ export async function runDesktopUpdate(stateDir, hooks = {}) {
|
|
|
815
846
|
throw Error("The installed app changed after preview. Nothing was replaced.");
|
|
816
847
|
const current = actual === j.old.hash ? j.old : atInstalled(j.next, j);
|
|
817
848
|
j.serviceInterrupted = true;
|
|
849
|
+
// Recorded once, before the first stop: a resumed update keeps the processes it first saw.
|
|
850
|
+
if (j.stoppedPids === undefined)
|
|
851
|
+
j.stoppedPids = servicePids(j.stateDir);
|
|
818
852
|
checkpoint("stopping", "Current work is finished. Stopping the background service for the update.");
|
|
819
853
|
await control("stop", current, j);
|
|
820
854
|
const stopped = await status(current, j);
|
|
821
855
|
if (["running", "loaded"].includes(stopped.state))
|
|
822
856
|
throw Error("The background service did not stop. No app swap was attempted.");
|
|
857
|
+
const alive = hooks.processAlive ?? (pid => processMayBeAlive(pid, false));
|
|
858
|
+
for (let waited = 0; j.stoppedPids.some(alive); waited += 250) {
|
|
859
|
+
if (waited >= 60_000)
|
|
860
|
+
throw Error(`The background service is still running (process ${j.stoppedPids.filter(alive).join(", ")}) after it was stopped. No app swap was attempted.`);
|
|
861
|
+
await sleep(250);
|
|
862
|
+
}
|
|
823
863
|
const afterStop = connect(j.databaseFile, true);
|
|
824
864
|
try {
|
|
825
865
|
if (Object.values(activeUpdateWork(afterStop)).some(n => n !== 0) || afterStop.prepare("SELECT 1 FROM watch_lease WHERE expires_at>? LIMIT 1").get(new Date().toISOString()))
|
|
826
866
|
throw Error("A worker or active operation still uses this database. Stop the other controller before continuing the update.");
|
|
867
|
+
releaseDesktopCodingOwner(j, afterStop, alive);
|
|
827
868
|
assertDesktopCodingStopped(j, afterStop);
|
|
828
869
|
}
|
|
829
870
|
finally {
|
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;
|