toolroll 0.9.8 → 0.9.9
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 +22 -0
- package/dist/assignment-brief.d.ts +2 -0
- package/dist/assignment-brief.js +2 -1
- package/dist/assignment-presentation.js +3 -1
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +5 -0
- package/dist/assignment.d.ts +9 -0
- package/dist/assignment.js +39 -7
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-crew.js +4 -0
- package/dist/browser-workspace.d.ts +40 -0
- package/dist/builder.d.ts +2 -0
- package/dist/builder.js +114 -8
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -1
- package/dist/check-progress.js +4 -3
- package/dist/checkout-cleanup.d.ts +40 -2
- package/dist/checkout-cleanup.js +129 -9
- package/dist/desktop-update.d.ts +52 -0
- package/dist/desktop-update.js +147 -5
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +8 -0
- package/dist/first-run.d.ts +1 -1
- package/dist/first-run.js +7 -5
- package/dist/lead-context.d.ts +1 -1
- package/dist/lead-context.js +4 -2
- package/dist/lead-status.d.ts +10 -1
- package/dist/lead-status.js +21 -24
- package/dist/needs-you.d.ts +55 -0
- package/dist/needs-you.js +83 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +112 -14
- package/dist/plane-review.js +4 -0
- package/dist/process-recovery-eligibility.d.ts +1 -0
- package/dist/project-learning.d.ts +3 -3
- package/dist/project-learning.js +28 -17
- package/dist/proof.d.ts +3 -1
- package/dist/proof.js +4 -2
- package/dist/result-completion.d.ts +4 -0
- package/dist/result-completion.js +11 -0
- package/dist/review-context.d.ts +3 -0
- package/dist/review-context.js +45 -5
- package/dist/run-closeout.d.ts +89 -0
- package/dist/run-closeout.js +302 -0
- package/dist/serve.d.ts +15 -1
- package/dist/serve.js +129 -14
- package/dist/setup-guide.d.ts +2 -2
- package/dist/shared-deps.d.ts +94 -0
- package/dist/shared-deps.js +365 -0
- package/dist/storage.js +2 -0
- package/dist/store.d.ts +36 -0
- package/dist/store.js +125 -29
- package/dist/task-status.d.ts +18 -4
- package/dist/task-status.js +35 -18
- package/dist/telegram-progress.js +2 -1
- package/dist/telegram-status.js +3 -3
- package/dist/test-temp.d.ts +19 -0
- package/dist/test-temp.js +98 -0
- package/dist/toolroll-update-ui.js +7 -2
- package/dist/toolroll-update.d.ts +21 -1
- package/dist/toolroll-update.js +135 -5
- package/dist/work-index.d.ts +11 -0
- package/dist/work-index.js +103 -16
- package/dist/work-summary.d.ts +3 -1
- package/dist/workspace-ui.js +1 -1
- package/dist/worktree.d.ts +16 -0
- package/dist/worktree.js +47 -0
- package/package.json +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test temp folders (`toolroll storage`, `storage clean`): what the unit tests, journeys and scripts make in the
|
|
3
|
+
* system temp folder. The tests delete their own (each run gets one temp root that its global teardown removes:
|
|
4
|
+
* test/temp-root.ts); a run that was killed, and older versions, leave folders behind. `toolroll storage` counts them
|
|
5
|
+
* and `storage clean` removes the ones nothing has touched for a day.
|
|
6
|
+
*
|
|
7
|
+
* Every prefix the tests and scripts give tmpdir() is covered here (test-temp.test.ts reads the sources to keep it
|
|
8
|
+
* so), and Playwright's own browser profiles.
|
|
9
|
+
*/
|
|
10
|
+
import { lstatSync, readdirSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
const DAY_MS = 86_400_000;
|
|
14
|
+
/** Name prefixes of what tests, journeys and scripts leave in the temp folder. */
|
|
15
|
+
export const TEST_TEMP_PREFIXES = [
|
|
16
|
+
"so-", "standing-orders-", "toolroll-", "no-wt", "epoch", "cancel-floor",
|
|
17
|
+
"attest-", "budgets-", "chat-v66-", "check-levels-", "coding-context-", "console-url-", "console-v2-", "e2-attest-",
|
|
18
|
+
"flow-goal-", "flow-scratch", "flows-chat-", "invoke-attest-", "knowledge-test-", "learning-", "peek-", "phone-origin-",
|
|
19
|
+
"proposal-repo-", "race-", "refresh-migration-", "refresh-refusal-", "release-check-", "shared-chat-", "skills-test-",
|
|
20
|
+
"task-status-surfaces-", "template-repo-", "template-test-", "v14-migrate-", "v17-migrate-", "watch-anchor-",
|
|
21
|
+
"needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-",
|
|
22
|
+
// Playwright's browser profiles and artifacts.
|
|
23
|
+
"playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
|
|
24
|
+
];
|
|
25
|
+
export function isTestTemp(name) {
|
|
26
|
+
return TEST_TEMP_PREFIXES.some(prefix => name.startsWith(prefix));
|
|
27
|
+
}
|
|
28
|
+
/** Where they collect: the temp folder (and /tmp, where some scripts write). */
|
|
29
|
+
export function tempRoots() {
|
|
30
|
+
const roots = [tmpdir()];
|
|
31
|
+
if (process.platform !== "win32" && !roots.includes("/tmp"))
|
|
32
|
+
roots.push("/tmp");
|
|
33
|
+
return roots;
|
|
34
|
+
}
|
|
35
|
+
/** When anything last happened in a folder: it, or what is directly in it, last changed. */
|
|
36
|
+
function touchedAt(path) {
|
|
37
|
+
let newest = lstatSync(path).mtimeMs;
|
|
38
|
+
let names = [];
|
|
39
|
+
try {
|
|
40
|
+
names = readdirSync(path);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return newest;
|
|
44
|
+
}
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
try {
|
|
47
|
+
newest = Math.max(newest, lstatSync(join(path, name)).mtimeMs);
|
|
48
|
+
}
|
|
49
|
+
catch { /* gone */ }
|
|
50
|
+
}
|
|
51
|
+
return newest;
|
|
52
|
+
}
|
|
53
|
+
/** Every test temp folder in `roots`, and which of them nothing has touched for a day. */
|
|
54
|
+
export function testTempFolders(roots, now) {
|
|
55
|
+
const all = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
for (const root of roots) {
|
|
58
|
+
let names = [];
|
|
59
|
+
try {
|
|
60
|
+
names = readdirSync(root);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
if (!isTestTemp(name))
|
|
67
|
+
continue;
|
|
68
|
+
const path = join(root, name);
|
|
69
|
+
try {
|
|
70
|
+
const stat = lstatSync(path);
|
|
71
|
+
const key = `${stat.dev}:${stat.ino}`;
|
|
72
|
+
if (seen.has(key) || stat.isSymbolicLink())
|
|
73
|
+
continue;
|
|
74
|
+
seen.add(key);
|
|
75
|
+
all.push({ path, touchedAt: stat.isDirectory() ? touchedAt(path) : stat.mtimeMs });
|
|
76
|
+
}
|
|
77
|
+
catch { /* gone */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { all, stale: all.filter(one => now.getTime() - one.touchedAt > DAY_MS) };
|
|
81
|
+
}
|
|
82
|
+
/** Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes. */
|
|
83
|
+
export function removeStaleTestTemp(roots, now) {
|
|
84
|
+
const removed = [];
|
|
85
|
+
const failed = [];
|
|
86
|
+
for (const one of testTempFolders(roots, now).stale) {
|
|
87
|
+
try {
|
|
88
|
+
if (now.getTime() - (lstatSync(one.path).isDirectory() ? touchedAt(one.path) : lstatSync(one.path).mtimeMs) <= DAY_MS)
|
|
89
|
+
continue;
|
|
90
|
+
rmSync(one.path, { recursive: true, force: true, maxRetries: 3 });
|
|
91
|
+
removed.push(one.path);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
failed.push(one.path);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { removed, failed };
|
|
98
|
+
}
|
|
@@ -12,7 +12,7 @@ export const UPDATES_CSS = `.updates{max-width:640px;min-width:0;overflow-wrap:a
|
|
|
12
12
|
`.updates ol.update-steps li[data-state=failed]{color:var(--so-danger);font-weight:600}.updates ol.update-steps li[data-state=failed]::before{background:var(--so-danger);border-color:var(--so-danger)}` +
|
|
13
13
|
`.updates ol.update-steps .step-detail{grid-column:2;font-weight:400;color:var(--so-muted);font-size:.8125rem;margin-top:2px}` +
|
|
14
14
|
`.updates .whats-new ul{margin:10px 0 0;padding-left:18px}.updates .whats-new li{margin:4px 0}.updates .whats-new form{margin:14px 0 0}` +
|
|
15
|
-
`.updates .update-problem{color:var(--so-danger)}.updates .stamp{margin:10px 0 0}.updates code{white-space:nowrap}` +
|
|
15
|
+
`.updates .update-problem{color:var(--so-danger)}.updates .stamp{margin:10px 0 0}.updates code{white-space:nowrap}.updates [data-update-outcome=stopped] code,.updates .step-detail code{white-space:normal;overflow-wrap:anywhere}` +
|
|
16
16
|
`@media (prefers-reduced-motion:no-preference){.updates ol.update-steps li[data-state=now]::before{animation:update-pulse 1.6s ease-in-out infinite}}@keyframes update-pulse{50%{box-shadow:0 0 0 7px color-mix(in srgb,var(--so-info) 6%,transparent)}}` +
|
|
17
17
|
`@media (max-width:600px){.updates .update-actions{display:grid}.updates .update-actions button{width:100%;min-height:44px}.updates .step-up{max-width:none}.updates .step-up input{min-height:44px}}`;
|
|
18
18
|
/** The steps, each done, current, failed or waiting. Also the live region's fragment. */
|
|
@@ -23,7 +23,8 @@ export function updateStepsHtml(j, running) {
|
|
|
23
23
|
const items = UPDATE_STEPS.map((step, i) => {
|
|
24
24
|
const state = j.phase === "complete" || i < last ? "done" : i > last ? "waiting" : failed ? "failed" : finished ? "waiting" : "now";
|
|
25
25
|
// Only where it adds something: which work it waits for, or what went wrong.
|
|
26
|
-
const detail =
|
|
26
|
+
const detail = state === "now" && step === "draining" && j.waiting ? `<span class="step-detail">${e(j.waiting.on)}.${j.waiting.action ? ` If nothing of it is running, run <code>${e(j.waiting.action)}</code>.` : ""}</span>`
|
|
27
|
+
: (state === "now" && step === "draining") || (state === "failed" && j.phase === "rolling-back") ? `<span class="step-detail">${e(j.detail)}</span>` : "";
|
|
27
28
|
return `<li data-step="${step}" data-state="${state}">${e(STEP_WORDS[step])}${detail}</li>`;
|
|
28
29
|
}).join("");
|
|
29
30
|
const title = j.phase === "scheduled" ? `Update to ${e(j.to.version)} scheduled for ${e(clock(j.at ?? j.startedAt))}`
|
|
@@ -38,6 +39,10 @@ function outcomeHtml(j) {
|
|
|
38
39
|
return j.kind === "rollback" ? `<div class="card" data-update-outcome="rolled-back"><h2>Back on ${e(j.to.version)}</h2><p class="meta">${e(j.detail)}</p></div>` : "";
|
|
39
40
|
if (j.phase === "cancelled")
|
|
40
41
|
return `<div class="card" data-update-outcome="cancelled"><h2>Update cancelled</h2><p class="meta">Nothing changed.</p></div>`;
|
|
42
|
+
// Stopped on a finished run it can't show has ended: what is in the way and the one command, nothing more.
|
|
43
|
+
if (j.phase === "refused" && j.waiting)
|
|
44
|
+
return `<div class="card" data-update-outcome="stopped"><h2>Update to ${e(j.to.version)} stopped: run #${e(j.waiting.run)} is in the way</h2>` +
|
|
45
|
+
`<p>${e(j.waiting.on)}. New work resumed.</p>` + (j.waiting.action ? `<p class="meta">If nothing of it is running, run <code>${e(j.waiting.action)}</code>, then update again.</p>` : `<p class="meta">Update again once it has stopped.</p>`) + `</div>`;
|
|
41
46
|
const title = j.phase === "refused" ? `Didn't update to ${e(j.to.version)}` : j.phase === "restored" ? `Update to ${e(j.to.version)} didn't finish` : "The update needs attention";
|
|
42
47
|
return `<div class="card" data-update-outcome="${e(j.phase)}"><h2>${title}</h2><p class="${j.phase === "needs-attention" ? "update-problem" : ""}" role="alert">${e(j.detail)}</p>` +
|
|
43
48
|
`<details><summary>Steps</summary>${updateStepsHtml(j, false).replace(/<h2>.*?<\/h2>/, "")}</details></div>`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { type LingeringRun, type UpdateWaiting } from "./desktop-update.js";
|
|
2
3
|
import { type SupervisorRunner } from "./daemon.js";
|
|
3
4
|
export declare const PROVENANCE_REPOSITORY = "https://github.com/ap9000/toolroll";
|
|
4
5
|
export declare const PROVENANCE_WORKFLOW = ".github/workflows/publish.yml";
|
|
@@ -103,6 +104,11 @@ export type RuntimeUpdateJournal = {
|
|
|
103
104
|
* the restored version and would be lost), and it keeps a fresh copy aside before every attempt until then. */
|
|
104
105
|
restoredDatabase?: boolean;
|
|
105
106
|
seen?: boolean;
|
|
107
|
+
/** The finished run the update is waiting on (or stopped waiting on): what is in the way and the command that
|
|
108
|
+
* clears it. Absent while it waits on ordinary running work. */
|
|
109
|
+
waiting?: LingeringRun & {
|
|
110
|
+
since: string;
|
|
111
|
+
};
|
|
106
112
|
};
|
|
107
113
|
export type PackageRelease = {
|
|
108
114
|
version: string;
|
|
@@ -160,6 +166,8 @@ export type UpdateOutcome = {
|
|
|
160
166
|
journal: RuntimeUpdateJournal | null;
|
|
161
167
|
};
|
|
162
168
|
export declare const runtimeUpdateTerminal: (phase: RuntimePhase) => boolean;
|
|
169
|
+
/** How long a launched or resuming updater is expected to take to start and hold the lock. */
|
|
170
|
+
export declare const UPDATER_START_MS: number;
|
|
163
171
|
export declare function readRuntimeUpdate(stateDir: string, file?: string): RuntimeUpdateJournal | null;
|
|
164
172
|
/** The update `--rollback` returns from: the last one that completed, whatever was attempted since. */
|
|
165
173
|
export declare function lastCompletedUpdate(stateDir: string): RuntimeUpdateJournal | null;
|
|
@@ -245,6 +253,18 @@ export type RuntimeUpdateStatus = {
|
|
|
245
253
|
} | null;
|
|
246
254
|
};
|
|
247
255
|
export declare function runtimeUpdateStatus(stateDir: string): RuntimeUpdateStatus;
|
|
256
|
+
/** An update that is waiting, what on, and the one action that clears it: for `toolroll status` and the console.
|
|
257
|
+
* `inTheWay` asks whether a finished run still holds it up; a stopped update whose run has since cleared says nothing. */
|
|
258
|
+
export type { UpdateWaiting };
|
|
259
|
+
export declare function updateWaitingOf(stateDir: string, inTheWay: (run: number) => boolean): UpdateWaiting | null;
|
|
260
|
+
/** An updater that ended while letting work finish (a crash, a kill, the Mac restarting) leaves new work paused with
|
|
261
|
+
* nobody left to lift the pause. With no updater holding the update, the pause is lifted here and the update says it
|
|
262
|
+
* stopped. Nothing was switched before the backup, so nothing else needs putting back. The background worker's
|
|
263
|
+
* reconcile, `toolroll status` and the console run this. Returns what it said, or null. */
|
|
264
|
+
export declare function releaseStalledUpdate(stateDir: string, now: Date): string | null;
|
|
265
|
+
/** What `toolroll status` and the console show: a stalled `toolroll update` released first, then this installation's
|
|
266
|
+
* waiting update, `toolroll update`'s or the desktop app's. */
|
|
267
|
+
export declare function waitingUpdate(databaseFile: string, inTheWay: (run: number) => boolean, now: Date, desktopStates?: readonly string[]): UpdateWaiting | null;
|
|
248
268
|
export declare function markWhatsNewSeen(stateDir: string): void;
|
|
249
269
|
type Exec = (command: string, args: string[], options?: {
|
|
250
270
|
cwd?: string;
|
|
@@ -282,8 +302,8 @@ export declare function launchRuntimeUpdate(args: {
|
|
|
282
302
|
run?: SupervisorRunner;
|
|
283
303
|
platform?: NodeJS.Platform;
|
|
284
304
|
npmBin?: () => Promise<string | null>;
|
|
305
|
+
now?: () => Date;
|
|
285
306
|
}): Promise<void>;
|
|
286
307
|
/** The job's last act: its definition goes, so nothing can start it again. Only the definition for this id. */
|
|
287
308
|
export declare function retireUpdateJob(id: string, home?: string): void;
|
|
288
309
|
export declare const nextScheduledAt: typeof nextAt;
|
|
289
|
-
export {};
|
package/dist/toolroll-update.js
CHANGED
|
@@ -29,7 +29,7 @@ import { homedir, userInfo } from "node:os";
|
|
|
29
29
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
30
30
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
31
31
|
import { createRequire } from "node:module";
|
|
32
|
-
import { durableJson,
|
|
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
34
|
import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, removeCodingUpdateGate } from "./coding-update.js";
|
|
35
35
|
import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
|
|
@@ -61,6 +61,24 @@ const journalFile = (stateDir) => join(stateDir, "toolroll-update.json");
|
|
|
61
61
|
/** The last completed update, kept apart from the journal: what `--rollback` returns from. */
|
|
62
62
|
const lastUpdateFile = (stateDir) => join(stateDir, "toolroll-update.last.json");
|
|
63
63
|
const cancelFile = (j) => join(j.stageDir, "cancel-request.json");
|
|
64
|
+
/** An updater on its way to this update before it holds the lock: the console's job once launched (launchd may not
|
|
65
|
+
* have started it yet), or a resume about to drive it. */
|
|
66
|
+
const startingFile = (j) => join(j.stageDir, "updater-starting.json");
|
|
67
|
+
/** How long a launched or resuming updater is expected to take to start and hold the lock. */
|
|
68
|
+
export const UPDATER_START_MS = 2 * 60_000;
|
|
69
|
+
function markStarting(j, state, now) {
|
|
70
|
+
durableJson(startingFile(j), { id: j.id, state, at: now.toISOString() });
|
|
71
|
+
}
|
|
72
|
+
function updaterStarting(j, now) {
|
|
73
|
+
try {
|
|
74
|
+
const mark = JSON.parse(readFileSync(startingFile(j), "utf8"));
|
|
75
|
+
const age = now.getTime() - Date.parse(mark.at ?? "");
|
|
76
|
+
return mark.id === j.id && age >= 0 && age < UPDATER_START_MS;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
64
82
|
const sha = (bytes, algorithm = "sha256") => createHash(algorithm).update(bytes).digest("hex");
|
|
65
83
|
const fileHash = (file) => sha(readFileSync(file));
|
|
66
84
|
const quote = (s) => '"' + s.replaceAll('"', '""') + '"';
|
|
@@ -744,6 +762,7 @@ export async function resumeRuntimeUpdate(stateDir, system, id) {
|
|
|
744
762
|
return { ok: true, phase: j?.phase ?? "complete", message: "The update this job was started for is no longer the saved one. Nothing was changed.", journal: j };
|
|
745
763
|
if (!j || runtimeUpdateTerminal(j.phase))
|
|
746
764
|
return { ok: true, phase: j?.phase ?? "complete", message: "No update is in progress.", journal: j };
|
|
765
|
+
markStarting(j, "resuming", system.now());
|
|
747
766
|
return driveRuntimeUpdate(j, system);
|
|
748
767
|
}
|
|
749
768
|
/** A prepared update whose job could not start. */
|
|
@@ -801,6 +820,23 @@ async function driveRuntimeUpdate(j, system) {
|
|
|
801
820
|
const lock = sqliteLock(join(j.stageDir, "worker.sqlite"), 1000);
|
|
802
821
|
if (!lock)
|
|
803
822
|
return { ok: false, phase: j.phase, message: `An updater is already working on the update to ${j.to.version}.`, journal: j };
|
|
823
|
+
// What was read before the lock may be out of date: a stalled update released, or a cancel, in between.
|
|
824
|
+
let saved = null;
|
|
825
|
+
try {
|
|
826
|
+
saved = readRuntimeUpdate(j.stateDir);
|
|
827
|
+
}
|
|
828
|
+
catch {
|
|
829
|
+
saved = null;
|
|
830
|
+
}
|
|
831
|
+
if (saved?.id === j.id) {
|
|
832
|
+
if (runtimeUpdateTerminal(saved.phase)) {
|
|
833
|
+
lock.close();
|
|
834
|
+
return { ok: saved.phase === "complete" || saved.phase === "cancelled", phase: saved.phase, message: saved.detail, journal: saved };
|
|
835
|
+
}
|
|
836
|
+
Object.assign(j, saved);
|
|
837
|
+
}
|
|
838
|
+
// The lock now says an updater is here; the starting mark has done its job.
|
|
839
|
+
rmSync(startingFile(j), { force: true });
|
|
804
840
|
const at = (phase) => UPDATE_STEPS.indexOf(phase);
|
|
805
841
|
const step = (phase, detail) => { save(j, phase, detail, system.now()); system.checkpoint?.(phase); };
|
|
806
842
|
const noun = j.kind === "update" ? "update" : "rollback";
|
|
@@ -1042,9 +1078,15 @@ async function drain(j, system) {
|
|
|
1042
1078
|
try {
|
|
1043
1079
|
db.exec("PRAGMA busy_timeout=5000");
|
|
1044
1080
|
if (j.when === "now") {
|
|
1045
|
-
const running = runningWorkWords(db)
|
|
1081
|
+
const running = runningWorkWords(db);
|
|
1046
1082
|
if (running)
|
|
1047
1083
|
throw new Refusal(`Work is running: ${running}. Nothing was changed. Use When idle to wait for it.`);
|
|
1084
|
+
settleLeftoverRecords(db, system.now());
|
|
1085
|
+
const lingering = lingeringRun(db);
|
|
1086
|
+
if (lingering) {
|
|
1087
|
+
j.waiting = { ...lingering, since: system.now().toISOString() };
|
|
1088
|
+
throw new Refusal(`${lingering.on}. ${clearWords(lingering)} Nothing was changed.`);
|
|
1089
|
+
}
|
|
1048
1090
|
if (updateAdmissionPaused(db) && !updateGateOwned(db, j.id))
|
|
1049
1091
|
throw new Refusal("Another update owns the admission pause. Nothing was changed.");
|
|
1050
1092
|
installUpdateGate(db, j.id);
|
|
@@ -1058,10 +1100,23 @@ async function drain(j, system) {
|
|
|
1058
1100
|
for (;;) {
|
|
1059
1101
|
if (cancelRequested(j))
|
|
1060
1102
|
return;
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1103
|
+
const idle = Object.values(activeUpdateWork(db)).every(n => n === 0);
|
|
1104
|
+
// The same settling reconcile the background worker runs, so a leftover record it can prove never holds this up.
|
|
1105
|
+
if (idle)
|
|
1106
|
+
settleLeftoverRecords(db, system.now());
|
|
1107
|
+
const lingering = idle ? lingeringRun(db) : null;
|
|
1108
|
+
if (!lingering && freezeUpdateGate(db, j.id)) {
|
|
1109
|
+
delete j.waiting;
|
|
1063
1110
|
return;
|
|
1064
|
-
|
|
1111
|
+
}
|
|
1112
|
+
if (!lingering)
|
|
1113
|
+
delete j.waiting;
|
|
1114
|
+
else if (j.waiting?.run !== lingering.run || j.waiting.on !== lingering.on)
|
|
1115
|
+
j.waiting = { ...lingering, since: system.now().toISOString() };
|
|
1116
|
+
// Waiting longer on a finished run proves nothing more: stop, let new work resume, and say what clears it.
|
|
1117
|
+
if (j.waiting && system.now().getTime() - Date.parse(j.waiting.since) >= LINGERING_LIMIT_MS)
|
|
1118
|
+
throw new Refusal(stoppedWaitingWords(j.waiting));
|
|
1119
|
+
save(j, "draining", lingering ? `New work is paused. Waiting for run #${lingering.run}: ${lingeringWords(lingering)}` : `New work is paused. Waiting for ${runningWorkWords(db) ?? "running work"} to finish. Nothing is being cancelled.`, system.now());
|
|
1065
1120
|
await system.sleep(2000);
|
|
1066
1121
|
}
|
|
1067
1122
|
}
|
|
@@ -1121,6 +1176,77 @@ export function runtimeUpdateStatus(stateDir) {
|
|
|
1121
1176
|
}
|
|
1122
1177
|
return { journal: j, running, whatsNew, lastUpdate: last ? { from: last.from.version, to: last.to.version } : null };
|
|
1123
1178
|
}
|
|
1179
|
+
export function updateWaitingOf(stateDir, inTheWay) {
|
|
1180
|
+
let j;
|
|
1181
|
+
try {
|
|
1182
|
+
j = readRuntimeUpdate(stateDir);
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
return null;
|
|
1186
|
+
}
|
|
1187
|
+
if (!j || j.kind !== "update")
|
|
1188
|
+
return null;
|
|
1189
|
+
const version = j.to.version;
|
|
1190
|
+
if (j.phase === "draining")
|
|
1191
|
+
return updateWaitingWords({ app: false, version, stopped: false, lingering: j.waiting ?? null });
|
|
1192
|
+
if (j.phase !== "refused" || !j.waiting || !inTheWay(j.waiting.run))
|
|
1193
|
+
return null;
|
|
1194
|
+
return updateWaitingWords({ app: false, version, stopped: true, lingering: j.waiting });
|
|
1195
|
+
}
|
|
1196
|
+
/** An updater that ended while letting work finish (a crash, a kill, the Mac restarting) leaves new work paused with
|
|
1197
|
+
* nobody left to lift the pause. With no updater holding the update, the pause is lifted here and the update says it
|
|
1198
|
+
* stopped. Nothing was switched before the backup, so nothing else needs putting back. The background worker's
|
|
1199
|
+
* reconcile, `toolroll status` and the console run this. Returns what it said, or null. */
|
|
1200
|
+
export function releaseStalledUpdate(stateDir, now) {
|
|
1201
|
+
let j;
|
|
1202
|
+
try {
|
|
1203
|
+
j = readRuntimeUpdate(stateDir);
|
|
1204
|
+
}
|
|
1205
|
+
catch {
|
|
1206
|
+
return null;
|
|
1207
|
+
}
|
|
1208
|
+
if (!j || j.phase !== "draining")
|
|
1209
|
+
return null;
|
|
1210
|
+
const lock = sqliteLock(join(j.stageDir, "worker.sqlite"));
|
|
1211
|
+
if (!lock)
|
|
1212
|
+
return null;
|
|
1213
|
+
try {
|
|
1214
|
+
// An updater may have moved on, or a resumed one replaced it, between the first read and the lock.
|
|
1215
|
+
const held = readRuntimeUpdate(stateDir);
|
|
1216
|
+
if (!held || held.id !== j.id || held.phase !== "draining")
|
|
1217
|
+
return null;
|
|
1218
|
+
// A job launched or a resume under way has not taken the lock yet: it continues this update; nothing is lifted under it.
|
|
1219
|
+
if (updaterStarting(held, now))
|
|
1220
|
+
return null;
|
|
1221
|
+
try {
|
|
1222
|
+
ungate(held);
|
|
1223
|
+
}
|
|
1224
|
+
catch { /* no pause yet, or the database is unreadable: the journal still says it stopped */ }
|
|
1225
|
+
if (held.kind === "update")
|
|
1226
|
+
rmSync(join(held.stageDir, "runtime"), { recursive: true, force: true });
|
|
1227
|
+
const noun = held.kind === "update" ? "update" : "rollback";
|
|
1228
|
+
const words = `The ${noun} to ${held.to.version} stopped: its updater ended before running work finished. Nothing was changed; new work resumed. Run toolroll ${noun === "update" ? "update" : "update --rollback"} to try again.`;
|
|
1229
|
+
save(held, "refused", words, now);
|
|
1230
|
+
try {
|
|
1231
|
+
ledger(held.databaseFile, now, held.actor, `toolroll ${noun} stopped`, "refused", `${held.from.version} → ${held.to.version}: the updater ended while draining`);
|
|
1232
|
+
}
|
|
1233
|
+
catch { /* the journal still says what happened */ }
|
|
1234
|
+
return words;
|
|
1235
|
+
}
|
|
1236
|
+
finally {
|
|
1237
|
+
lock.close();
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
/** What `toolroll status` and the console show: a stalled `toolroll update` released first, then this installation's
|
|
1241
|
+
* waiting update, `toolroll update`'s or the desktop app's. */
|
|
1242
|
+
export function waitingUpdate(databaseFile, inTheWay, now, desktopStates) {
|
|
1243
|
+
const stateDir = dirname(databaseFile);
|
|
1244
|
+
try {
|
|
1245
|
+
releaseStalledUpdate(stateDir, now);
|
|
1246
|
+
}
|
|
1247
|
+
catch { /* an unreadable record is shown as it is */ }
|
|
1248
|
+
return updateWaitingOf(stateDir, inTheWay) ?? desktopUpdateWaitingOf(databaseFile, inTheWay, desktopStates);
|
|
1249
|
+
}
|
|
1124
1250
|
export function markWhatsNewSeen(stateDir) {
|
|
1125
1251
|
const j = readRuntimeUpdate(stateDir);
|
|
1126
1252
|
if (!j || j.seen || j.phase !== "complete")
|
|
@@ -1362,6 +1488,10 @@ export async function launchRuntimeUpdate(args, seams = {}) {
|
|
|
1362
1488
|
const dist = args.dist ?? dirname(fileURLToPath(import.meta.url));
|
|
1363
1489
|
const command = [process.execPath, join(dist, "bin.js"), "update", "--resume", "--id", args.id, "--db", args.databaseFile];
|
|
1364
1490
|
const stateDir = dirname(args.databaseFile);
|
|
1491
|
+
// Pending until launchd starts the job and it holds the lock: nothing releases the update meanwhile.
|
|
1492
|
+
const saved = readRuntimeUpdate(stateDir);
|
|
1493
|
+
if (saved?.id === args.id && !runtimeUpdateTerminal(saved.phase))
|
|
1494
|
+
markStarting(saved, "launched", (seams.now ?? (() => new Date()))());
|
|
1365
1495
|
const log = join(stateDir, "toolroll-update.log");
|
|
1366
1496
|
if ((seams.platform ?? process.platform) === "darwin") {
|
|
1367
1497
|
const home = seams.home ?? homedir();
|
package/dist/work-index.d.ts
CHANGED
|
@@ -66,3 +66,14 @@ export declare class WorkIndexCursorError extends Error {
|
|
|
66
66
|
* Cursors bind the admitted scope, project and view, never broaden access. */
|
|
67
67
|
export declare function workIndexPage(store: Store, now: Date, access: WorkSummaryAccess, options?: WorkIndexOptions): WorkIndexPage;
|
|
68
68
|
export declare function workCountsByProject(store: Store, now: Date, access: WorkSummaryAccess): WorkProjectCounts[];
|
|
69
|
+
/** Families whose headline is Ready for review, newest first: the Tasks
|
|
70
|
+
* list's own rows, so a completed family (root or any revision), a failed
|
|
71
|
+
* check and a superseded version never count. */
|
|
72
|
+
export declare function workReadyForReview(store: Store, now: Date, access: WorkSummaryAccess, limit?: number): {
|
|
73
|
+
count: number;
|
|
74
|
+
results: {
|
|
75
|
+
rootId: string;
|
|
76
|
+
taskId: string;
|
|
77
|
+
run: number | null;
|
|
78
|
+
}[];
|
|
79
|
+
};
|
package/dist/work-index.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/** Paginated, permission-bound summaries of saved task facts. These reads do
|
|
2
|
-
* not verify artifacts
|
|
2
|
+
* not verify artifacts or authorize task operations; a build Toolroll can't
|
|
3
|
+
* yet confirm stopped is read through the store's own read-only probe. */
|
|
3
4
|
import { createHash } from 'node:crypto';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
4
7
|
import { readProjectAccess } from './project-access.js';
|
|
5
8
|
import { pauseOnTaskProviders, providerName, signInReason } from './provider-auth.js';
|
|
6
|
-
import { scopeTermsProblem } from './store.js';
|
|
9
|
+
import { scopeTermsProblem, stopFactOf } from './store.js';
|
|
7
10
|
import { plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } from './task-status.js';
|
|
8
11
|
import { isCheckLevel } from './check-levels.js';
|
|
12
|
+
import { MARKER } from './worktree.js';
|
|
9
13
|
import { withFollowUps } from './result-follow-ups.js';
|
|
14
|
+
import { NEEDS, processNeedOf } from './needs-you.js';
|
|
10
15
|
export const WORK_INDEX_PAGE_LIMIT = 40;
|
|
11
16
|
export const WORK_INDEX_MAX_LIMIT = 100;
|
|
12
17
|
const EMPTY = { all: 0, 'needs-you': 0, running: 0, completed: 0 };
|
|
@@ -20,6 +25,8 @@ const s = (row, key) => row[key] == null ? null : String(row[key]);
|
|
|
20
25
|
* MATERIALIZED prevents counts + page selection repeating correlated reads. */
|
|
21
26
|
/** A gate note counts only while its pause is on one of the task's current providers. */
|
|
22
27
|
const GATE_PAUSE_ON_TASK = pauseOnTaskProviders('p', 'c.ref_id');
|
|
28
|
+
/** A row whose build may still be running (the store's reading, custodyReadings): Waiting, not Needs you. */
|
|
29
|
+
const CUSTODY_WAITS = `(custody_unresolved AND code IN ('process-needs-attention','result-needs-attention') AND ref_id IN (SELECT value FROM json_each($waitingCustody)))`;
|
|
23
30
|
const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
|
|
24
31
|
SELECT t.id, t.title, t.state, t.created_at, t.updated_at, r.id ref_id, r.repo,
|
|
25
32
|
r.revision_of, r.revision_brief_artifact, r.assigned_runner, r.plan,
|
|
@@ -55,7 +62,8 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
|
|
|
55
62
|
(SELECT id FROM run WHERE task_ref=a.ref_id AND outcome IS NULL ORDER BY id DESC LIMIT 1) unfinished,
|
|
56
63
|
CASE WHEN a.state='done' THEN (SELECT id FROM run WHERE task_ref=a.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1) END result_id,
|
|
57
64
|
(SELECT h.id FROM hold h WHERE h.task_ref=a.ref_id AND (h.until IS NULL OR h.until>$now) ORDER BY h.held_at,h.id LIMIT 1) hold_id,
|
|
58
|
-
q.decision_id,cu.task_ref IS NOT NULL
|
|
65
|
+
q.decision_id,(cu.task_ref IS NOT NULL OR a.ref_id IN (SELECT value FROM json_each($workspaceCustody)))
|
|
66
|
+
AND a.ref_id NOT IN (SELECT value FROM json_each($settledCustody)) custody_unresolved
|
|
59
67
|
FROM admitted a LEFT JOIN lineage l ON l.ref_id=a.ref_id
|
|
60
68
|
LEFT JOIN questions q ON q.task_ref=a.ref_id LEFT JOIN custody cu ON cu.task_ref=a.ref_id
|
|
61
69
|
), family_heads AS MATERIALIZED (
|
|
@@ -197,9 +205,9 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
|
|
|
197
205
|
ELSE 'queued' END code
|
|
198
206
|
FROM checked f
|
|
199
207
|
), ranked AS MATERIALIZED (
|
|
200
|
-
SELECT c.*,code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') needs,
|
|
208
|
+
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} needs,
|
|
201
209
|
CASE WHEN code='complete' THEN 3 WHEN code='cancelled' THEN 4 WHEN code='running' THEN 1
|
|
202
|
-
WHEN code IN ('updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') THEN 2 ELSE 0 END rank,
|
|
210
|
+
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,
|
|
203
211
|
CASE WHEN code='complete' THEN completed.at ELSE family_updated END sort_at,
|
|
204
212
|
completed.actor checked_actor,completed.at checked_at,completed.outcome checked_digest
|
|
205
213
|
FROM classified 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))
|
|
@@ -235,8 +243,38 @@ function registerValidators(store) {
|
|
|
235
243
|
registered.set(db, projection);
|
|
236
244
|
return projection;
|
|
237
245
|
}
|
|
238
|
-
|
|
239
|
-
|
|
246
|
+
/** The list can't see processes: each task whose build Toolroll can't yet
|
|
247
|
+
* confirm stopped, as the store reads it now (the task page's own reading).
|
|
248
|
+
* Null when it has since settled; `open` runs are the classification's own.
|
|
249
|
+
* `workspace`: a finished task whose last build's workspace note is still
|
|
250
|
+
* there, which only the store can read (a held or unreadable workspace). */
|
|
251
|
+
function custodyReadings(store) {
|
|
252
|
+
const refs = store.handle.prepare(`SELECT r.task_ref FROM held_session h JOIN run r ON r.id=h.run WHERE h.ended_at IS NULL
|
|
253
|
+
UNION SELECT r.task_ref FROM run r WHERE r.provider_started_at IS NOT NULL AND NOT EXISTS(SELECT 1 FROM run_process p WHERE p.run=r.id)
|
|
254
|
+
UNION SELECT r.task_ref FROM run_process p JOIN run r ON r.id=p.run WHERE p.exited_at IS NULL AND p.container_empty_at IS NULL`).all().map(row => Number(row['task_ref']));
|
|
255
|
+
const noted = store.handle.prepare(`SELECT r.task_ref,r.worktree FROM task t JOIN task_ref tr ON tr.external_id=t.id
|
|
256
|
+
JOIN run r ON r.id=(SELECT MAX(id) FROM run WHERE task_ref=tr.id AND role IN ('builder','scout') AND finished_at IS NOT NULL)
|
|
257
|
+
WHERE t.state='done' AND r.worktree IS NOT NULL`).all()
|
|
258
|
+
.filter(row => existsSync(join(String(row['worktree']), MARKER))).map(row => Number(row['task_ref']));
|
|
259
|
+
const readings = new Map();
|
|
260
|
+
for (const ref of new Set([...refs, ...noted])) {
|
|
261
|
+
let found = null;
|
|
262
|
+
for (const run of store.runsFor(ref)) {
|
|
263
|
+
const fact = stopFactOf(store, run.id);
|
|
264
|
+
if (fact !== null && fact.kind !== 'open') {
|
|
265
|
+
found = fact;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
readings.set(ref, found);
|
|
270
|
+
}
|
|
271
|
+
return { readings, workspace: new Set(noted) };
|
|
272
|
+
}
|
|
273
|
+
function parameters(now, access, options, custody) {
|
|
274
|
+
const ids = (keep) => JSON.stringify([...custody.readings].filter(([ref, fact]) => keep(ref, fact)).map(([ref]) => ref));
|
|
275
|
+
return { $settledCustody: ids((_, fact) => fact === null), $waitingCustody: ids((_, fact) => fact !== null && 'wait' in (processNeedOf(fact) ?? {})),
|
|
276
|
+
$workspaceCustody: ids((ref, fact) => fact !== null && custody.workspace.has(ref)),
|
|
277
|
+
$now: now.toISOString(), $alive: new Date(now.getTime() - 180_000).toISOString(),
|
|
240
278
|
$all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []),
|
|
241
279
|
$unplaced: access.principal === 'operator' && access.includeUnplaced === true ? 1 : 0,
|
|
242
280
|
$projectSet: options.project != null ? 1 : 0, $project: options.project ?? null, $state: options.state ?? null, $leadId: options.leadId ?? null };
|
|
@@ -279,6 +317,7 @@ function readCursor(value, scope) {
|
|
|
279
317
|
* Cursors bind the admitted scope, project and view, never broaden access. */
|
|
280
318
|
export function workIndexPage(store, now, access, options = {}) {
|
|
281
319
|
const projection = registerValidators(store);
|
|
320
|
+
const custody = custodyReadings(store);
|
|
282
321
|
const view = VIEWS.includes(options.view ?? 'all') ? options.view ?? 'all' : 'all';
|
|
283
322
|
const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(WORK_INDEX_MAX_LIMIT, Math.floor(options.limit))) : WORK_INDEX_PAGE_LIMIT;
|
|
284
323
|
const scope = cursorScope(access, options, view), cursor = readCursor(options.cursor, scope);
|
|
@@ -309,21 +348,39 @@ export function workIndexPage(store, now, access, options = {}) {
|
|
|
309
348
|
'question_run',(SELECT run FROM decision WHERE id=page.question_id),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
|
|
310
349
|
'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),
|
|
311
350
|
'question_task',(SELECT r.external_id FROM decision d JOIN run ON run.id=d.run JOIN task_ref r ON r.id=run.task_ref WHERE d.id=page.question_id)) FROM page`)
|
|
312
|
-
.all({ ...parameters(now, access, options), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
|
|
351
|
+
.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() });
|
|
313
352
|
const selected = rows.slice(1).map(row => JSON.parse(String(row['row_json'])));
|
|
314
353
|
const page = selected.slice(0, limit), last = page.at(-1);
|
|
315
354
|
const nextCursor = selected.length > limit && last !== undefined ? Buffer.from(JSON.stringify({ version: 1, scope,
|
|
316
355
|
rank: n(last, 'rank'), at: s(last, 'sort_at'), root: n(last, 'root_ref') })).toString('base64url') : null;
|
|
317
356
|
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') }));
|
|
318
|
-
|
|
357
|
+
// A row whose build Toolroll can't confirm stopped reads the store's own answer, as the task page does.
|
|
358
|
+
const probe = (taskId) => {
|
|
359
|
+
const ref = store.lookupRef(taskId);
|
|
360
|
+
return ref === null ? null : custody.readings.get(ref.id) ?? null;
|
|
361
|
+
};
|
|
362
|
+
return { items: page.map(row => itemOf(row, access.principal, probe)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]), nextCursor, limit, view };
|
|
319
363
|
}
|
|
320
364
|
export function workCountsByProject(store, now, access) {
|
|
321
365
|
const projection = registerValidators(store);
|
|
322
366
|
return store.handle.prepare(`${projection} SELECT repo,${TOTALS},SUM(state='queued' AND family_running=0) queued,
|
|
323
367
|
SUM(state='done' AND family_running=0 AND updated_at>=$recent) done_recently FROM ranked GROUP BY repo ORDER BY repo`)
|
|
324
|
-
.all({ ...parameters(now, access, {}), $recent: new Date(now.getTime() - 86_400_000).toISOString() })
|
|
368
|
+
.all({ ...parameters(now, access, {}, custodyReadings(store)), $recent: new Date(now.getTime() - 86_400_000).toISOString() })
|
|
325
369
|
.map(row => ({ repo: s(row, 'repo'), totals: counts(row), queued: n(row, 'queued'), doneRecently: n(row, 'done_recently') }));
|
|
326
370
|
}
|
|
371
|
+
/** Families whose headline is Ready for review, newest first: the Tasks
|
|
372
|
+
* list's own rows, so a completed family (root or any revision), a failed
|
|
373
|
+
* check and a superseded version never count. */
|
|
374
|
+
export function workReadyForReview(store, now, access, limit = 5) {
|
|
375
|
+
const ready = [];
|
|
376
|
+
let cursor = null;
|
|
377
|
+
do {
|
|
378
|
+
const page = workIndexPage(store, now, access, { view: 'needs-you', limit: WORK_INDEX_MAX_LIMIT, cursor });
|
|
379
|
+
ready.push(...page.items.filter(one => one.status.label === 'Ready for review'));
|
|
380
|
+
cursor = page.nextCursor;
|
|
381
|
+
} while (cursor !== null);
|
|
382
|
+
return { count: ready.length, results: ready.slice(0, Math.max(0, limit)).map(one => ({ rootId: one.rootId, taskId: one.activeTaskId, run: one.resultRunId })) };
|
|
383
|
+
}
|
|
327
384
|
/** Labels stay navigation, never an authorization to mutate. Full task
|
|
328
385
|
* opening re-proves process custody, receipt bytes and approval terms. */
|
|
329
386
|
/** The list's Checks: the build's own result, its level, and follow-up checks on the same commit (result-follow-ups.ts). */
|
|
@@ -347,9 +404,16 @@ function listChecksOf(own, level, follows) {
|
|
|
347
404
|
return null;
|
|
348
405
|
return { status: read.status, exitCode: null, head: null, level: read.level, running: read.running };
|
|
349
406
|
}
|
|
350
|
-
|
|
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
|
+
function itemOf(row, principal, probe) {
|
|
351
412
|
const code = String(row['code']), id = String(row['id']);
|
|
352
|
-
const
|
|
413
|
+
const custody = code === 'process-needs-attention' || (code === 'result-needs-attention' && n(row, 'custody_unresolved') === 1);
|
|
414
|
+
const process = custody && probe !== undefined ? processNeedOf(probe(id)) : null;
|
|
415
|
+
const waiting = process !== null && 'wait' in process;
|
|
416
|
+
const need = n(row, 'needs') === 1 && !waiting, running = n(row, 'family_running') === 1;
|
|
353
417
|
const assignmentState = code === 'complete' ? 'complete' : code === 'ready-to-check' ? 'ready-to-check'
|
|
354
418
|
: code === 'cancelled' ? 'cancelled' : need ? 'needs-decision' : 'working';
|
|
355
419
|
const words = {
|
|
@@ -375,7 +439,7 @@ function itemOf(row, principal) {
|
|
|
375
439
|
'needs-scope': ['Needs your decision', 'Define the task before a worker can start.', 'write-scope', 'Define the task'],
|
|
376
440
|
'invalid-scope': ['Needs your decision', 'The saved task scope cannot be read safely.', 'write-scope', 'Review scope'],
|
|
377
441
|
'needs-agent-profile': ['Needs your decision', 'Choose an available agent for this task.', 'select-agent', 'Choose an agent'],
|
|
378
|
-
'needs-approval': ['Needs your decision', 'Review the current plan before a worker can start.', 'approve-scope', '
|
|
442
|
+
'needs-approval': ['Needs your decision', 'Review the current plan before a worker can start.', 'approve-scope', 'Approve plan'],
|
|
379
443
|
'missing-requirement': ['Needs your decision', 'A required capability is missing or expired.', 'repair-capability', 'Review missing requirement'],
|
|
380
444
|
'no-worker-registered': ['Needs your decision', 'No connected builder includes this project.', 'start-worker', 'Check connection'],
|
|
381
445
|
'no-worker-online': ['Needs your decision', 'The builder has stopped checking in.', 'start-worker', 'Check connection'],
|
|
@@ -409,6 +473,18 @@ function itemOf(row, principal) {
|
|
|
409
473
|
actionCode = 'open-result';
|
|
410
474
|
actionLabel = 'Open result';
|
|
411
475
|
}
|
|
476
|
+
if (process !== null && 'need' in process) {
|
|
477
|
+
actionCode = 'confirm-stopped';
|
|
478
|
+
actionLabel = NEEDS[process.need].action.label;
|
|
479
|
+
}
|
|
480
|
+
else if (custody && probe === undefined) {
|
|
481
|
+
actionCode = 'confirm-stopped';
|
|
482
|
+
actionLabel = NEEDS['confirm-stopped'].action.label;
|
|
483
|
+
}
|
|
484
|
+
if (code === 'signed-out') {
|
|
485
|
+
actionCode = 'sign-in';
|
|
486
|
+
actionLabel = NEEDS['sign-in'].action.label;
|
|
487
|
+
}
|
|
412
488
|
if (actionCode === null) {
|
|
413
489
|
actionCode = code === 'running' ? 'inspect-run' : 'inspect-task';
|
|
414
490
|
actionLabel = code === 'running' ? 'Watch the build' : 'View task details';
|
|
@@ -417,21 +493,32 @@ function itemOf(row, principal) {
|
|
|
417
493
|
const primaryAction = actionCode === null ? null : { code: actionCode, label: actionLabel,
|
|
418
494
|
target: { taskId: successor ?? (code === 'earlier-active' ? s(row, 'earlier_id') ?? id : actionCode === 'answer-decision' ? s(row, 'question_task') ?? id : id),
|
|
419
495
|
runId: actionCode === 'answer-decision' ? n(row, 'question_run') || null : actionCode === 'inspect-run' && code === 'running' ? n(row, 'live_run') || null : actionCode === 'open-result' || actionCode === 'inspect-run' ? n(row, 'result_id') || null
|
|
420
|
-
: actionCode === 'reconcile-run' ? n(row, 'unfinished') || null : ['resume-run', 'inspect-stop'].includes(actionCode) ? n(row, 'stop_run') || null
|
|
496
|
+
: actionCode === 'reconcile-run' ? n(row, 'unfinished') || null : ['resume-run', 'inspect-stop'].includes(actionCode) ? n(row, 'stop_run') || null
|
|
497
|
+
: actionCode === 'confirm-stopped' ? process?.build ?? (n(row, 'result_id') || null) : null,
|
|
421
498
|
decisionId: actionCode === 'answer-decision' ? n(row, 'question_id') || null : null },
|
|
422
499
|
access: read ? 'read' : principal === 'operator' ? 'operator-control' : ['answer-decision', 'write-scope', 'unhold'].includes(actionCode) ? 'proposal-only' : 'operator-handoff',
|
|
423
500
|
retry: actionCode === 'reconcile-run' ? 'reconcile-before-retry' : read ? 'read-again' : 'refresh-before-acting' };
|
|
424
501
|
// The one shared status: the headline and sentence every surface uses.
|
|
425
|
-
const
|
|
502
|
+
const coded = stageOfCode(code, { needsPerson: need, planning: s(row, 'live_role') === 'planner', operatorHold: s(row, 'hold_kind') === 'operator' });
|
|
503
|
+
const reading = process !== null
|
|
504
|
+
? 'wait' in process ? { stage: 'waiting', wait: process.wait } : { stage: 'needs-you', need: process.need }
|
|
505
|
+
: custody ? { stage: 'needs-you', need: 'confirm-stopped' }
|
|
506
|
+
: coded.stage === 'needs-you' && (coded.need === undefined || coded.need === 'other') && INDEX_NEED[code] !== undefined ? { stage: 'needs-you', need: INDEX_NEED[code] } : coded;
|
|
426
507
|
const checkStatus = s(row, 'check_status'), verdict = s(row, 'proof_verdict');
|
|
427
508
|
const finished = reading.stage === 'finished' || reading.stage === 'complete';
|
|
428
509
|
const own = checkStatus === 'failed' || n(row, 'proof_failed_check') === 1 ? 'failed' : checkStatus === 'passed' || verdict === 'verified' ? 'passed' : null;
|
|
429
|
-
const shared = taskStatusOf({ stage: reading.stage, ...(reading.need === undefined ? {} : { need: reading.need }),
|
|
510
|
+
const shared = taskStatusOf({ stage: reading.stage, ...(reading.need === undefined ? {} : { need: reading.need }), ...(reading.wait === undefined ? {} : { wait: reading.wait }),
|
|
511
|
+
...(process === null ? {} : { needContext: { build: process.build } }),
|
|
430
512
|
reason: finished ? null : plainReasonOf(reading.stage, code, detail || null), report: s(row, 'result_role') === 'scout',
|
|
431
513
|
checks: !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']),
|
|
432
514
|
completedBy: code !== 'complete' ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, '') });
|
|
433
515
|
label = shared.headline;
|
|
434
516
|
detail = shared.sentence;
|
|
517
|
+
// Every Needs you action wears its need's own words.
|
|
518
|
+
if (shared.need != null && shared.need.key !== 'other' && shared.need.key !== 'review-result' && primaryAction !== null) {
|
|
519
|
+
actionLabel = shared.need.action.label;
|
|
520
|
+
primaryAction.label = actionLabel;
|
|
521
|
+
}
|
|
435
522
|
const views = ['all'];
|
|
436
523
|
if (need)
|
|
437
524
|
views.push('needs-you');
|