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/telegram.js
CHANGED
|
@@ -31,6 +31,7 @@ import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_
|
|
|
31
31
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
32
32
|
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
33
33
|
import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
|
|
34
|
+
import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText } from "./lead-voice.js";
|
|
34
35
|
import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
|
|
35
36
|
import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
|
|
36
37
|
import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
|
|
@@ -194,7 +195,7 @@ export function createTransport(token, timeoutMs = 30_000) {
|
|
|
194
195
|
catch (error) {
|
|
195
196
|
return {
|
|
196
197
|
ok: false,
|
|
197
|
-
description: scrub(
|
|
198
|
+
description: scrub(transportError(error), token),
|
|
198
199
|
uncertain: true,
|
|
199
200
|
};
|
|
200
201
|
}
|
|
@@ -204,6 +205,48 @@ export function createTransport(token, timeoutMs = 30_000) {
|
|
|
204
205
|
}
|
|
205
206
|
};
|
|
206
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Why this process can't reach Telegram the way the service does, or null.
|
|
210
|
+
* A coding agent's sandbox fences the network: Codex says so in its own
|
|
211
|
+
* variables, and Claude Code's sandbox routes traffic through a proxy on
|
|
212
|
+
* this computer, which curl uses and Node's fetch does not — so sends fail
|
|
213
|
+
* "fetch failed" while curl works (Oct 2). Builds, checks and agents also
|
|
214
|
+
* get a database of their own, so none of them can hold the live bridge;
|
|
215
|
+
* this keeps an agent-launched `watch` or `bridge` from holding it either.
|
|
216
|
+
*/
|
|
217
|
+
export function networkFence(env) {
|
|
218
|
+
const set = (name) => (env[name] ?? "") !== "";
|
|
219
|
+
if (set("CODEX_SANDBOX_NETWORK_DISABLED") || set("CODEX_SANDBOX"))
|
|
220
|
+
return "a Codex sandbox";
|
|
221
|
+
const agent = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"].some(set);
|
|
222
|
+
const loopbackProxy = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"].some(name => {
|
|
223
|
+
const value = env[name] ?? "";
|
|
224
|
+
if (value === "")
|
|
225
|
+
return false;
|
|
226
|
+
try {
|
|
227
|
+
return /^(?:localhost|127\.\d+\.\d+\.\d+|\[::1\])$/i.test(new URL(value.includes("://") ? value : `http://${value}`).hostname);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return agent && loopbackProxy ? "a Claude Code sandbox" : null;
|
|
234
|
+
}
|
|
235
|
+
/** The plain line a fenced process says instead of polling or sending. */
|
|
236
|
+
export function networkFenceLine(reason) {
|
|
237
|
+
return `Telegram: not connecting from here — this process runs inside ${reason}, which blocks its network. Replies and notifications go out from the Toolroll service.`;
|
|
238
|
+
}
|
|
239
|
+
/** fetch's own message ("fetch failed") with the reason under it, e.g. "fetch failed (EMFILE: too many open files)". */
|
|
240
|
+
export function transportError(error) {
|
|
241
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
242
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
243
|
+
if (cause === undefined || cause === null)
|
|
244
|
+
return message;
|
|
245
|
+
const code = typeof cause.code === "string" ? cause.code : null;
|
|
246
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
247
|
+
const why = code !== null && !detail.includes(code) ? `${code}: ${detail}` : detail;
|
|
248
|
+
return why === "" || why === message ? message : `${message} (${why.slice(0, 160)})`;
|
|
249
|
+
}
|
|
207
250
|
/** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
|
|
208
251
|
export function multipartOf(params, upload) {
|
|
209
252
|
const form = new FormData();
|
|
@@ -332,12 +375,16 @@ export async function followBridge(store, options) {
|
|
|
332
375
|
// turn must not stall the long poll, and each cycle's lease re-acquire is
|
|
333
376
|
// the renewal that keeps this the only live poller meanwhile. One
|
|
334
377
|
// processor at a time; its counts land on the totals when it finishes.
|
|
378
|
+
// It only persists the reply: the cycle sends it, in the same slot as
|
|
379
|
+
// notifications, so a reply never needs a connection of its own beside
|
|
380
|
+
// the long poll (Oct 2: replies failed "fetch failed" for minutes while
|
|
381
|
+
// notifications from the same process went out).
|
|
335
382
|
let inFlight = null;
|
|
336
383
|
const kick = () => {
|
|
337
384
|
if (inFlight !== null || options.conversation === undefined || options.readProjects === undefined)
|
|
338
385
|
return;
|
|
339
386
|
const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
|
|
340
|
-
inFlight = processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal)
|
|
387
|
+
inFlight = processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal, "turns")
|
|
341
388
|
.catch(error => { report.problems.push(`telegram chat: ${error instanceof Error ? error.message : String(error)}`); })
|
|
342
389
|
.finally(() => {
|
|
343
390
|
inFlight = null;
|
|
@@ -435,12 +482,13 @@ export async function followBridge(store, options) {
|
|
|
435
482
|
// the picker first. Best effort; typed commands work either way.
|
|
436
483
|
if (!commandsListed && options.conversation !== undefined) {
|
|
437
484
|
commandsListed = true;
|
|
485
|
+
// Both carry the stop signal: a stop never waits on a slow Bot API for them.
|
|
438
486
|
try {
|
|
439
|
-
await transport("setMyCommands", { commands: TELEGRAM_COMMANDS });
|
|
487
|
+
await transport("setMyCommands", { commands: TELEGRAM_COMMANDS }, signal);
|
|
440
488
|
}
|
|
441
489
|
catch { /* typed commands still work */ }
|
|
442
490
|
// After an upgrade, a bot still called StandingOrders becomes Toolroll, once per start; any other name is kept.
|
|
443
|
-
await nameTelegramBot(transport, "upgrade");
|
|
491
|
+
await nameTelegramBot(transport, "upgrade", signal);
|
|
444
492
|
}
|
|
445
493
|
const startedAt = Date.now();
|
|
446
494
|
const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
|
|
@@ -449,13 +497,18 @@ export async function followBridge(store, options) {
|
|
|
449
497
|
await deliverOutbox(store, botId, transport, owner, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin, options.conversation?.evidenceRoot);
|
|
450
498
|
await deliverTeam(store, botId, transport, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin);
|
|
451
499
|
}
|
|
500
|
+
// Replies the turn worker has planned go out here, like notifications.
|
|
501
|
+
if (options.conversation !== undefined && options.readProjects !== undefined) {
|
|
502
|
+
await processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal, "replies");
|
|
503
|
+
}
|
|
452
504
|
// Pushed updates first (some may wait from before a fall back), then — only while
|
|
453
505
|
// Telegram isn't pushing — ask for more.
|
|
454
506
|
const pushed = await drainInbox({ store, botId, transport, clock, report, readProjects: options.readProjects, conversation: options.conversation, projects: null }, owner, lease.generation);
|
|
455
507
|
if (pushed > 0)
|
|
456
508
|
report.pushed = pushed;
|
|
457
509
|
if (!pushing()) {
|
|
458
|
-
|
|
510
|
+
// While a turn is running, poll briefly so its reply is sent within a second or two of being written.
|
|
511
|
+
await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, inFlight === null ? pollSeconds : 1, signal, options.readProjects, options.conversation);
|
|
459
512
|
// A push address nobody here wants any more (the public address was removed): take it down, so asking works again.
|
|
460
513
|
if (report.webhookActive === true && push === null)
|
|
461
514
|
await transport("deleteWebhook", { drop_pending_updates: false }, signal);
|
|
@@ -474,11 +527,13 @@ export async function followBridge(store, options) {
|
|
|
474
527
|
total.chatRefused = (total.chatRefused ?? 0) + report.chatRefused;
|
|
475
528
|
if (report.chatConfirmed !== undefined)
|
|
476
529
|
total.chatConfirmed = (total.chatConfirmed ?? 0) + report.chatConfirmed;
|
|
530
|
+
if (report.chatAnswered !== undefined)
|
|
531
|
+
total.chatAnswered = (total.chatAnswered ?? 0) + report.chatAnswered;
|
|
477
532
|
total.problems.push(...report.problems);
|
|
478
|
-
if (report.sent > 0 || report.answered > 0 || report.paired > 0 || (report.statusReplies ?? 0) > 0 || (report.chatQueued ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || (report.chatConfirmed ?? 0) > 0 || report.problems.length > 0) {
|
|
533
|
+
if (report.sent > 0 || report.answered > 0 || report.paired > 0 || (report.statusReplies ?? 0) > 0 || (report.chatQueued ?? 0) > 0 || (report.chatAnswered ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || (report.chatConfirmed ?? 0) > 0 || report.problems.length > 0) {
|
|
479
534
|
options.onCycle?.(report);
|
|
480
535
|
}
|
|
481
|
-
if (report.problems.length > 0 && report.sent === 0 && report.answered === 0) {
|
|
536
|
+
if (report.problems.length > 0 && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0) {
|
|
482
537
|
// The wire is down. Back off; the counter resets on the first clean cycle.
|
|
483
538
|
if (!signal.aborted) {
|
|
484
539
|
await wait(FOLLOW_BACKOFF_MS[Math.min(failures, FOLLOW_BACKOFF_MS.length - 1)]);
|
|
@@ -493,7 +548,7 @@ export async function followBridge(store, options) {
|
|
|
493
548
|
const took = Date.now() - startedAt;
|
|
494
549
|
// While Telegram pushes (v98) there is no long poll to wait on: a pushed update waits at most this long.
|
|
495
550
|
const floor = pushing() ? PUSH_IDLE_MS : FOLLOW_IDLE_FLOOR_MS;
|
|
496
|
-
if (!signal.aborted && report.sent === 0 && report.answered === 0 && took < floor) {
|
|
551
|
+
if (!signal.aborted && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0 && took < floor) {
|
|
497
552
|
await wait(floor - took);
|
|
498
553
|
}
|
|
499
554
|
}
|
|
@@ -501,16 +556,25 @@ export async function followBridge(store, options) {
|
|
|
501
556
|
finally {
|
|
502
557
|
// A turn in flight finishes on its own bounds (the engine's wall clock);
|
|
503
558
|
// its reply is fenced on the claim and the channel like every other part.
|
|
504
|
-
if (inFlight !== null)
|
|
559
|
+
if (inFlight !== null) {
|
|
505
560
|
await inFlight;
|
|
561
|
+
// Its reply was written for the cycle that will not come: send it now, while the lease is still ours.
|
|
562
|
+
if (options.conversation !== undefined && options.readProjects !== undefined) {
|
|
563
|
+
const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
|
|
564
|
+
await processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, undefined, "replies").catch(() => { });
|
|
565
|
+
if (report.chatAnswered !== undefined)
|
|
566
|
+
total.chatAnswered = (total.chatAnswered ?? 0) + report.chatAnswered;
|
|
567
|
+
total.problems.push(...report.problems);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
506
570
|
store.releaseBridgeLease(botId, owner, clock());
|
|
507
571
|
}
|
|
508
572
|
return total;
|
|
509
573
|
}
|
|
510
574
|
/** The queued turns for one bot, counted onto the pass's report. */
|
|
511
|
-
async function processConversations(store, botId, transport, owner, clock, report, readProjects, conversation, signal) {
|
|
575
|
+
async function processConversations(store, botId, transport, owner, clock, report, readProjects, conversation, signal, only) {
|
|
512
576
|
const chat = { answered: 0, refused: 0, problems: [] };
|
|
513
|
-
await processTelegramConversations({ store, botId, transport, owner, clock, readProjects, options: conversation, report: chat, ...(signal === undefined ? {} : { signal }) });
|
|
577
|
+
await processTelegramConversations({ store, botId, transport, owner, clock, readProjects, options: conversation, report: chat, ...(signal === undefined ? {} : { signal }), ...(only === undefined ? {} : { only }) });
|
|
514
578
|
if (chat.answered > 0)
|
|
515
579
|
report.chatAnswered = (report.chatAnswered ?? 0) + chat.answered;
|
|
516
580
|
if (chat.refused > 0)
|
|
@@ -580,6 +644,8 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
|
|
|
580
644
|
catch {
|
|
581
645
|
report.problems.push("evening digest could not be prepared");
|
|
582
646
|
}
|
|
647
|
+
// A lead claim that went two hours quiet repaints its owner's card back to the task's real state.
|
|
648
|
+
enqueueLeadLapses(store, now);
|
|
583
649
|
const digestDue = digest.everyMs === null || digest.lastSentAt === null || now.getTime() >= new Date(digest.lastSentAt).getTime() + digest.everyMs;
|
|
584
650
|
// Every paired person is a destination of their own: each binding claims
|
|
585
651
|
// and settles its own rows under its own ceiling, in turn.
|
|
@@ -852,8 +918,35 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
852
918
|
}
|
|
853
919
|
return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
854
920
|
};
|
|
921
|
+
/** The lead's words (lead-voice.ts): one message; a later say within two minutes edits it in place. */
|
|
922
|
+
const leadSayOutcome = async (fact) => {
|
|
923
|
+
const words = leadSayText(fact);
|
|
924
|
+
const button = fact.link === null ? null : factButton(phoneOrigin, fact.link);
|
|
925
|
+
const keyboard = button === null ? [] : [button];
|
|
926
|
+
const earlier = leadSayEarlier(store, fact).map(id => store.telegramMessageOf(id, fact.destination)).find(one => one !== null) ?? null;
|
|
927
|
+
if (earlier !== null) {
|
|
928
|
+
const problem = (await readAccess()) ?? fence();
|
|
929
|
+
if (problem !== null)
|
|
930
|
+
return { ok: false, error: problem };
|
|
931
|
+
const edited = await editProgress(transport, binding.chatId, earlier, words, keyboard);
|
|
932
|
+
if (edited.ok) {
|
|
933
|
+
store.recordTelegramMessage(fact, binding, earlier, clock());
|
|
934
|
+
const after = (await readAccess()) ?? fence();
|
|
935
|
+
return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, earlier) } : { ok: false, error: after };
|
|
936
|
+
}
|
|
937
|
+
if (edited.retryAfter !== undefined)
|
|
938
|
+
store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
|
|
939
|
+
// Only Telegram's definitive missing/uneditable answer permits a new message.
|
|
940
|
+
if (!edited.replace)
|
|
941
|
+
return edited;
|
|
942
|
+
}
|
|
943
|
+
const sent = await sender(words, keyboard.length === 0 ? undefined : keyboard, [fact]);
|
|
944
|
+
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
945
|
+
};
|
|
855
946
|
let outcome;
|
|
856
|
-
if (
|
|
947
|
+
if (rows.length === 1 && row.kind === LEAD_SAY_KIND)
|
|
948
|
+
outcome = await leadSayOutcome(row);
|
|
949
|
+
else if (quiet && rows.length === 1 && isTaskFact(row))
|
|
857
950
|
outcome = await quietOutcome(row);
|
|
858
951
|
else {
|
|
859
952
|
// A failure or decision still gets its own alert. Refresh an existing
|
package/dist/test-temp.js
CHANGED
|
@@ -18,7 +18,7 @@ export const TEST_TEMP_PREFIXES = [
|
|
|
18
18
|
"flow-goal-", "flow-scratch", "flows-chat-", "invoke-attest-", "knowledge-test-", "learning-", "peek-", "phone-origin-",
|
|
19
19
|
"proposal-repo-", "race-", "refresh-migration-", "refresh-refusal-", "release-check-", "shared-chat-", "skills-test-",
|
|
20
20
|
"task-status-surfaces-", "template-repo-", "template-test-", "v14-migrate-", "v17-migrate-", "watch-anchor-",
|
|
21
|
-
"needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-",
|
|
21
|
+
"needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-",
|
|
22
22
|
// Playwright's browser profiles and artifacts.
|
|
23
23
|
"playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
|
|
24
24
|
];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { type LingeringRun, type UpdateWaiting } from "./desktop-update.js";
|
|
3
|
+
import { type ReleasedCodingOwner } from "./coding-update.js";
|
|
3
4
|
import { type SupervisorRunner } from "./daemon.js";
|
|
4
5
|
export declare const PROVENANCE_REPOSITORY = "https://github.com/ap9000/toolroll";
|
|
5
6
|
export declare const PROVENANCE_WORKFLOW = ".github/workflows/publish.yml";
|
|
@@ -103,6 +104,8 @@ export type RuntimeUpdateJournal = {
|
|
|
103
104
|
/** The restore put the backup back: a retried restore never puts it back again (what was written since belongs to
|
|
104
105
|
* the restored version and would be lost), and it keeps a fresh copy aside before every attempt until then. */
|
|
105
106
|
restoredDatabase?: boolean;
|
|
107
|
+
/** A stale coding owner record this run released after proving its processes gone. */
|
|
108
|
+
codingOwnerReleased?: ReleasedCodingOwner;
|
|
106
109
|
seen?: boolean;
|
|
107
110
|
/** The finished run the update is waiting on (or stopped waiting on): what is in the way and the command that
|
|
108
111
|
* clears it. Absent while it waits on ordinary running work. */
|
package/dist/toolroll-update.js
CHANGED
|
@@ -31,7 +31,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
31
31
|
import { createRequire } from "node:module";
|
|
32
32
|
import { clearWords, durableJson, LINGERING_LIMIT_MS, lingeringRun, lingeringWords, settleLeftoverRecords, sqliteLock, desktopUpdateWaitingOf, stoppedWaitingWords, updateWaitingWords, verifiedDatabaseBackup } from "./desktop-update.js";
|
|
33
33
|
import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused, updateGateOwned } from "./desktop-update-gate.js";
|
|
34
|
-
import {
|
|
34
|
+
import { processMayBeAlive } from "./process-liveness.js";
|
|
35
|
+
import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
|
|
35
36
|
import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
|
|
36
37
|
import { NAME } from "./names.js";
|
|
37
38
|
import { isNewer, REGISTRY } from "./releases.js";
|
|
@@ -887,6 +888,12 @@ async function driveRuntimeUpdate(j, system) {
|
|
|
887
888
|
refuseWhileWatching(j, system);
|
|
888
889
|
const db = new (sqlite().DatabaseSync)(j.databaseFile, { readOnly: true });
|
|
889
890
|
try {
|
|
891
|
+
// An older runtime killed before its close left its owner record behind: released once its processes are proved gone.
|
|
892
|
+
const released = releaseStaleCodingOwner(db, [...j.service?.pids ?? [], ...(j.watches ?? []).flatMap(w => w.pids)], (pid, group) => system.processAlive(pid) || processMayBeAlive(pid, group));
|
|
893
|
+
if (released !== null) {
|
|
894
|
+
j.codingOwnerReleased = released;
|
|
895
|
+
ledger(j.databaseFile, system.now(), j.actor, "toolroll coding owner released", "released", `${j.from.version} stopped without releasing the coding workspace; process ${released.pid}${released.nativePid === null ? "" : ` and agent ${released.nativePid}`} proved gone`);
|
|
896
|
+
}
|
|
890
897
|
assertCodingUpdateStopped(db);
|
|
891
898
|
}
|
|
892
899
|
finally {
|
|
@@ -1392,7 +1399,11 @@ export function machineSystem(home = homedir(), env = process.env, seams = {}) {
|
|
|
1392
1399
|
servicePids: async (unit) => {
|
|
1393
1400
|
const printed = await supervise("launchctl", ["print", `${domain}/${labelOf(unit)}`]);
|
|
1394
1401
|
const pid = Number(printed.stdout.match(/\n\s*pid = (\d+)/)?.[1]);
|
|
1395
|
-
|
|
1402
|
+
if (printed.code !== 0 || !(pid > 1))
|
|
1403
|
+
return [];
|
|
1404
|
+
// Its `up` child too: the process that owns the coding catalog, so its exit is proved as well.
|
|
1405
|
+
const children = await supervise("pgrep", ["-P", String(pid)]);
|
|
1406
|
+
return [pid, ...(children.code === 0 ? children.stdout.trim().split(/\s+/).map(Number).filter(one => Number.isSafeInteger(one) && one > 1) : [])];
|
|
1396
1407
|
},
|
|
1397
1408
|
stopService: async (unit) => {
|
|
1398
1409
|
// As long as a watch's toolroll up takes to exit.
|
package/dist/work-index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Store, type TaskState } from './store.js';
|
|
|
2
2
|
import type { AssignmentSnapshot } from './assignment.js';
|
|
3
3
|
import type { WorkAction, WorkSummaryAccess } from './work-summary.js';
|
|
4
4
|
import type { WorkStatus, WorkView } from './workspace-ui.js';
|
|
5
|
+
import { type Ask } from './needs-you.js';
|
|
5
6
|
export type WorkIndexCounts = Record<WorkView, number>;
|
|
6
7
|
export type WorkIndexItem = {
|
|
7
8
|
rootId: string;
|
|
@@ -22,6 +23,8 @@ export type WorkIndexItem = {
|
|
|
22
23
|
resultOutcome: string | null;
|
|
23
24
|
publicationUrl: string | null;
|
|
24
25
|
status: WorkStatus;
|
|
26
|
+
/** What a Needs you row asks of a person (needs-you.ts); null for every other row. */
|
|
27
|
+
ask: Ask | null;
|
|
25
28
|
primaryAction: WorkAction | null;
|
|
26
29
|
completion: {
|
|
27
30
|
actor: string;
|
|
@@ -38,9 +41,13 @@ export type WorkIndexOptions = {
|
|
|
38
41
|
state?: TaskState;
|
|
39
42
|
leadId?: string;
|
|
40
43
|
};
|
|
44
|
+
/** How the list is grouped: the Needs you rows by their ask, then building, then the rest. */
|
|
45
|
+
export type WorkIndexGroup = Ask | 'building' | 'rest';
|
|
46
|
+
export type WorkIndexGroupCounts = Record<WorkIndexGroup, number>;
|
|
41
47
|
export type WorkIndexPage = {
|
|
42
48
|
items: WorkIndexItem[];
|
|
43
49
|
totals: WorkIndexCounts;
|
|
50
|
+
groups: WorkIndexGroupCounts;
|
|
44
51
|
projects: WorkProjectCounts[];
|
|
45
52
|
nextCursor: string | null;
|
|
46
53
|
limit: number;
|
|
@@ -54,6 +61,8 @@ export type WorkProjectCounts = {
|
|
|
54
61
|
};
|
|
55
62
|
export declare const WORK_INDEX_PAGE_LIMIT = 40;
|
|
56
63
|
export declare const WORK_INDEX_MAX_LIMIT = 100;
|
|
64
|
+
/** A code's ask: a saved result is Review (unless its build may still run), otherwise its need's ask. */
|
|
65
|
+
export declare function askOfCode(code: string, custody?: boolean): Ask;
|
|
57
66
|
export declare class WorkIndexCursorError extends Error {
|
|
58
67
|
constructor();
|
|
59
68
|
}
|
package/dist/work-index.js
CHANGED
|
@@ -11,10 +11,12 @@ import { plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } f
|
|
|
11
11
|
import { isCheckLevel } from './check-levels.js';
|
|
12
12
|
import { MARKER } from './worktree.js';
|
|
13
13
|
import { withFollowUps } from './result-follow-ups.js';
|
|
14
|
-
import { NEEDS, processNeedOf } from './needs-you.js';
|
|
14
|
+
import { ASKS, NEED_ASK, NEEDS, processNeedOf } from './needs-you.js';
|
|
15
|
+
import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
|
|
15
16
|
export const WORK_INDEX_PAGE_LIMIT = 40;
|
|
16
17
|
export const WORK_INDEX_MAX_LIMIT = 100;
|
|
17
18
|
const EMPTY = { all: 0, 'needs-you': 0, running: 0, completed: 0 };
|
|
19
|
+
const NO_GROUPS = { decide: 0, review: 0, unblock: 0, building: 0, rest: 0 };
|
|
18
20
|
const FAMILY_PROBLEM = 'Task history is unavailable or incomplete. This task is shown separately.';
|
|
19
21
|
const VIEWS = ['all', 'needs-you', 'running', 'completed'];
|
|
20
22
|
const n = (row, key) => Number(row[key] ?? 0);
|
|
@@ -25,6 +27,27 @@ const s = (row, key) => row[key] == null ? null : String(row[key]);
|
|
|
25
27
|
* MATERIALIZED prevents counts + page selection repeating correlated reads. */
|
|
26
28
|
/** A gate note counts only while its pause is on one of the task's current providers. */
|
|
27
29
|
const GATE_PAUSE_ON_TASK = pauseOnTaskProviders('p', 'c.ref_id');
|
|
30
|
+
/** Index codes that wait on a person, as their need (needs-you.ts); dispatch codes come from task-status's own table. */
|
|
31
|
+
const INDEX_NEED = {
|
|
32
|
+
'earlier-active': 'earlier-version', 'process-needs-attention': 'confirm-stopped', 'invalid-scope': 'define-task', 'decision-queue': 'questions',
|
|
33
|
+
};
|
|
34
|
+
/** Every index code that can wait on a person. */
|
|
35
|
+
const PERSON_CODES = ['history-problem', 'earlier-active', 'ready-to-check', 'waiting-decision', 'decision-queue', 'result-needs-attention',
|
|
36
|
+
'process-needs-attention', 'failed', 'vanished-run', 'stopping', 'stopped', 'held', 'waiting-incident', 'terminal-dependency', 'needs-project',
|
|
37
|
+
'needs-scope', 'invalid-scope', 'needs-agent-profile', 'needs-approval', 'missing-requirement', 'no-worker-registered', 'no-worker-online', 'signed-out'];
|
|
38
|
+
/** A code's ask: a saved result is Review (unless its build may still run), otherwise its need's ask. */
|
|
39
|
+
export function askOfCode(code, custody = false) {
|
|
40
|
+
if (custody)
|
|
41
|
+
return 'unblock';
|
|
42
|
+
if (code === 'ready-to-check' || code === 'result-needs-attention')
|
|
43
|
+
return 'review';
|
|
44
|
+
return NEED_ASK[INDEX_NEED[code] ?? stageOfCode(code, { needsPerson: true }).need ?? 'other'];
|
|
45
|
+
}
|
|
46
|
+
const codesOf = (ask) => PERSON_CODES.filter(code => askOfCode(code) === ask).map(code => `'${code}'`).join(',');
|
|
47
|
+
/** The ask as its place in the list (ASKS order), read by the same code the row's status reads. */
|
|
48
|
+
const ASK_RANK = `CASE WHEN code IN (${codesOf('decide')}) THEN 0 WHEN code IN (${codesOf('review')}) AND NOT custody_unresolved THEN 1 ELSE 2 END`;
|
|
49
|
+
/** Where a row sits in the list: Decide, Review, Unblock, then building, then the rest by recency. */
|
|
50
|
+
const ORDER = `(CASE WHEN rank=0 THEN ask_rank WHEN rank=1 THEN 3 ELSE 4 END)`;
|
|
28
51
|
/** A row whose build may still be running (the store's reading, custodyReadings): Waiting, not Needs you. */
|
|
29
52
|
const CUSTODY_WAITS = `(custody_unresolved AND code IN ('process-needs-attention','result-needs-attention') AND ref_id IN (SELECT value FROM json_each($waitingCustody)))`;
|
|
30
53
|
const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
|
|
@@ -204,13 +227,18 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
|
|
|
204
227
|
WHEN deliverable='report' THEN 'scouting-ready'
|
|
205
228
|
ELSE 'queued' END code
|
|
206
229
|
FROM checked f
|
|
230
|
+
), claimed AS MATERIALIZED (
|
|
231
|
+
SELECT c.*,CASE WHEN code IN ('cancelled','complete') THEN 0 ELSE ${LEAD_CLAIM_SQL} END lead_claim FROM classified c
|
|
207
232
|
), ranked AS MATERIALIZED (
|
|
208
|
-
|
|
233
|
+
-- A task the person's lead is on (lead-voice.ts) waits on nobody: it leaves Needs you until the claim ends or lapses.
|
|
234
|
+
SELECT c.*,code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') AND NOT ${CUSTODY_WAITS} AND lead_claim<>1 needs,
|
|
235
|
+
code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') AND NOT ${CUSTODY_WAITS} person_need,
|
|
209
236
|
CASE WHEN code='complete' THEN 3 WHEN code='cancelled' THEN 4 WHEN code='running' THEN 1
|
|
210
237
|
WHEN ${CUSTODY_WAITS} OR code IN ('updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') THEN 2 ELSE 0 END rank,
|
|
238
|
+
${ASK_RANK} ask_rank,
|
|
211
239
|
CASE WHEN code='complete' THEN completed.at ELSE family_updated END sort_at,
|
|
212
240
|
completed.actor checked_actor,completed.at checked_at,completed.outcome checked_digest
|
|
213
|
-
FROM
|
|
241
|
+
FROM claimed c LEFT JOIN action_ledger completed ON completed.id=c.checked_id WHERE ($state IS NULL OR c.state=$state) AND ($leadId IS NULL OR EXISTS(SELECT 1 FROM team_task_owner own WHERE own.task_ref=c.root_ref AND own.lead=$leadId))
|
|
214
242
|
)`;
|
|
215
243
|
const registered = new WeakMap();
|
|
216
244
|
/** Connection-local pure parsing, with exactly the authoritative stored-scope
|
|
@@ -277,12 +305,19 @@ function parameters(now, access, options, custody) {
|
|
|
277
305
|
$now: now.toISOString(), $alive: new Date(now.getTime() - 180_000).toISOString(),
|
|
278
306
|
$all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []),
|
|
279
307
|
$unplaced: access.principal === 'operator' && access.includeUnplaced === true ? 1 : 0,
|
|
280
|
-
$projectSet: options.project != null ? 1 : 0, $project: options.project ?? null, $state: options.state ?? null, $leadId: options.leadId ?? null
|
|
308
|
+
$projectSet: options.project != null ? 1 : 0, $project: options.project ?? null, $state: options.state ?? null, $leadId: options.leadId ?? null,
|
|
309
|
+
$leadIdleSince: new Date(now.getTime() - LEAD_IDLE_MS).toISOString(),
|
|
310
|
+
$leadOf: access.principal === 'operator' && access.viewer != null && access.viewer !== '' ? `lead for ${access.viewer}` : null };
|
|
281
311
|
}
|
|
282
312
|
function counts(row) {
|
|
283
313
|
return { all: n(row, 'all_count'), 'needs-you': n(row, 'needs_count'), running: n(row, 'running_count'), completed: n(row, 'completed_count') };
|
|
284
314
|
}
|
|
285
315
|
const TOTALS = `COUNT(*) all_count,COALESCE(SUM(needs),0) needs_count,COALESCE(SUM(family_running),0) running_count,COALESCE(SUM(code='complete'),0) completed_count`;
|
|
316
|
+
const GROUP_TOTALS = `COALESCE(SUM(rank=0 AND ask_rank=0),0) decide_count,COALESCE(SUM(rank=0 AND ask_rank=1),0) review_count,COALESCE(SUM(rank=0 AND ask_rank=2),0) unblock_count,`
|
|
317
|
+
+ `COALESCE(SUM(rank=1),0) building_count,COALESCE(SUM(rank>1),0) rest_count`;
|
|
318
|
+
function groupCounts(row) {
|
|
319
|
+
return { decide: n(row, 'decide_count'), review: n(row, 'review_count'), unblock: n(row, 'unblock_count'), building: n(row, 'building_count'), rest: n(row, 'rest_count') };
|
|
320
|
+
}
|
|
286
321
|
export class WorkIndexCursorError extends Error {
|
|
287
322
|
constructor() { super('This work page cursor is invalid or belongs to a different view.'); this.name = 'WorkIndexCursorError'; }
|
|
288
323
|
}
|
|
@@ -299,7 +334,7 @@ function readCursor(value, scope) {
|
|
|
299
334
|
if (value.length > 1024 || !/^[\w-]+$/.test(value))
|
|
300
335
|
throw Error();
|
|
301
336
|
const c = JSON.parse(Buffer.from(value, 'base64url').toString());
|
|
302
|
-
if (c.version !==
|
|
337
|
+
if (c.version !== 2 || c.scope !== scope || !Number.isSafeInteger(c.rank) || c.rank < 0 || c.rank > 4 ||
|
|
303
338
|
!Number.isSafeInteger(c.root) || c.root < 1 || typeof c.at !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(c.at))
|
|
304
339
|
throw Error();
|
|
305
340
|
return c;
|
|
@@ -323,16 +358,16 @@ export function workIndexPage(store, now, access, options = {}) {
|
|
|
323
358
|
const scope = cursorScope(access, options, view), cursor = readCursor(options.cursor, scope);
|
|
324
359
|
const filter = view === 'needs-you' ? 'needs=1' : view === 'running' ? 'family_running=1' : view === 'completed' ? "code='complete'" : '1';
|
|
325
360
|
const rows = store.handle.prepare(`${projection}, selected_page AS MATERIALIZED (
|
|
326
|
-
SELECT * FROM ranked WHERE ${filter} AND ($cursorRoot=0 OR
|
|
327
|
-
(
|
|
328
|
-
ORDER BY
|
|
329
|
-
), page AS (SELECT p.*,COALESCE(p.result_id,(SELECT id FROM run WHERE task_ref=p.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1)) page_result_id FROM selected_page p), totals AS (SELECT ${TOTALS} FROM ranked), project_totals AS (
|
|
361
|
+
SELECT * FROM ranked WHERE ${filter} AND ($cursorRoot=0 OR
|
|
362
|
+
${ORDER}>$cursorRank OR (${ORDER}=$cursorRank AND (sort_at<$cursorAt OR (sort_at=$cursorAt AND root_ref<$cursorRoot))))
|
|
363
|
+
ORDER BY ${ORDER},sort_at DESC,root_ref DESC LIMIT $limit
|
|
364
|
+
), page AS (SELECT p.*,COALESCE(p.result_id,(SELECT id FROM run WHERE task_ref=p.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1)) page_result_id FROM selected_page p), totals AS (SELECT ${TOTALS},${GROUP_TOTALS} FROM ranked), project_totals AS (
|
|
330
365
|
SELECT repo,${TOTALS},SUM(state='queued' AND family_running=0) queued,
|
|
331
366
|
SUM(state='done' AND family_running=0 AND updated_at>=$recent) done_recently FROM ranked GROUP BY repo
|
|
332
367
|
)
|
|
333
368
|
SELECT 0 record_kind,totals.*,(SELECT json_group_array(json_object('repo',repo,'all_count',all_count,'needs_count',needs_count,
|
|
334
369
|
'running_count',running_count,'completed_count',completed_count,'queued',queued,'done_recently',done_recently)) FROM project_totals) row_json FROM totals
|
|
335
|
-
UNION ALL SELECT 1,0,0,0,0,json_object('root_ref',root_ref,'root_id',root_id,'id',id,'root_title',root_title,'repo',repo,
|
|
370
|
+
UNION ALL SELECT 1,0,0,0,0,0,0,0,0,0,json_object('root_ref',root_ref,'root_id',root_id,'id',id,'root_title',root_title,'repo',repo,
|
|
336
371
|
'state',state,'created_at',created_at,'family_updated',family_updated,'version_count',version_count,'earlier_active',earlier_active,
|
|
337
372
|
'earlier_id',earlier_id,'broken',broken,'live_run',live_run,'result_id',page_result_id,'result_outcome',(SELECT outcome FROM run WHERE id=page_result_id),
|
|
338
373
|
'publication_url',(SELECT pr_url FROM publication WHERE run=page_result_id),
|
|
@@ -342,7 +377,7 @@ export function workIndexPage(store, now, access, options = {}) {
|
|
|
342
377
|
'check_follows',(SELECT json_group_array(q.outcome||' '||COALESCE((SELECT substr(f.outcome,instr(f.outcome,':')+1) FROM action_ledger f WHERE f.repo IS page.repo AND f.run_id=page_result_id
|
|
343
378
|
AND f.action='checks finished' AND f.outcome LIKE q.id||':%' LIMIT 1),'open')) FROM (SELECT id,outcome FROM action_ledger WHERE repo IS page.repo AND run_id=page_result_id AND action='checks requested' ORDER BY id) q),
|
|
344
379
|
'proof_verdict',(SELECT COALESCE(machine_verdict,verdict) FROM proof_verdict WHERE run=page_result_id),
|
|
345
|
-
'proof_failed_check',(SELECT verdict='refuted' AND reasons_json LIKE '%approved verification command exited%' FROM proof_verdict WHERE run=page_result_id),'code',code,'needs',needs,'family_running',family_running,'rank',rank,'sort_at',sort_at,
|
|
380
|
+
'proof_failed_check',(SELECT verdict='refuted' AND reasons_json LIKE '%approved verification command exited%' FROM proof_verdict WHERE run=page_result_id),'code',code,'needs',needs,'person_need',person_need,'lead_claim',lead_claim,'family_running',family_running,'rank',rank,'ask_rank',ask_rank,'list_order',${ORDER},'sort_at',sort_at,
|
|
346
381
|
'checked_actor',checked_actor,'checked_at',checked_at,'checked_digest',checked_digest,'question_id',question_id,
|
|
347
382
|
'dependency_id',dependency_id,'dependency_state',dependency_state,'custody_unresolved',custody_unresolved,'hold_kind',hold_kind,'hold_reason',substr(hold_reason,1,300),'stop_run',stop_run,'unfinished',unfinished,'signed_out',signed_out,
|
|
348
383
|
'question_run',(SELECT run FROM decision WHERE id=page.question_id),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
|
|
@@ -351,15 +386,16 @@ export function workIndexPage(store, now, access, options = {}) {
|
|
|
351
386
|
.all({ ...parameters(now, access, options, custody), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
|
|
352
387
|
const selected = rows.slice(1).map(row => JSON.parse(String(row['row_json'])));
|
|
353
388
|
const page = selected.slice(0, limit), last = page.at(-1);
|
|
354
|
-
const nextCursor = selected.length > limit && last !== undefined ? Buffer.from(JSON.stringify({ version:
|
|
355
|
-
rank: n(last, '
|
|
389
|
+
const nextCursor = selected.length > limit && last !== undefined ? Buffer.from(JSON.stringify({ version: 2, scope,
|
|
390
|
+
rank: n(last, 'list_order'), at: s(last, 'sort_at'), root: n(last, 'root_ref') })).toString('base64url') : null;
|
|
356
391
|
const projects = JSON.parse(String(rows[0]?.['row_json'] ?? '[]')).map(row => ({ repo: s(row, 'repo'), totals: counts(row), queued: n(row, 'queued'), doneRecently: n(row, 'done_recently') }));
|
|
357
392
|
// A row whose build Toolroll can't confirm stopped reads the store's own answer, as the task page does.
|
|
358
393
|
const probe = (taskId) => {
|
|
359
394
|
const ref = store.lookupRef(taskId);
|
|
360
395
|
return ref === null ? null : custody.readings.get(ref.id) ?? null;
|
|
361
396
|
};
|
|
362
|
-
return { items: page.map(row => itemOf(row, access.principal, probe)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
|
|
397
|
+
return { items: page.map(row => itemOf(row, access.principal, probe)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
|
|
398
|
+
groups: rows[0] === undefined ? { ...NO_GROUPS } : groupCounts(rows[0]), nextCursor, limit, view };
|
|
363
399
|
}
|
|
364
400
|
export function workCountsByProject(store, now, access) {
|
|
365
401
|
const projection = registerValidators(store);
|
|
@@ -404,16 +440,15 @@ function listChecksOf(own, level, follows) {
|
|
|
404
440
|
return null;
|
|
405
441
|
return { status: read.status, exitCode: null, head: null, level: read.level, running: read.running };
|
|
406
442
|
}
|
|
407
|
-
/** Index codes that wait on a person, as their need (needs-you.ts); dispatch codes come from task-status's own table. */
|
|
408
|
-
const INDEX_NEED = {
|
|
409
|
-
'earlier-active': 'earlier-version', 'process-needs-attention': 'confirm-stopped', 'invalid-scope': 'define-task', 'decision-queue': 'questions',
|
|
410
|
-
};
|
|
411
443
|
function itemOf(row, principal, probe) {
|
|
412
444
|
const code = String(row['code']), id = String(row['id']);
|
|
413
445
|
const custody = code === 'process-needs-attention' || (code === 'result-needs-attention' && n(row, 'custody_unresolved') === 1);
|
|
414
446
|
const process = custody && probe !== undefined ? processNeedOf(probe(id)) : null;
|
|
415
447
|
const waiting = process !== null && 'wait' in process;
|
|
416
448
|
const need = n(row, 'needs') === 1 && !waiting, running = n(row, 'family_running') === 1;
|
|
449
|
+
// The lead's claim (lead-voice.ts): the row still reads its own stage, worded "Your lead is on it".
|
|
450
|
+
const lead = n(row, 'lead_claim') === 1 ? 'on-it' : n(row, 'lead_claim') === 2 ? 'lapsed' : null;
|
|
451
|
+
const personNeed = (n(row, 'person_need') === 1 || need) && !waiting;
|
|
417
452
|
const assignmentState = code === 'complete' ? 'complete' : code === 'ready-to-check' ? 'ready-to-check'
|
|
418
453
|
: code === 'cancelled' ? 'cancelled' : need ? 'needs-decision' : 'working';
|
|
419
454
|
const words = {
|
|
@@ -499,7 +534,7 @@ function itemOf(row, principal, probe) {
|
|
|
499
534
|
access: read ? 'read' : principal === 'operator' ? 'operator-control' : ['answer-decision', 'write-scope', 'unhold'].includes(actionCode) ? 'proposal-only' : 'operator-handoff',
|
|
500
535
|
retry: actionCode === 'reconcile-run' ? 'reconcile-before-retry' : read ? 'read-again' : 'refresh-before-acting' };
|
|
501
536
|
// The one shared status: the headline and sentence every surface uses.
|
|
502
|
-
const coded = stageOfCode(code, { needsPerson:
|
|
537
|
+
const coded = stageOfCode(code, { needsPerson: personNeed, planning: s(row, 'live_role') === 'planner', operatorHold: s(row, 'hold_kind') === 'operator' });
|
|
503
538
|
const reading = process !== null
|
|
504
539
|
? 'wait' in process ? { stage: 'waiting', wait: process.wait } : { stage: 'needs-you', need: process.need }
|
|
505
540
|
: custody ? { stage: 'needs-you', need: 'confirm-stopped' }
|
|
@@ -511,7 +546,8 @@ function itemOf(row, principal, probe) {
|
|
|
511
546
|
...(process === null ? {} : { needContext: { build: process.build } }),
|
|
512
547
|
reason: finished ? null : plainReasonOf(reading.stage, code, detail || null), report: s(row, 'result_role') === 'scout',
|
|
513
548
|
checks: !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']),
|
|
514
|
-
completedBy: code !== 'complete' ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, '')
|
|
549
|
+
completedBy: code !== 'complete' ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, ''),
|
|
550
|
+
...(lead === null ? {} : { lead }) });
|
|
515
551
|
label = shared.headline;
|
|
516
552
|
detail = shared.sentence;
|
|
517
553
|
// Every Needs you action wears its need's own words.
|
|
@@ -533,6 +569,7 @@ function itemOf(row, principal, probe) {
|
|
|
533
569
|
resultOutcome: s(row, 'result_outcome'), publicationUrl: s(row, 'publication_url'),
|
|
534
570
|
status: { token: `assignment-${assignmentState}`, label, detail, tone: workToneOf(shared.headline),
|
|
535
571
|
action: primaryAction === null ? null : { label: actionLabel, kind: actionCode === 'open-result' ? 'open-result' : 'open-task' }, views, rank: n(row, 'rank') },
|
|
572
|
+
ask: need ? ASKS[n(row, 'ask_rank')] ?? 'unblock' : null,
|
|
536
573
|
primaryAction, completion: code === 'complete' ? { actor: String(row['checked_actor']), at: String(row['checked_at']), digest: String(row['checked_digest']) } : null,
|
|
537
574
|
evidence: 'recorded' };
|
|
538
575
|
}
|
package/dist/work-summary.d.ts
CHANGED
|
@@ -41,10 +41,13 @@ export declare function openWorkDecisionOf(store: Store, taskRef: number, now: D
|
|
|
41
41
|
export declare function workDecisionAction(taskId: string, decision: NonNullable<WorkFacts["openDecision"]>, principal: WorkPrincipal): WorkAction;
|
|
42
42
|
/** Pure counterpart for callers already holding the same facts. */
|
|
43
43
|
export declare function workSummaryOf(facts: WorkFacts, principal: WorkPrincipal, resultDisplay?: DisplayStatus): WorkSummary;
|
|
44
|
-
export type WorkSummaryAccess =
|
|
44
|
+
export type WorkSummaryAccess =
|
|
45
|
+
/** `viewer`: the person reading, whose own lead's claim (lead-voice.ts) a task reads as "Your lead is on it". */
|
|
46
|
+
{
|
|
45
47
|
principal: "operator";
|
|
46
48
|
repos: readonly string[] | null;
|
|
47
49
|
includeUnplaced?: boolean;
|
|
50
|
+
viewer?: string | null;
|
|
48
51
|
} | {
|
|
49
52
|
principal: "coordinator";
|
|
50
53
|
repos: readonly string[];
|
package/dist/workspace-ui.d.ts
CHANGED
|
@@ -190,6 +190,31 @@ export declare function dispatchActionLabel(dispatch: DispatchDiagnosis | null):
|
|
|
190
190
|
/** A finished result's display status in the shared headline's words: a
|
|
191
191
|
* failed project check is Failed, a result missing its record needs a
|
|
192
192
|
* person, every other saved result is Ready for review. Token and action stay. */
|
|
193
|
+
/** The result page's own sentence when the decision is on it. */
|
|
194
|
+
export declare const RESULT_DECISION_SENTENCE = "Review the change, then accept it or ask for changes.";
|
|
195
|
+
/** What the result page's Accept says, from facts it already shows: the
|
|
196
|
+
* recorded checks, the requirements not met, the ones a person still checks,
|
|
197
|
+
* whether the saved proof reads, and what the button posts. "Accept" only when everything is met and the
|
|
198
|
+
* checks passed; otherwise "Accept without checks" and one line naming what.
|
|
199
|
+
* `effect` says what pressing it does, in the action's own terms. */
|
|
200
|
+
export type AcceptFacts = {
|
|
201
|
+
checks: "passed" | "failed" | "not-run" | "off" | "running" | "unavailable" | null;
|
|
202
|
+
unmet: number;
|
|
203
|
+
yours: number;
|
|
204
|
+
/** `complete`: marks the exact result complete; `accept`: records a person's acceptance only. */
|
|
205
|
+
action: "complete" | "accept";
|
|
206
|
+
/** `pull-request`: one can be opened from the task after; `off`: publishing isn't set up; `other`: neither is said. Accept itself never publishes. */
|
|
207
|
+
publishing: "pull-request" | "off" | "other";
|
|
208
|
+
/** False when the saved proof is missing or can't be read: nothing on record says what was met. */
|
|
209
|
+
proof: boolean;
|
|
210
|
+
};
|
|
211
|
+
export type AcceptWords = {
|
|
212
|
+
label: "Accept" | "Accept without checks";
|
|
213
|
+
ready: boolean;
|
|
214
|
+
why: string | null;
|
|
215
|
+
effect: string;
|
|
216
|
+
};
|
|
217
|
+
export declare function acceptWordsOf(facts: AcceptFacts): AcceptWords;
|
|
193
218
|
export declare function resultHeadlineOf<T extends DisplayStatus>(status: T): T;
|
|
194
219
|
/** The one shared headline (task-status.ts) over the work status below: the
|
|
195
220
|
* token, views, rank, action and diagnostics stay; the words and tone are
|
package/dist/workspace-ui.js
CHANGED
|
@@ -195,7 +195,7 @@ function storedResultStatusOf(result, publication) {
|
|
|
195
195
|
};
|
|
196
196
|
}
|
|
197
197
|
if (result.verdict === "short" && humanReview) {
|
|
198
|
-
return { token: "verification-needed", label: "Ready to inspect", detail: withPublication("The remaining requirements need a person's inspection.
|
|
198
|
+
return { token: "verification-needed", label: "Ready to inspect", detail: withPublication("The remaining requirements need a person's inspection."), tone: "attention", action: { label: "Inspect the result", kind: "open-review" } };
|
|
199
199
|
}
|
|
200
200
|
if (result.verdict === "short") {
|
|
201
201
|
return {
|
|
@@ -312,6 +312,25 @@ const VERIFIED_RESULT = new Set(["ready-to-review", "pr-opened", "merge-observed
|
|
|
312
312
|
/** A finished result's display status in the shared headline's words: a
|
|
313
313
|
* failed project check is Failed, a result missing its record needs a
|
|
314
314
|
* person, every other saved result is Ready for review. Token and action stay. */
|
|
315
|
+
/** The result page's own sentence when the decision is on it. */
|
|
316
|
+
export const RESULT_DECISION_SENTENCE = "Review the change, then accept it or ask for changes.";
|
|
317
|
+
export function acceptWordsOf(facts) {
|
|
318
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
319
|
+
const parts = [
|
|
320
|
+
...(facts.proof ? [] : ["nothing on record says what was met"]),
|
|
321
|
+
...(facts.checks === "passed" ? [] : [facts.checks === "failed" ? "checks failed" : facts.checks === "off" ? "checks are off for this project"
|
|
322
|
+
: facts.checks === "running" ? "checks are still running" : facts.checks === "unavailable" ? "saved checks can't be read" : "checks didn't run"]),
|
|
323
|
+
...(facts.unmet > 0 ? [`${plural(facts.unmet, "requirement isn't", "requirements aren't")} met`] : []),
|
|
324
|
+
...(facts.yours > 0 ? [`${plural(facts.yours, "item still needs", "items still need")} your check`] : []),
|
|
325
|
+
];
|
|
326
|
+
const ready = parts.length === 0;
|
|
327
|
+
const joined = parts.length <= 1 ? parts.join("") : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
|
|
328
|
+
const effect = facts.action === "accept" ? "Records that you accept it. You mark it complete next."
|
|
329
|
+
: facts.publishing === "pull-request" ? "Marks it complete. No pull request opens; you can open one from the task after."
|
|
330
|
+
: facts.publishing === "off" ? "Marks it complete. The branch stays; publishing isn't set up."
|
|
331
|
+
: "Marks it complete. Nothing is published.";
|
|
332
|
+
return { label: ready ? "Accept" : "Accept without checks", ready, why: ready ? null : `${joined.charAt(0).toUpperCase()}${joined.slice(1)}.`, effect };
|
|
333
|
+
}
|
|
315
334
|
export function resultHeadlineOf(status) {
|
|
316
335
|
const read = RESULT_NEEDS_YOU.has(status.token) ? { stage: "needs-you", need: "other" }
|
|
317
336
|
: status.token === "checks-failed" ? { stage: "failed" } : { stage: "finished" };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.12",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|