toolroll 0.9.77 → 0.9.78
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 +81 -0
- package/dist/agent-fence.js +18 -2
- package/dist/backup-ui.d.ts +9 -1
- package/dist/backup-ui.js +28 -3
- package/dist/backup.js +4 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/builder.d.ts +4 -3
- package/dist/builder.js +14 -8
- package/dist/check-failure.js +31 -3
- package/dist/check-report.d.ts +18 -3
- package/dist/check-report.js +67 -7
- package/dist/claim.d.ts +9 -0
- package/dist/claim.js +9 -1
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/contracts/release-terms.d.ts +41 -0
- package/dist/contracts/release-terms.js +53 -0
- package/dist/controller-service.js +4 -1
- package/dist/deploy-rescue.d.ts +41 -0
- package/dist/deploy-rescue.js +94 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.js +4 -3
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +15 -3
- package/dist/exec.d.ts +2 -0
- package/dist/exec.js +15 -1
- package/dist/guides.js +18 -8
- package/dist/latency-timeline.d.ts +46 -0
- package/dist/latency-timeline.js +120 -0
- package/dist/live-backup.d.ts +39 -0
- package/dist/live-backup.js +271 -0
- package/dist/operate.js +203 -16
- package/dist/planner.js +2 -2
- package/dist/release-adapters.d.ts +32 -1
- package/dist/release-adapters.js +99 -7
- package/dist/release-carry.d.ts +21 -1
- package/dist/release-carry.js +22 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +74 -5
- package/dist/release-gate.d.ts +5 -0
- package/dist/release-gate.js +1 -1
- package/dist/release-recheck.js +3 -2
- package/dist/release.d.ts +37 -4
- package/dist/release.js +128 -43
- package/dist/runner-process.d.ts +67 -0
- package/dist/runner-process.js +219 -0
- package/dist/runner.d.ts +48 -6
- package/dist/runner.js +71 -8
- package/dist/scope.d.ts +19 -1
- package/dist/scope.js +19 -2
- package/dist/scout.js +2 -2
- package/dist/serve.js +11 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/server/pages.js +7 -3
- package/dist/server/remote-hooks.d.ts +1 -1
- package/dist/server/remote-hooks.js +5 -3
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +1 -0
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/settings.js +22 -2
- package/dist/server/tasks.js +4 -1
- package/dist/storage-sweep.js +1 -1
- package/dist/store.d.ts +125 -2
- package/dist/store.js +250 -12
- package/dist/surface.js +3 -1
- package/dist/teardown-git.js +5 -5
- package/dist/toolroll-update.js +2 -2
- package/dist/up-role-child.js +6 -2
- package/dist/workspace-revision.js +3 -1
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -26,6 +26,7 @@ import { inspectAcpAgent, inspectionWords } from "./acp-inspect.js";
|
|
|
26
26
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
27
27
|
import { resolve } from "node:path";
|
|
28
28
|
import { canonicalProject } from "./project.js";
|
|
29
|
+
import { lifecycleOf, reportLifecycle } from "./store.js";
|
|
29
30
|
import { ALL_CREDENTIAL_ENV, PROVIDER_IDS, inspectionOf } from "./provider.js";
|
|
30
31
|
import { attestationOf, versionInRange } from "./attest.js";
|
|
31
32
|
/** A runner's repo binding, in the same canonical form task filing uses —
|
|
@@ -39,11 +40,18 @@ export const RUNNER_TOKEN_MS = 365 * 86_400_000;
|
|
|
39
40
|
/**
|
|
40
41
|
* How long a runner may go quiet before it is presumed gone.
|
|
41
42
|
*
|
|
42
|
-
*
|
|
43
|
-
* machine dead
|
|
44
|
-
*
|
|
43
|
+
* Four heartbeats (RUNNER_HEARTBEAT_MS): one slow check-in never declares a busy
|
|
44
|
+
* machine dead, and a machine that really is gone holds its name for a minute,
|
|
45
|
+
* not three (docs/plans/actors-lessons.md). A clean stop doesn't wait even that
|
|
46
|
+
* long: a draining or stopped holder never blocks the next start
|
|
47
|
+
* (registerRunnerIfIdle).
|
|
45
48
|
*/
|
|
46
|
-
export const DEFAULT_LIVENESS_MS =
|
|
49
|
+
export const DEFAULT_LIVENESS_MS = 60_000;
|
|
50
|
+
/** How often a live runner says so: `up`'s runner heartbeat and every watch lease renewal. */
|
|
51
|
+
export const RUNNER_HEARTBEAT_MS = 15_000;
|
|
52
|
+
/** The longest a watch lease may be taken or renewed for (durable-actors' MAX_HOST_LEASE_DURATION_MS): a crashed
|
|
53
|
+
* holder's projects are free within a minute. The store clamps every ttl to it. */
|
|
54
|
+
export { MAX_WATCH_LEASE_MS } from "./store.js";
|
|
47
55
|
export function register(store, options) {
|
|
48
56
|
const { name, host, capacity = 1, repos = [], agents = [], now, newToken = mintToken, mutation = {}, } = options;
|
|
49
57
|
const token = newToken();
|
|
@@ -78,6 +86,9 @@ export function register(store, options) {
|
|
|
78
86
|
// Doing it from the `previous` snapshot above is racy: two registrars can
|
|
79
87
|
// read the same predecessor and otherwise mint the same generation.
|
|
80
88
|
const runner = store.saveRunner(proposed, hashToken(token), mutation);
|
|
89
|
+
// v137: this registration is started; its lifecycle row is bound to it by registered_at, so an earlier holder's
|
|
90
|
+
// drain or stop never describes it.
|
|
91
|
+
reportLifecycle(store, workerComponent(name), "started", runner.registeredAt, now);
|
|
81
92
|
return {
|
|
82
93
|
runner,
|
|
83
94
|
token,
|
|
@@ -204,9 +215,30 @@ export function normalizeRunnerName(raw) {
|
|
|
204
215
|
* the other gets the typed refusal.
|
|
205
216
|
*/
|
|
206
217
|
export function registerRunnerIfIdle(store, options) {
|
|
207
|
-
const { name, now } = options;
|
|
218
|
+
const { name, now, previousGone, ...registration } = options;
|
|
208
219
|
return store.transact(() => {
|
|
209
|
-
|
|
220
|
+
let existing = store.getRunner(name);
|
|
221
|
+
// The protection against two live holders stands: only a holder whose every process is proved gone, or whose own
|
|
222
|
+
// drain has ended (its stop bound passed, or it reported stopped), is signed off.
|
|
223
|
+
let signedOff = null;
|
|
224
|
+
const blocked = existing !== null && ((existing.runner.retiredAt === null && isAlive(existing.runner, now)) || store.liveWatchLeaseOf(name, now) !== null);
|
|
225
|
+
const drain = existing === null ? null : drainOf(store, existing.runner, now);
|
|
226
|
+
// A scheduled drain deadline is not proof that its timer ran or its children exited.
|
|
227
|
+
if (blocked && existing !== null && previousGone !== undefined) {
|
|
228
|
+
const proof = previousGone(existing);
|
|
229
|
+
if (proof.gone) {
|
|
230
|
+
store.retireRunner(name, now);
|
|
231
|
+
store.expireWatchLeasesOf(name, now);
|
|
232
|
+
store.recordAction({ at: now.toISOString(), actor: "toolroll up", repo: null, taskId: null, runId: null,
|
|
233
|
+
action: "worker signed off", outcome: "processes proved gone", source: "policy", detail: proof.detail });
|
|
234
|
+
signedOff = proof.detail;
|
|
235
|
+
existing = store.getRunner(name);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// A live draining holder remains exclusive even if its advertised deadline has passed.
|
|
239
|
+
if (existing !== null && drain !== null && (isAlive(existing.runner, now) || store.liveWatchLeaseOf(name, now) !== null)) {
|
|
240
|
+
return { ok: false, reason: "runner-draining", until: drain.until, detail: `Toolroll is restarting (draining): ${name} is still stopping (scheduled by ${drain.until})` };
|
|
241
|
+
}
|
|
210
242
|
if (existing !== null && existing.runner.retiredAt === null && isAlive(existing.runner, now)) {
|
|
211
243
|
return {
|
|
212
244
|
ok: false,
|
|
@@ -227,8 +259,8 @@ export function registerRunnerIfIdle(store, options) {
|
|
|
227
259
|
// otherwise hide it from every later recovery.
|
|
228
260
|
const recoveredRuns = existing === null ? 0 : store.recoverRunnerWork(name, now).runs.length;
|
|
229
261
|
// A restart keeps the capacity an operator set; only an explicit one changes it.
|
|
230
|
-
const
|
|
231
|
-
return { ok: true, ...
|
|
262
|
+
const registered = register(store, { ...registration, name, now, capacity: options.capacity ?? existing?.runner.capacity ?? 1 });
|
|
263
|
+
return { ok: true, ...registered, recoveredRuns, signedOff };
|
|
232
264
|
});
|
|
233
265
|
}
|
|
234
266
|
/**
|
|
@@ -282,6 +314,10 @@ export function retireRunnerIfCurrent(store, name, token, now) {
|
|
|
282
314
|
if (!auth.ok)
|
|
283
315
|
return { ok: false, reason: auth.reason };
|
|
284
316
|
store.retireRunner(name, now);
|
|
317
|
+
// Stopped is retired: its leases lapse now, so the next start or watch takes its projects over (recovering whatever
|
|
318
|
+
// this incarnation left) without waiting for them to run out.
|
|
319
|
+
store.expireWatchLeasesOf(name, now);
|
|
320
|
+
reportLifecycle(store, workerComponent(name), "stopped", auth.runner.registeredAt, now);
|
|
285
321
|
return { ok: true };
|
|
286
322
|
});
|
|
287
323
|
}
|
|
@@ -435,3 +471,30 @@ export function reportProviderReadinessAuthed(store, args, now) {
|
|
|
435
471
|
return auth;
|
|
436
472
|
});
|
|
437
473
|
}
|
|
474
|
+
// ---- v137 lifecycle (docs/plans/actors-lessons.md): last in the file, so the reviewed function positions stay ----
|
|
475
|
+
/** A worker's lifecycle row (service_lifecycle). */
|
|
476
|
+
export const workerComponent = (name) => `worker:${name}`;
|
|
477
|
+
/**
|
|
478
|
+
* The stop begins (durable-actors' draining report), fenced by this holder's credential: from now this runner takes
|
|
479
|
+
* no new work (claim.ts acquire refuses). `until` is a schedule, never proof that the holder exited.
|
|
480
|
+
*/
|
|
481
|
+
export function drainRunnerIfCurrent(store, name, token, now, until) {
|
|
482
|
+
return store.transact(() => {
|
|
483
|
+
const auth = authenticate(store, name, token);
|
|
484
|
+
if (!auth.ok)
|
|
485
|
+
return false;
|
|
486
|
+
return reportLifecycle(store, workerComponent(name), "draining", auth.runner.registeredAt, now, { drainUntil: until });
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/** A runner's drain as its own lifecycle row reports it (only the current registration's row counts): null when it
|
|
490
|
+
* isn't draining; ended once its bound passed or it reported stopped. */
|
|
491
|
+
export function drainOf(store, runner, now) {
|
|
492
|
+
const row = lifecycleOf(store, workerComponent(runner.name));
|
|
493
|
+
if (row === null || row.holder !== runner.registeredAt)
|
|
494
|
+
return null;
|
|
495
|
+
if (row.state === "stopped")
|
|
496
|
+
return { until: row.changedAt, ended: true };
|
|
497
|
+
if (row.state !== "draining" || row.drainUntil === null)
|
|
498
|
+
return null;
|
|
499
|
+
return { until: row.drainUntil, ended: Date.parse(row.drainUntil) <= now.getTime() };
|
|
500
|
+
}
|
package/dist/scope.d.ts
CHANGED
|
@@ -412,6 +412,9 @@ export type Scope = SavedScopeTerms & {
|
|
|
412
412
|
* carry policy, grant, check access, expiry. Part of the digest when present; a person's seal of them opens the
|
|
413
413
|
* release's authority record. */
|
|
414
414
|
releaseTerms?: ReleaseTerms | null;
|
|
415
|
+
/** v138 (docs/plans/pipeline-speed.md, P0): a release check's purpose when the release can't carry (no terms) — this
|
|
416
|
+
* exact candidate is a release's check. Part of the digest when present; never present beside terms. */
|
|
417
|
+
releaseCheck?: ReleaseCheckPurpose | null;
|
|
415
418
|
};
|
|
416
419
|
/** Who wrote a scope's text, when not a person (approval-policy plan §1.4). */
|
|
417
420
|
export type ScopeAuthor = "mate" | "coordinator" | "scout" | "lead";
|
|
@@ -464,6 +467,9 @@ export type ScopeInput = {
|
|
|
464
467
|
/** v135: release terms (release-carry.ts releaseTermsFor), from the plane's own records. Undefined keeps any terms
|
|
465
468
|
* the scope already carries; null removes them. */
|
|
466
469
|
releaseTerms?: ReleaseTerms | null;
|
|
470
|
+
/** v138: a release check's purpose (release-carry.ts releaseCheckPurposeFor), from the plane's own records. Undefined
|
|
471
|
+
* keeps any purpose the scope already carries; null removes it. */
|
|
472
|
+
releaseCheck?: ReleaseCheckPurpose | null;
|
|
467
473
|
now: Date;
|
|
468
474
|
mutation?: Mutation;
|
|
469
475
|
};
|
|
@@ -488,6 +494,8 @@ export declare function digestOf(scope: Pick<Scope, "goal" | "outOfScope" | "tou
|
|
|
488
494
|
candidate?: string | null;
|
|
489
495
|
/** v135: release terms, folded in only when present. */
|
|
490
496
|
releaseTerms?: ReleaseTerms | null;
|
|
497
|
+
/** v138: a release check's purpose, folded in only when present. */
|
|
498
|
+
releaseCheck?: ReleaseCheckPurpose | null;
|
|
491
499
|
}, target?: ExecutionProfile | null | {
|
|
492
500
|
chain: readonly ChainEntry[];
|
|
493
501
|
}, route?: PhaseRoute | null): string;
|
|
@@ -864,10 +872,12 @@ export declare function scopeRouteWords(scope: Pick<Scope, "proposedRouteJson">,
|
|
|
864
872
|
export declare function describeScope(scope: Scope, readiness?: ReadinessLookup): string[];
|
|
865
873
|
/** The approval sheet's release lines (release-once.md): the release in one sentence, then what asks again. */
|
|
866
874
|
export declare function releaseTermsSentence(terms: ReleaseTerms): string;
|
|
875
|
+
/** The approval sheet's line for a release check purpose (pipeline-speed.md, P0): a release that can't carry. */
|
|
876
|
+
export declare function releaseCheckSentence(purpose: ReleaseCheckPurpose): string;
|
|
867
877
|
/** What asks again, in full: the terms behind the sentence. */
|
|
868
878
|
export declare function releaseAsksAgainWords(terms: ReleaseTerms): string[];
|
|
869
879
|
export { personProof } from "./person-proof.js";
|
|
870
|
-
import { type ReleaseTerms } from "./contracts/release-terms.js";
|
|
880
|
+
import { type ReleaseCheckPurpose, type ReleaseTerms } from "./contracts/release-terms.js";
|
|
871
881
|
import { type AutoEvidence, type ReleaseAuthority, type ReleaseCarry } from "./release-authority.js";
|
|
872
882
|
/** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
|
|
873
883
|
export declare const machineActing: () => boolean;
|
|
@@ -955,6 +965,14 @@ export declare function mainDebtProblem(store: Store, repo: string, taskId: stri
|
|
|
955
965
|
* chat it is approved only through the release card.
|
|
956
966
|
*/
|
|
957
967
|
export declare function deployGateScope(scope: Pick<Scope, "candidate" | "releaseTerms">): boolean;
|
|
968
|
+
/**
|
|
969
|
+
* Whether a scope's check is a release's check (docs/plans/pipeline-speed.md, P0): it carries signed release terms, or a
|
|
970
|
+
* release check purpose sealed with it. Read from the scope's signed fields only, independent of whether the release can
|
|
971
|
+
* carry. It makes the check stricter (a complete, valid report; CI never stands in for its units) and, through
|
|
972
|
+
* check-failure.ts recheckReleaseTask, lets the plane rerun the identical candidate after confirmed infrastructure; it
|
|
973
|
+
* never carries a later commit or approves anything.
|
|
974
|
+
*/
|
|
975
|
+
export declare function releaseCheckScope(scope: Pick<Scope, "releaseTerms" | "releaseCheck"> | null): boolean;
|
|
958
976
|
/**
|
|
959
977
|
* The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
|
|
960
978
|
* belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
|
package/dist/scope.js
CHANGED
|
@@ -619,6 +619,7 @@ route) {
|
|
|
619
619
|
...(scope.qualityMode === "strict" ? { qualityMode: "strict" } : {}),
|
|
620
620
|
...(scope.candidate ? { candidate: scope.candidate } : {}),
|
|
621
621
|
...(scope.releaseTerms == null ? {} : { releaseTerms: releaseTermsDigest(scope.releaseTerms) }),
|
|
622
|
+
...(scope.releaseCheck == null ? {} : { releaseCheck: releaseCheckPurposeDigest(scope.releaseCheck) }),
|
|
622
623
|
// The single outer key, whichever target produced its inner value:
|
|
623
624
|
// absent => the golden and every profileless approval are untouched;
|
|
624
625
|
// a legacy profile => its exact profileDigestOf; an explicit chain
|
|
@@ -672,6 +673,7 @@ function proposeLocked(store, input) {
|
|
|
672
673
|
...(input.qualityMode === undefined ? {} : { qualityMode: input.qualityMode }),
|
|
673
674
|
...(posture === undefined ? {} : { posture }),
|
|
674
675
|
...(input.releaseTerms === undefined ? {} : { releaseTerms: input.releaseTerms }),
|
|
676
|
+
...(input.releaseCheck === undefined ? {} : { releaseCheck: input.releaseCheck }),
|
|
675
677
|
proposedVia,
|
|
676
678
|
});
|
|
677
679
|
// The store may have RECOMPUTED the digest to bind the resolved profile
|
|
@@ -1265,7 +1267,7 @@ export function scopeAuthorityOf(scope, env = {}) {
|
|
|
1265
1267
|
const parity = routeParityProblem(route, profile, { riskLevel: scope.riskLevel ?? "routine", qualityMode: scope.qualityMode ?? "default" });
|
|
1266
1268
|
if (parity !== null)
|
|
1267
1269
|
return { ok: false, reason: "parity", problem: parity };
|
|
1268
|
-
const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null }, profile, route);
|
|
1270
|
+
const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null, releaseCheck: scope.releaseCheck ?? null }, profile, route);
|
|
1269
1271
|
if (digest !== scope.digest) {
|
|
1270
1272
|
return { ok: false, reason: "digest", problem: "the scope's reference does not re-derive from its own terms, agents, and route — file it again" };
|
|
1271
1273
|
}
|
|
@@ -1325,6 +1327,7 @@ export function describeScope(scope, readiness = NO_READINESS) {
|
|
|
1325
1327
|
// Its sandbox terms (project-sandbox.ts), part of what the yes agrees to: what it reads, and any widening.
|
|
1326
1328
|
...(scope.profile === null || scope.profile === undefined ? [] : ((terms) => [` reads ${terms.reads}`, ...(terms.mach === null ? [] : [` services ${terms.mach}`])])(sandboxTermsWords(scope.profile))),
|
|
1327
1329
|
...releaseTermsWords(scope.releaseTerms ?? null),
|
|
1330
|
+
...(scope.releaseCheck == null ? [] : [` release ${releaseCheckSentence(scope.releaseCheck)}`]),
|
|
1328
1331
|
` reference ${scope.digest}`,
|
|
1329
1332
|
` approved ${describeApproval(approval)}`,
|
|
1330
1333
|
];
|
|
@@ -1336,6 +1339,10 @@ export function releaseTermsSentence(terms) {
|
|
|
1336
1339
|
const outside = terms.access === "sandboxed" ? "" : " This project's checks don't run in the sandbox: those later test-only commits will run outside the sandbox without being shown to you first.";
|
|
1337
1340
|
return `Release ${terms.packageVersion} from pull request #${terms.pr} at ${terms.approvedSha.slice(0, 7)}. Later commits on this pull request that only change tests, docs or the changelog ship without asking you again, until it merges or for 3 days. You'll see each one in the release summary.${outside}`;
|
|
1338
1341
|
}
|
|
1342
|
+
/** The approval sheet's line for a release check purpose (pipeline-speed.md, P0): a release that can't carry. */
|
|
1343
|
+
export function releaseCheckSentence(purpose) {
|
|
1344
|
+
return `Release ${purpose.packageVersion} from pull request #${purpose.pr} at ${purpose.approvedSha.slice(0, 7)}. Every later commit asks for your approval again. If Toolroll's own machine fails during the check, it reruns this same commit's check (at most twice).`;
|
|
1345
|
+
}
|
|
1339
1346
|
/** What asks again, in full: the terms behind the sentence. */
|
|
1340
1347
|
export function releaseAsksAgainWords(terms) {
|
|
1341
1348
|
const p = terms.policy;
|
|
@@ -1364,7 +1371,7 @@ function describeApproval(approval) {
|
|
|
1364
1371
|
// ---- Only a person approves (docs/plans/approval-policy.md, phase 1). Kept last: the freshness tripwire pins places.
|
|
1365
1372
|
export { personProof } from "./person-proof.js";
|
|
1366
1373
|
import { personProof } from "./person-proof.js";
|
|
1367
|
-
import { releaseTermsDigest } from "./contracts/release-terms.js";
|
|
1374
|
+
import { releaseCheckPurposeDigest, releaseTermsDigest } from "./contracts/release-terms.js";
|
|
1368
1375
|
import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem } from "./release-authority.js";
|
|
1369
1376
|
/** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
|
|
1370
1377
|
export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
|
|
@@ -1548,6 +1555,16 @@ export function mainDebtProblem(store, repo, taskId) {
|
|
|
1548
1555
|
export function deployGateScope(scope) {
|
|
1549
1556
|
return (scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null;
|
|
1550
1557
|
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Whether a scope's check is a release's check (docs/plans/pipeline-speed.md, P0): it carries signed release terms, or a
|
|
1560
|
+
* release check purpose sealed with it. Read from the scope's signed fields only, independent of whether the release can
|
|
1561
|
+
* carry. It makes the check stricter (a complete, valid report; CI never stands in for its units) and, through
|
|
1562
|
+
* check-failure.ts recheckReleaseTask, lets the plane rerun the identical candidate after confirmed infrastructure; it
|
|
1563
|
+
* never carries a later commit or approves anything.
|
|
1564
|
+
*/
|
|
1565
|
+
export function releaseCheckScope(scope) {
|
|
1566
|
+
return scope !== null && ((scope.releaseTerms ?? null) !== null || (scope.releaseCheck ?? null) !== null);
|
|
1567
|
+
}
|
|
1551
1568
|
/**
|
|
1552
1569
|
* The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
|
|
1553
1570
|
* belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
|
package/dist/scout.js
CHANGED
|
@@ -21,7 +21,7 @@ import { run } from "./exec.js";
|
|
|
21
21
|
import { checkoutGit } from "./teardown-git.js";
|
|
22
22
|
import { auditOf } from "./provider.js";
|
|
23
23
|
import { currentClaim, heartbeat } from "./claim.js";
|
|
24
|
-
import { heartbeat as runnerHeartbeat } from "./runner.js";
|
|
24
|
+
import { heartbeat as runnerHeartbeat, RUNNER_HEARTBEAT_MS } from "./runner.js";
|
|
25
25
|
import { parseDecision } from "./decision.js";
|
|
26
26
|
import { scoutHandbackSchema } from "./contracts/scout-report.js";
|
|
27
27
|
import { parseReport, REPORT_IMAGE_FILE, REPORT_LIMITS, REPORT_VERSION, SCOUT_OUTPUT_JSON_SCHEMA } from "./scout-report.js";
|
|
@@ -43,7 +43,7 @@ const GIT = "git";
|
|
|
43
43
|
const AGENT_ENV_DENYLIST = [...TELEGRAM_TOKEN_ENVS];
|
|
44
44
|
const DEFAULT_SCOUT_TIMEOUT_MS = 20 * 60_000;
|
|
45
45
|
const DEFAULT_SCOUT_TURNS = CLAUDE_LIMITS.maxTurns;
|
|
46
|
-
const DEFAULT_PULSE_MS =
|
|
46
|
+
const DEFAULT_PULSE_MS = RUNNER_HEARTBEAT_MS;
|
|
47
47
|
/** The scout's own headless browser: the pinned Playwright tool, saving its
|
|
48
48
|
* screenshots straight into the run's image folder. */
|
|
49
49
|
export const SCOUT_BROWSER = "toolroll-browser";
|
package/dist/serve.js
CHANGED
|
@@ -100,6 +100,7 @@ import { prepareWorkspaceRevision, WorkspaceValidatorCache } from "./workspace-r
|
|
|
100
100
|
* repo, only runs whose task belongs to that repo (or to no repo yet).
|
|
101
101
|
*/
|
|
102
102
|
import { createHash, randomBytes } from "node:crypto";
|
|
103
|
+
import { keepSlowRequest, Timeline } from "./latency-timeline.js";
|
|
103
104
|
import { readFileSync } from "node:fs";
|
|
104
105
|
import { createServer } from "node:http";
|
|
105
106
|
import { homedir, hostname } from "node:os";
|
|
@@ -775,6 +776,9 @@ export function createDecisionServer(options) {
|
|
|
775
776
|
// uses the address it reached), reads one row, and says nothing else about the installation.
|
|
776
777
|
const method = request.method ?? "GET";
|
|
777
778
|
const matchedRoute = matchRoute(method, hook.pathname);
|
|
779
|
+
// Where a slow answer's time went (latency-timeline.ts): milestones by route id only, kept when it took a second.
|
|
780
|
+
const timeline = new Timeline();
|
|
781
|
+
timeline.mark("route matched");
|
|
778
782
|
const edgeRoute = matchedRoute?.stage === 'edge' ? matchedRoute : null;
|
|
779
783
|
const wrongMethodEdge = edgeRoute === null ? ROUTES.find(row => row.stage === 'edge' && new RegExp(row.pattern).test(hook.pathname)) ?? null : null;
|
|
780
784
|
// D7: a password on a request still signs in this release, announced on every answer and named in every refusal;
|
|
@@ -837,6 +841,7 @@ export function createDecisionServer(options) {
|
|
|
837
841
|
return;
|
|
838
842
|
const refusedToken = {};
|
|
839
843
|
const who = identify(request, touch, edgeRoute?.domain === 'live', refusedToken);
|
|
844
|
+
timeline.mark("signed in");
|
|
840
845
|
if (refusedToken.principal !== undefined) {
|
|
841
846
|
if (refuseBudget(requestBudget.admit(refusedToken.principal.tokenId, "api")))
|
|
842
847
|
return;
|
|
@@ -883,7 +888,9 @@ export function createDecisionServer(options) {
|
|
|
883
888
|
freshWork: ["request", "task", "result"].some(name => url.searchParams.has(name)),
|
|
884
889
|
...(workspaceRead && typeof request.headers['if-none-match'] === 'string' ? { workspaceIfNoneMatch: request.headers['if-none-match'] } : {}),
|
|
885
890
|
workspaceRequest: url.searchParams.get('request'),
|
|
891
|
+
timeline,
|
|
886
892
|
};
|
|
893
|
+
response.once("finish", () => keepSlowRequest(store, timeline, matchedRoute?.id ?? "unmatched", response.statusCode, clock()));
|
|
887
894
|
if (method === "GET" || method === "POST")
|
|
888
895
|
return freshFacts.run(fresh, () => provenPasswordAccount.run(passwordProof, () => requestContext.run(requestFacts, async () => {
|
|
889
896
|
if (edgeRoute?.domain === 'live')
|
|
@@ -937,6 +944,7 @@ export function createDecisionServer(options) {
|
|
|
937
944
|
etag: '"' + createHash('sha256').update(`${key}:${revision}:${expiresAt}`).digest('hex') + '"' };
|
|
938
945
|
}
|
|
939
946
|
await prefetchChrome(route, url, who);
|
|
947
|
+
timeline.mark("chrome and worker reads");
|
|
940
948
|
// The worker wait lets a sign-out, credential/access change or console ceiling change land. Prove the
|
|
941
949
|
// cookie and route again before rendering protected content or answering with a content validator.
|
|
942
950
|
if (who.via === 'cookie') {
|
|
@@ -1454,6 +1462,7 @@ export function createDecisionServer(options) {
|
|
|
1454
1462
|
* are what sensitivity strips.
|
|
1455
1463
|
*/
|
|
1456
1464
|
function sendScreen(response, status, s) {
|
|
1465
|
+
requestContext.getStore()?.timeline?.mark("handler done; rendering");
|
|
1457
1466
|
// Sensitivity is the page as authored (cockpit §2, R14): its own explicit word, else a password field it was written
|
|
1458
1467
|
// with — judged before an identity-provider sign-in replaces that field, so an SSO step-up page stays sensitive.
|
|
1459
1468
|
const authored = s.sensitive ?? (s.forceSensitive === true ||
|
|
@@ -1486,6 +1495,8 @@ export function createDecisionServer(options) {
|
|
|
1486
1495
|
notices.push(s.chrome.modeBanner.words);
|
|
1487
1496
|
if (s.chrome.updateWaiting)
|
|
1488
1497
|
notices.push(s.chrome.updateWaiting.words);
|
|
1498
|
+
if (options.draining?.() === true)
|
|
1499
|
+
notices.push('Toolroll is restarting (draining). Saved work is kept; new builds start once it is back.');
|
|
1489
1500
|
let crew = { crew: [], crewTruncated: false };
|
|
1490
1501
|
try {
|
|
1491
1502
|
const project = s.chrome.active === 'chat' ? null : s.chrome.project;
|
package/dist/server/http.d.ts
CHANGED
|
@@ -148,6 +148,8 @@ export type ServeOptions = {
|
|
|
148
148
|
ssoFetch?: typeof fetch;
|
|
149
149
|
/** Cockpit B1: this `up` console's instance secret (null: none — the instance route answers 404). */
|
|
150
150
|
instanceSecret?: () => string | null;
|
|
151
|
+
/** v137: whether this console is draining (its `up` is stopping): /healthz says so at once, and pages say Toolroll is restarting. */
|
|
152
|
+
draining?: () => boolean;
|
|
151
153
|
/** Cockpit E: how Finder is asked to reveal a proved checkout (tests pass their own; never a launch). True: shown. */
|
|
152
154
|
revealPath?: (path: string) => Promise<boolean> | boolean;
|
|
153
155
|
chatEnv?: Record<string, string | undefined>;
|
package/dist/server/pages.js
CHANGED
|
@@ -43,6 +43,7 @@ import { healthWords } from "../server-telemetry.js";
|
|
|
43
43
|
import { tally } from "../summary.js";
|
|
44
44
|
import { serveInstallAsset } from "./pages-assets.js";
|
|
45
45
|
import { healthz } from "./remote-hooks.js";
|
|
46
|
+
import { slowRequestLines, SLOW_REQUEST_MS } from "../latency-timeline.js";
|
|
46
47
|
import { leadsStillAdmitted, leadTilesFor } from './agents-live-lead.js';
|
|
47
48
|
import { refuse, regionScript, screen, settingsRows, WORKSPACE_STYLE, workToolRows } from "./chrome.js";
|
|
48
49
|
import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
|
|
@@ -627,9 +628,12 @@ export function createPagesHandlers(runtime) {
|
|
|
627
628
|
async function health(ctx) {
|
|
628
629
|
const { request, response } = ctx;
|
|
629
630
|
const health = store.telemetry.snapshot();
|
|
631
|
+
// Beside /healthz, for an instance operator: where the slowest recent console answers spent their time (route ids
|
|
632
|
+
// and milestones only).
|
|
633
|
+
const slow = slowRequestLines(store);
|
|
630
634
|
return request.headers.accept?.includes('application/json')
|
|
631
|
-
? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify(health))
|
|
632
|
-
: respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n`);
|
|
635
|
+
? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify({ ...health, slowRequests: slow }))
|
|
636
|
+
: respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n${slow.length === 0 ? "" : `\nSlow console requests (over ${SLOW_REQUEST_MS} ms), newest first:\n${slow.join("\n")}\n`}`);
|
|
633
637
|
}
|
|
634
638
|
// v104: Prometheus metrics, for an instance operator (a scraper sends one's API token).
|
|
635
639
|
async function metrics(ctx) {
|
|
@@ -984,7 +988,7 @@ export function createPagesHandlers(runtime) {
|
|
|
984
988
|
}
|
|
985
989
|
async function healthzEdge(ctx) {
|
|
986
990
|
const { response, method } = ctx;
|
|
987
|
-
return healthz(store, response, method === 'HEAD');
|
|
991
|
+
return healthz(store, response, method === 'HEAD', options.draining?.() === true);
|
|
988
992
|
}
|
|
989
993
|
async function browserAsset(ctx) {
|
|
990
994
|
const { request, response, url } = ctx;
|
|
@@ -26,4 +26,4 @@ export declare function telegramHook(ctx: RemoteHookContext, request: IncomingMe
|
|
|
26
26
|
export declare function flowHook(ctx: RemoteHookContext, request: IncomingMessage, response: ServerResponse, url: URL): Promise<void>;
|
|
27
27
|
/** A shared button as a public form (v84): its questions, and one card per submission. No sign-in, no session, nothing about the flow shown. */
|
|
28
28
|
export declare function flowForm(ctx: RemoteHookContext, request: IncomingMessage, response: ServerResponse, url: URL): Promise<void>;
|
|
29
|
-
export declare function healthz(store: Store, response: ServerResponse, head: boolean): void;
|
|
29
|
+
export declare function healthz(store: Store, response: ServerResponse, head: boolean, draining?: boolean): void;
|
|
@@ -122,7 +122,7 @@ export async function flowForm(ctx, request, response, url) {
|
|
|
122
122
|
return page({ status: 500, html: "" });
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
-
export function healthz(store, response, head) {
|
|
125
|
+
export function healthz(store, response, head, draining = false) {
|
|
126
126
|
let healthy = true;
|
|
127
127
|
try {
|
|
128
128
|
store.handle.prepare("SELECT 1 FROM schema_version").get();
|
|
@@ -130,7 +130,9 @@ export function healthz(store, response, head) {
|
|
|
130
130
|
catch {
|
|
131
131
|
healthy = false;
|
|
132
132
|
}
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
// A draining console (its `up` is stopping) still answers pages, but says so here at once: a probe moves on instead
|
|
134
|
+
// of waiting for it to disappear.
|
|
135
|
+
const body = JSON.stringify(draining && healthy ? { status: "draining", message: "Toolroll is restarting (draining)" } : { status: healthy ? "ok" : "unavailable" });
|
|
136
|
+
response.writeHead(healthy && !draining ? 200 : 503, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
135
137
|
response.end(head ? undefined : body);
|
|
136
138
|
}
|
|
@@ -185,6 +185,8 @@ export type ReviewCockpitView = {
|
|
|
185
185
|
efficiency?: RunEfficiency | null;
|
|
186
186
|
/** What the run's network was and what its proxy saw (egress.ts): display only. */
|
|
187
187
|
network?: RunNetworkRecord | null;
|
|
188
|
+
/** Where the time went (latency-timeline.ts): its release check's parts and the task's deploys, display only. */
|
|
189
|
+
timing?: import("../latency-timeline.js").TimingView | null;
|
|
188
190
|
/** How far its checks reached (check-access.ts): shown only when wider than sandboxed. */
|
|
189
191
|
checkAccess?: CheckAccess;
|
|
190
192
|
taskId: string;
|
|
@@ -625,6 +625,7 @@ export function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab,
|
|
|
625
625
|
notes: view.notes.map(one => ({ author: one.author, at: one.createdAt, note: one.note })),
|
|
626
626
|
efficiency: view.efficiency ?? null,
|
|
627
627
|
network: view.network ?? null,
|
|
628
|
+
timing: view.timing ?? null,
|
|
628
629
|
acts: { primary: null, secondary: null, line: null }, runChecks: null, mismatch: null,
|
|
629
630
|
// The raw run record lives under Details now (2026-10-02): /r/<id> for this result redirects here.
|
|
630
631
|
record: run === null ? null : { build: run.id, href: `/r/${run.id}?record=1`, facts: [
|
|
@@ -18,6 +18,8 @@ export type RequestFacts = {
|
|
|
18
18
|
returnTo: string;
|
|
19
19
|
actor?: string;
|
|
20
20
|
createdTask?: string;
|
|
21
|
+
/** This request's latency timeline (latency-timeline.ts): kept when the answer was slow. */
|
|
22
|
+
timeline?: import("../latency-timeline.js").Timeline;
|
|
21
23
|
browser?: boolean;
|
|
22
24
|
workspaceRead?: boolean;
|
|
23
25
|
/** Receipt reconciliation, task and result reads (`request`, `task`, `result`): work is read afresh, never cached. */
|
package/dist/server/settings.js
CHANGED
|
@@ -117,7 +117,9 @@ import { sweepSchedule } from "../storage-sweep-schedule.js";
|
|
|
117
117
|
import { storageHtml } from "../storage-ui.js";
|
|
118
118
|
import { landedSummary } from "../landed-cleanup.js";
|
|
119
119
|
import { bytesWords, parseCleanup } from "../storage.js";
|
|
120
|
-
import { isDigestTime, RESULT_SCREENSHOTS } from "../store.js";
|
|
120
|
+
import { isDigestTime, liveBackupState, RESULT_SCREENSHOTS, setLiveBackupSettings } from "../store.js";
|
|
121
|
+
import { defaultLiveFolder, liveFolderOf, liveFolderProblem } from "../live-backup.js";
|
|
122
|
+
import { LIVE_EVERY } from "../backup-ui.js";
|
|
121
123
|
import { checkTeamsCredentials, clearTeamsCredentials, loadTeamsCredentials, saveTeamsCredentials, TeamsError } from "../teams-api.js";
|
|
122
124
|
import { teamsSettingsHtml } from "../teams-settings.js";
|
|
123
125
|
import { telegramSettingsHtml } from "../telegram-settings.js";
|
|
@@ -497,7 +499,9 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
|
|
|
497
499
|
if (databaseFile === null)
|
|
498
500
|
return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
|
|
499
501
|
const settings = store.backupSettings();
|
|
500
|
-
const
|
|
502
|
+
const live = liveBackupState(store);
|
|
503
|
+
const view = { settings, folder: backupFolderOf(settings, databaseFile), defaultFolder: defaultBackupFolder(databaseFile), runs: store.backupRuns(20), csrf: who.session.csrf, now,
|
|
504
|
+
live: { state: live, folder: liveFolderOf(live.settings, databaseFile), defaultFolder: defaultLiveFolder(databaseFile) } };
|
|
501
505
|
return sendScreen(response, 200, screen("Backups", html `<p><a href="/settings">Settings</a></p><h1>Backups</h1>${(backupHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
|
|
502
506
|
}
|
|
503
507
|
// v105: Settings → Data, where an instance operator downloads everything. The download is a POST with the password.
|
|
@@ -1325,8 +1329,24 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
1325
1329
|
if (problem !== null)
|
|
1326
1330
|
return back("problem", problem);
|
|
1327
1331
|
}
|
|
1332
|
+
// The continuous copy (live-backup.ts): how often, and a folder that is a plain, private folder of this user's
|
|
1333
|
+
// outside every project and checkout. A form without these fields leaves it as it is.
|
|
1334
|
+
const liveBefore = liveBackupState(store).settings;
|
|
1335
|
+
const liveEvery = body.get("live_every") ?? null;
|
|
1336
|
+
const liveMinutes = Number(liveEvery);
|
|
1337
|
+
if (liveEvery !== null && liveEvery !== "off" && !LIVE_EVERY.includes(liveMinutes))
|
|
1338
|
+
return back("problem", "Choose how often to copy continuously.");
|
|
1339
|
+
const liveTyped = body.get("live_folder") === null ? liveBefore.folder ?? "" : (body.get("live_folder") ?? "").trim();
|
|
1340
|
+
const liveFolder = liveTyped === "" || liveTyped === defaultLiveFolder(databaseFile) ? null : liveTyped;
|
|
1341
|
+
if (liveFolder !== null) {
|
|
1342
|
+
const problem = liveFolderProblem(liveFolder, [...new Set([...managedRepos(), ...store.knownRepos(), ...(options.poolRoot === undefined ? [] : [options.poolRoot])])]);
|
|
1343
|
+
if (problem !== null)
|
|
1344
|
+
return back("problem", problem);
|
|
1345
|
+
}
|
|
1328
1346
|
const before = store.backupSettings();
|
|
1329
1347
|
store.setBackupSettings({ enabled: every !== "off", everyHours: every === "off" ? before.everyHours : hours, keep, folder }, who.name, now);
|
|
1348
|
+
setLiveBackupSettings(store, { enabled: liveEvery === null ? liveBefore.enabled : liveEvery !== "off", everyMinutes: liveEvery === null || liveEvery === "off" ? liveBefore.everyMinutes : liveMinutes,
|
|
1349
|
+
keep: liveBefore.keep, folder: liveFolder }, who.name, now);
|
|
1330
1350
|
return back("said", every === "off" ? "Saved. Scheduled backups are off." : "Saved.");
|
|
1331
1351
|
}
|
|
1332
1352
|
// v105: download everything as a .zip. An instance operator, with a step-up; the ledger records it.
|
package/dist/server/tasks.js
CHANGED
|
@@ -66,6 +66,7 @@ import { boardTickScript, oneLineOf, QUEUE_VIEW, refuse, regionScript, screen, t
|
|
|
66
66
|
import { chatReturnWithSaid, matchTaskPath, redirect, respond, reviewHref, safeChatReturn, safeReturn, SAFETY, taskChatHref, taskHref } from "./http.js";
|
|
67
67
|
import { boardBody, boardWorkers, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, workPage } from "./render-pages.js";
|
|
68
68
|
import { runEfficiencyOf } from "../run-efficiency.js";
|
|
69
|
+
import { timingFor } from "../latency-timeline.js";
|
|
69
70
|
import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReviewQueue, RESULT_REFUSALS, REVIEW_QUEUE_CAP, reviewCockpitDetailParts, reviewCockpitPage, reviewPriorityOf, runFactsFragment, runOutcomeBadge, runPage, RUNS_PAGE, runsPage, withRefusal } from "./render-results.js";
|
|
70
71
|
import { approvalFormDigest, approveRefusalWords, checkProgressHtml, consentDoorOf, decisionPage, nonceHashOf, PermissionsWouldChange, requirementsFromEditor, resumeDigestOf, taskOf } from "./render-tasks.js";
|
|
71
72
|
import {} from "./session.js";
|
|
@@ -2837,7 +2838,7 @@ export function createTasksHandlers(runtime) {
|
|
|
2837
2838
|
...(store.isDemo() ? { demo: true } : {}),
|
|
2838
2839
|
};
|
|
2839
2840
|
if (run === null) {
|
|
2840
|
-
return { ...base, run: null, detail: null, reviewRetry: null, review: null, notes: [] };
|
|
2841
|
+
return { ...base, run: null, detail: null, reviewRetry: null, review: null, notes: [], timing: timingFor(store, null, row.taskId) };
|
|
2841
2842
|
}
|
|
2842
2843
|
const detail = resultDetailOf(run, who, now);
|
|
2843
2844
|
return {
|
|
@@ -2866,6 +2867,8 @@ export function createTasksHandlers(runtime) {
|
|
|
2866
2867
|
} })(),
|
|
2867
2868
|
// What its network was and what its proxy saw (egress.ts), display only.
|
|
2868
2869
|
network: store.runNetwork(run.id),
|
|
2870
|
+
// Where the time went: its release check's parts and the task's deploys (latency-timeline.ts), display only.
|
|
2871
|
+
timing: timingFor(store, run.id, row.taskId),
|
|
2869
2872
|
// How far its checks reached (check-access.ts), display only.
|
|
2870
2873
|
checkAccess: store.runCheckAccess(run.id),
|
|
2871
2874
|
reviewRetry: store.reviewRetryStateOf(run.id),
|
package/dist/storage-sweep.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Nothing live work uses goes (`liveWork`): while a run is open, a lease current or a checkout in use, each removal
|
|
9
9
|
* and each signal first asks again whether that work uses the folder, and when its processes can't be read nothing
|
|
10
10
|
* goes. Every scan and removal runs in a separate child: synchronous disk work must never delay the worker's lease
|
|
11
|
-
* heartbeats (
|
|
11
|
+
* heartbeats (a lease lasts a minute). On Oct 12 the sweep starved those heartbeats and three builds ended interrupted half a second
|
|
12
12
|
* after it finished. Once a run has ended, the next sweep removes what
|
|
13
13
|
* it no longer uses.
|
|
14
14
|
*
|