toolroll 0.9.76 → 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 +273 -0
- package/README.md +11 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/dist/agent-fence.js +46 -10
- package/dist/assignment.js +3 -0
- package/dist/backup-ui.d.ts +9 -1
- package/dist/backup-ui.js +28 -3
- package/dist/backup.js +4 -3
- package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -31
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/builder.d.ts +18 -3
- package/dist/builder.js +152 -47
- package/dist/chat-decide.js +4 -0
- package/dist/check-failure.d.ts +92 -0
- package/dist/check-failure.js +295 -0
- package/dist/check-progress.d.ts +28 -1
- package/dist/check-progress.js +105 -6
- package/dist/check-report.d.ts +67 -6
- package/dist/check-report.js +254 -9
- package/dist/checkout-cleanup.js +2 -2
- package/dist/claim.d.ts +9 -0
- package/dist/claim.js +9 -1
- package/dist/cli.js +8 -5
- package/dist/coding-handoff.d.ts +1 -0
- package/dist/coding-handoff.js +20 -11
- package/dist/commit-extract.d.ts +6 -0
- package/dist/commit-extract.js +63 -8
- package/dist/console-chrome.d.ts +41 -0
- package/dist/console-chrome.js +166 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/contracts/release-check.d.ts +12 -0
- package/dist/contracts/release-check.js +17 -0
- package/dist/contracts/release-terms.d.ts +41 -0
- package/dist/contracts/release-terms.js +53 -0
- package/dist/contracts/verification-receipt.d.ts +20 -2
- package/dist/contracts/verification-receipt.js +15 -3
- package/dist/contracts/verification-view.d.ts +8 -0
- package/dist/controller-service.js +4 -1
- package/dist/controller-supervisor.d.ts +8 -0
- package/dist/controller-supervisor.js +33 -1
- package/dist/demo.js +3 -2
- package/dist/deploy-gate.js +5 -0
- 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.d.ts +3 -0
- package/dist/dispose.js +9 -5
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +10 -2
- package/dist/envelope.js +2 -1
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +19 -4
- package/dist/exec.d.ts +9 -0
- package/dist/exec.js +22 -2
- package/dist/git-floor.d.ts +16 -0
- package/dist/git-floor.js +36 -0
- package/dist/grant.js +5 -1
- package/dist/guides.js +22 -11
- 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.d.ts +13 -0
- package/dist/operate.js +969 -328
- package/dist/planner.js +2 -2
- package/dist/prepared-evidence.d.ts +2 -1
- package/dist/prepared-evidence.js +8 -3
- package/dist/project-knowledge.js +6 -2
- package/dist/project-learning.js +2 -2
- package/dist/proof.js +6 -0
- package/dist/read-executor-worker.js +8 -4
- package/dist/read-executor.d.ts +18 -1
- package/dist/read-executor.js +8 -0
- package/dist/release-adapters.d.ts +80 -2
- package/dist/release-adapters.js +441 -16
- package/dist/release-authority.js +6 -2
- package/dist/release-auto.d.ts +38 -7
- package/dist/release-auto.js +131 -15
- package/dist/release-card.d.ts +15 -3
- package/dist/release-card.js +25 -9
- package/dist/release-carry.d.ts +21 -1
- package/dist/release-carry.js +22 -0
- package/dist/release-ci-hold.d.ts +41 -0
- package/dist/release-ci-hold.js +53 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +95 -17
- package/dist/release-coverage.js +5 -2
- package/dist/release-gate.d.ts +5 -0
- package/dist/release-gate.js +1 -1
- package/dist/release-recheck.d.ts +1 -1
- package/dist/release-recheck.js +8 -2
- package/dist/release.d.ts +144 -14
- package/dist/release.js +421 -81
- package/dist/review-context.js +8 -6
- 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 +73 -9
- package/dist/sandbox-runtime.d.ts +8 -0
- package/dist/sandbox-runtime.js +14 -2
- package/dist/scope.d.ts +19 -1
- package/dist/scope.js +26 -3
- package/dist/scout.js +2 -2
- package/dist/secret-mint.d.ts +1 -0
- package/dist/secret-mint.js +14 -0
- package/dist/serve.js +112 -51
- package/dist/server/chat.js +5 -5
- 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/render-tasks.d.ts +2 -0
- package/dist/server/render-tasks.js +2 -1
- package/dist/server/request-context.d.ts +8 -0
- package/dist/server/runtime.d.ts +2 -0
- package/dist/server/settings.js +22 -2
- package/dist/server/task-views.js +7 -2
- package/dist/server/tasks.js +4 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage-sweep.js +1 -1
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +28 -1
- package/dist/store.d.ts +135 -2
- package/dist/store.js +275 -13
- package/dist/surface.js +3 -1
- package/dist/task-base.d.ts +4 -0
- package/dist/task-base.js +22 -2
- package/dist/teardown-git.d.ts +52 -3
- package/dist/teardown-git.js +145 -33
- package/dist/test-infra.js +1 -1
- package/dist/toolroll-update.js +2 -2
- package/dist/up-processes.d.ts +137 -0
- package/dist/up-processes.js +165 -0
- package/dist/up-role-child.d.ts +1 -0
- package/dist/up-role-child.js +81 -0
- package/dist/verification-evidence.d.ts +1 -0
- package/dist/verification-evidence.js +13 -1
- package/dist/workspace-revision.js +3 -1
- package/package.json +2 -1
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The rubric's vocabulary loads before anything that reaches the plan contract (see contracts/acceptance-terms.ts).
|
|
2
2
|
export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "./contracts/acceptance-terms.js";
|
|
3
|
+
import { mintSecret } from "./secret-mint.js";
|
|
3
4
|
import { passwordlessOn } from "./passwordless.js";
|
|
4
5
|
import { currentPasswordSource, passwordGuardOf, provenPasswordAccount } from "./sign-in-guard.js";
|
|
5
6
|
import { validateScopeText } from "./task-text.js";
|
|
@@ -47,10 +48,11 @@ import { z } from "zod";
|
|
|
47
48
|
import {} from "./contracts/acceptance-terms.js";
|
|
48
49
|
import { parseContract } from "./contracts/contract.js";
|
|
49
50
|
import { readAcceptance, readScopeTerms, storedRubricSchema } from "./contracts/scope.js";
|
|
51
|
+
import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
|
|
50
52
|
import { NO_READINESS, exactModelId, legOf, projectRoute, routeDigestOf, routeFromJson, routeJsonProblem, routeWords, } from "./phase-routing.js";
|
|
51
53
|
/** Same shape as a runner's credential, for the same reasons. */
|
|
52
54
|
function mintToken() {
|
|
53
|
-
return
|
|
55
|
+
return mintSecret(32);
|
|
54
56
|
}
|
|
55
57
|
export function hashToken(token) {
|
|
56
58
|
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
@@ -617,6 +619,7 @@ route) {
|
|
|
617
619
|
...(scope.qualityMode === "strict" ? { qualityMode: "strict" } : {}),
|
|
618
620
|
...(scope.candidate ? { candidate: scope.candidate } : {}),
|
|
619
621
|
...(scope.releaseTerms == null ? {} : { releaseTerms: releaseTermsDigest(scope.releaseTerms) }),
|
|
622
|
+
...(scope.releaseCheck == null ? {} : { releaseCheck: releaseCheckPurposeDigest(scope.releaseCheck) }),
|
|
620
623
|
// The single outer key, whichever target produced its inner value:
|
|
621
624
|
// absent => the golden and every profileless approval are untouched;
|
|
622
625
|
// a legacy profile => its exact profileDigestOf; an explicit chain
|
|
@@ -670,6 +673,7 @@ function proposeLocked(store, input) {
|
|
|
670
673
|
...(input.qualityMode === undefined ? {} : { qualityMode: input.qualityMode }),
|
|
671
674
|
...(posture === undefined ? {} : { posture }),
|
|
672
675
|
...(input.releaseTerms === undefined ? {} : { releaseTerms: input.releaseTerms }),
|
|
676
|
+
...(input.releaseCheck === undefined ? {} : { releaseCheck: input.releaseCheck }),
|
|
673
677
|
proposedVia,
|
|
674
678
|
});
|
|
675
679
|
// The store may have RECOMPUTED the digest to bind the resolved profile
|
|
@@ -1098,6 +1102,10 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
|
|
|
1098
1102
|
const releaseProblem = scope.releaseTerms == null ? null : releaseSealProblem(store, scope.releaseTerms);
|
|
1099
1103
|
if (releaseProblem !== null)
|
|
1100
1104
|
return { ok: false, reason: "policy", message: releaseProblem };
|
|
1105
|
+
// Filed while CI still ran: nobody approves it before the plane read exact-head CI passing (round 1, finding 4).
|
|
1106
|
+
const ciHold = releaseCiHoldOf(store, taskId);
|
|
1107
|
+
if (ciHold !== null)
|
|
1108
|
+
return { ok: false, reason: "policy", message: releaseCiHoldWords(ciHold) };
|
|
1101
1109
|
const gate = store.approvalGate(taskId, by, "person");
|
|
1102
1110
|
if (gate.verdict === "refuse")
|
|
1103
1111
|
return { ok: false, reason: gate.reason };
|
|
@@ -1259,7 +1267,7 @@ export function scopeAuthorityOf(scope, env = {}) {
|
|
|
1259
1267
|
const parity = routeParityProblem(route, profile, { riskLevel: scope.riskLevel ?? "routine", qualityMode: scope.qualityMode ?? "default" });
|
|
1260
1268
|
if (parity !== null)
|
|
1261
1269
|
return { ok: false, reason: "parity", problem: parity };
|
|
1262
|
-
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);
|
|
1263
1271
|
if (digest !== scope.digest) {
|
|
1264
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" };
|
|
1265
1273
|
}
|
|
@@ -1319,6 +1327,7 @@ export function describeScope(scope, readiness = NO_READINESS) {
|
|
|
1319
1327
|
// Its sandbox terms (project-sandbox.ts), part of what the yes agrees to: what it reads, and any widening.
|
|
1320
1328
|
...(scope.profile === null || scope.profile === undefined ? [] : ((terms) => [` reads ${terms.reads}`, ...(terms.mach === null ? [] : [` services ${terms.mach}`])])(sandboxTermsWords(scope.profile))),
|
|
1321
1329
|
...releaseTermsWords(scope.releaseTerms ?? null),
|
|
1330
|
+
...(scope.releaseCheck == null ? [] : [` release ${releaseCheckSentence(scope.releaseCheck)}`]),
|
|
1322
1331
|
` reference ${scope.digest}`,
|
|
1323
1332
|
` approved ${describeApproval(approval)}`,
|
|
1324
1333
|
];
|
|
@@ -1330,6 +1339,10 @@ export function releaseTermsSentence(terms) {
|
|
|
1330
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.";
|
|
1331
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}`;
|
|
1332
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
|
+
}
|
|
1333
1346
|
/** What asks again, in full: the terms behind the sentence. */
|
|
1334
1347
|
export function releaseAsksAgainWords(terms) {
|
|
1335
1348
|
const p = terms.policy;
|
|
@@ -1358,7 +1371,7 @@ function describeApproval(approval) {
|
|
|
1358
1371
|
// ---- Only a person approves (docs/plans/approval-policy.md, phase 1). Kept last: the freshness tripwire pins places.
|
|
1359
1372
|
export { personProof } from "./person-proof.js";
|
|
1360
1373
|
import { personProof } from "./person-proof.js";
|
|
1361
|
-
import { releaseTermsDigest } from "./contracts/release-terms.js";
|
|
1374
|
+
import { releaseCheckPurposeDigest, releaseTermsDigest } from "./contracts/release-terms.js";
|
|
1362
1375
|
import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem } from "./release-authority.js";
|
|
1363
1376
|
/** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
|
|
1364
1377
|
export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
|
|
@@ -1542,6 +1555,16 @@ export function mainDebtProblem(store, repo, taskId) {
|
|
|
1542
1555
|
export function deployGateScope(scope) {
|
|
1543
1556
|
return (scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null;
|
|
1544
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
|
+
}
|
|
1545
1568
|
/**
|
|
1546
1569
|
* The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
|
|
1547
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";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function mintSecret(bytes: number, draw?: (size: number) => Buffer): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets a person types or pastes on a command line (passwords, invite and approver tokens, runner credentials):
|
|
3
|
+
* random base64url, never starting with "-". A value starting with "--" reads as a flag to many parsers (`--token
|
|
4
|
+
* --abc…` was refused as a missing value: about 1 in 4096 minted passwords), so it is drawn again. The CLI accepts one
|
|
5
|
+
* anyway (an older secret), but a new one never needs that.
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
export function mintSecret(bytes, draw = randomBytes) {
|
|
9
|
+
for (;;) {
|
|
10
|
+
const secret = draw(bytes).toString("base64url");
|
|
11
|
+
if (!secret.startsWith("-"))
|
|
12
|
+
return secret;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -14,19 +14,15 @@ import { signInsUnavailableWords } from "./check-access.js";
|
|
|
14
14
|
import { LIVE_VIEW_CONSENT, liveCommandsShown, liveViewConsentDigest } from "./live-detail.js";
|
|
15
15
|
import { html, htmlString, jsonScript, textHtml } from "./html.js";
|
|
16
16
|
import { browserAssetsAvailable, browserWorkspaceDocument } from './browser-shell.js';
|
|
17
|
-
import {
|
|
17
|
+
import { accessKey, countsKey, indexKey, projectSummaryOf, RevisionCache, summaryKey } from './console-chrome.js';
|
|
18
18
|
import { usageChipOf } from './limits-ui.js';
|
|
19
|
-
/** Tasks the rail lists under a project; the rest are a count that links to the project's tasks. */
|
|
20
|
-
const RAIL_TASKS = 6;
|
|
21
19
|
/** Any header a proxy adds: a request carrying one is never "from this computer" (password freshness, one-time links). */
|
|
22
20
|
const PROXY_HEADER = /^(forwarded|via|x-forwarded-.+|x-real-ip|x-client-ip|x-original-.+|x-cluster-client-ip|true-client-ip|cf-connecting-ip|fastly-client-ip|x-proxyuser-ip)$/i;
|
|
23
|
-
/** Asks a project names for the chime (its count is complete beyond them). */
|
|
24
|
-
const ASKS_PER_PROJECT = 100;
|
|
25
21
|
/** How often the background checks look at what is due (docs/plans/get-side-effects.md), and how often this
|
|
26
22
|
* computer's agent sign-ins are asked again. */
|
|
27
23
|
const BACKGROUND_CHECKS_MS = 30_000;
|
|
28
24
|
const LOCAL_AGENTS_EVERY_MS = 2 * 60_000;
|
|
29
|
-
import {
|
|
25
|
+
import { browserCrewFromIndex, browserCrewOf, browserNavigationOf, browserProjectsOf, needsYouLabelOf } from './browser-workspace.js';
|
|
30
26
|
import { sharedActionPayload } from './chat-actions.js';
|
|
31
27
|
import { prepareCodingContext } from './coding-context.js';
|
|
32
28
|
import { CodingWorkspace } from './coding-workspace.js';
|
|
@@ -104,6 +100,7 @@ import { prepareWorkspaceRevision, WorkspaceValidatorCache } from "./workspace-r
|
|
|
104
100
|
* repo, only runs whose task belongs to that repo (or to no repo yet).
|
|
105
101
|
*/
|
|
106
102
|
import { createHash, randomBytes } from "node:crypto";
|
|
103
|
+
import { keepSlowRequest, Timeline } from "./latency-timeline.js";
|
|
107
104
|
import { readFileSync } from "node:fs";
|
|
108
105
|
import { createServer } from "node:http";
|
|
109
106
|
import { homedir, hostname } from "node:os";
|
|
@@ -255,6 +252,9 @@ export function createDecisionServer(options) {
|
|
|
255
252
|
};
|
|
256
253
|
};
|
|
257
254
|
const workspaceRevision = prepareWorkspaceRevision(store);
|
|
255
|
+
// The sidebar, the Tasks count and the crew: read at most once per workspace revision per reader's exact access
|
|
256
|
+
// (console-chrome.ts), and filled from a read worker before a page renders when one is attached.
|
|
257
|
+
const chromeReads = new RevisionCache(workspaceRevision, clock);
|
|
258
258
|
const workspaceIncarnation = randomBytes(16).toString('hex');
|
|
259
259
|
const workspaceValidators = new WorkspaceValidatorCache();
|
|
260
260
|
const providerHome = options.connectionHome ?? homedir();
|
|
@@ -776,6 +776,9 @@ export function createDecisionServer(options) {
|
|
|
776
776
|
// uses the address it reached), reads one row, and says nothing else about the installation.
|
|
777
777
|
const method = request.method ?? "GET";
|
|
778
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");
|
|
779
782
|
const edgeRoute = matchedRoute?.stage === 'edge' ? matchedRoute : null;
|
|
780
783
|
const wrongMethodEdge = edgeRoute === null ? ROUTES.find(row => row.stage === 'edge' && new RegExp(row.pattern).test(hook.pathname)) ?? null : null;
|
|
781
784
|
// D7: a password on a request still signs in this release, announced on every answer and named in every refusal;
|
|
@@ -838,6 +841,7 @@ export function createDecisionServer(options) {
|
|
|
838
841
|
return;
|
|
839
842
|
const refusedToken = {};
|
|
840
843
|
const who = identify(request, touch, edgeRoute?.domain === 'live', refusedToken);
|
|
844
|
+
timeline.mark("signed in");
|
|
841
845
|
if (refusedToken.principal !== undefined) {
|
|
842
846
|
if (refuseBudget(requestBudget.admit(refusedToken.principal.tokenId, "api")))
|
|
843
847
|
return;
|
|
@@ -881,8 +885,12 @@ export function createDecisionServer(options) {
|
|
|
881
885
|
get fresh() { return freshFacts.getStore() != null && ssoNowAvailable(who.name); } } : undefined,
|
|
882
886
|
refusal: (answer, status, body) => sendScreen(answer, status, screen(status === 404 ? "Not found" : "Request refused", body, { chrome: chromeFor(who.via === "cookie" ? who.session.project : null, "work") })),
|
|
883
887
|
workspaceRead,
|
|
888
|
+
freshWork: ["request", "task", "result"].some(name => url.searchParams.has(name)),
|
|
889
|
+
...(workspaceRead && typeof request.headers['if-none-match'] === 'string' ? { workspaceIfNoneMatch: request.headers['if-none-match'] } : {}),
|
|
884
890
|
workspaceRequest: url.searchParams.get('request'),
|
|
891
|
+
timeline,
|
|
885
892
|
};
|
|
893
|
+
response.once("finish", () => keepSlowRequest(store, timeline, matchedRoute?.id ?? "unmatched", response.statusCode, clock()));
|
|
886
894
|
if (method === "GET" || method === "POST")
|
|
887
895
|
return freshFacts.run(fresh, () => provenPasswordAccount.run(passwordProof, () => requestContext.run(requestFacts, async () => {
|
|
888
896
|
if (edgeRoute?.domain === 'live')
|
|
@@ -935,6 +943,17 @@ export function createDecisionServer(options) {
|
|
|
935
943
|
facts.workspaceValidator = { key, revision, expiresAt,
|
|
936
944
|
etag: '"' + createHash('sha256').update(`${key}:${revision}:${expiresAt}`).digest('hex') + '"' };
|
|
937
945
|
}
|
|
946
|
+
await prefetchChrome(route, url, who);
|
|
947
|
+
timeline.mark("chrome and worker reads");
|
|
948
|
+
// The worker wait lets a sign-out, credential/access change or console ceiling change land. Prove the
|
|
949
|
+
// cookie and route again before rendering protected content or answering with a content validator.
|
|
950
|
+
if (who.via === 'cookie') {
|
|
951
|
+
const current = identify(request, false);
|
|
952
|
+
if (current?.via !== 'cookie' || current.session !== who.session)
|
|
953
|
+
return redirect(response, loginHref(url.pathname + url.search));
|
|
954
|
+
if (!projectRequestAllowed(route, route.project, url, who, request, response, null))
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
938
957
|
return handleGet(route, url, who, request, response);
|
|
939
958
|
}
|
|
940
959
|
return handlePost(route, url, who, request, response, body);
|
|
@@ -1443,6 +1462,7 @@ export function createDecisionServer(options) {
|
|
|
1443
1462
|
* are what sensitivity strips.
|
|
1444
1463
|
*/
|
|
1445
1464
|
function sendScreen(response, status, s) {
|
|
1465
|
+
requestContext.getStore()?.timeline?.mark("handler done; rendering");
|
|
1446
1466
|
// Sensitivity is the page as authored (cockpit §2, R14): its own explicit word, else a password field it was written
|
|
1447
1467
|
// with — judged before an identity-provider sign-in replaces that field, so an SSO step-up page stays sensitive.
|
|
1448
1468
|
const authored = s.sensitive ?? (s.forceSensitive === true ||
|
|
@@ -1475,12 +1495,17 @@ export function createDecisionServer(options) {
|
|
|
1475
1495
|
notices.push(s.chrome.modeBanner.words);
|
|
1476
1496
|
if (s.chrome.updateWaiting)
|
|
1477
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.');
|
|
1478
1500
|
let crew = { crew: [], crewTruncated: false };
|
|
1479
1501
|
try {
|
|
1480
1502
|
const project = s.chrome.active === 'chat' ? null : s.chrome.project;
|
|
1481
1503
|
crew = extras.team?.tasks ? browserCrewFromIndex(extras.team.tasks, extras.team.selected?.id) : requestFacts.workCrew?.project === project ? browserCrewFromIndex(requestFacts.workCrew.page)
|
|
1482
1504
|
// The reader's own lead's claims read "<name> is on it" here too.
|
|
1483
|
-
:
|
|
1505
|
+
: (() => {
|
|
1506
|
+
const access = { principal: 'operator', repos: managedRepos(), includeUnplaced: false, viewer: requestFacts.actor ?? null };
|
|
1507
|
+
return chromeRead(`crew\u0000${accessKey(access)}\u0000${project ?? ''}`, () => browserCrewOf(store, clock(), access, { evidenceRoot, project }));
|
|
1508
|
+
})();
|
|
1484
1509
|
}
|
|
1485
1510
|
catch {
|
|
1486
1511
|
notices.push('Crew updates are unavailable. Open Tasks to inspect saved work.');
|
|
@@ -1586,7 +1611,17 @@ export function createDecisionServer(options) {
|
|
|
1586
1611
|
workspaceValidators.set(validator.key, validator);
|
|
1587
1612
|
response.setHeader('ETag', validator.etag);
|
|
1588
1613
|
}
|
|
1589
|
-
|
|
1614
|
+
const body = JSON.stringify(workspace);
|
|
1615
|
+
// Every other page's re-read after a live `change` (chat keeps its own validator, and its receipt, task and
|
|
1616
|
+
// result reads none): when what it renders now is exactly what the page holds, it hears 304 and keeps its view
|
|
1617
|
+
// (no transfer, no re-render).
|
|
1618
|
+
if (status === 200 && validator === undefined && path.pathname !== '/chat') {
|
|
1619
|
+
const etag = '"w' + createHash('sha256').update(body).digest('base64url') + '"';
|
|
1620
|
+
response.setHeader('ETag', etag);
|
|
1621
|
+
if (requestFacts.workspaceIfNoneMatch === etag)
|
|
1622
|
+
return respond(response, 304, 'application/json; charset=utf-8', '');
|
|
1623
|
+
}
|
|
1624
|
+
return respond(response, status, 'application/json; charset=utf-8', body);
|
|
1590
1625
|
}
|
|
1591
1626
|
const nonce = randomBytes(16).toString('base64');
|
|
1592
1627
|
// React owns the conversation and its refresh/draft lifecycle. Native
|
|
@@ -1623,8 +1658,9 @@ export function createDecisionServer(options) {
|
|
|
1623
1658
|
return page(response, status, document, nonce, s.functional?.fetches === true || chromeLayer || sensitiveChrome !== "");
|
|
1624
1659
|
}
|
|
1625
1660
|
/** The needs-you count every surface wears, read from this request's own
|
|
1626
|
-
* Work counts (one
|
|
1627
|
-
* live page's badge and its Needs you tab
|
|
1661
|
+
* Work counts (one read per request, kept only while the workspace revision
|
|
1662
|
+
* it was read at stands), so a live page's badge and its Needs you tab
|
|
1663
|
+
* always agree. */
|
|
1628
1664
|
function needsYouBadge(project) {
|
|
1629
1665
|
return needsYouCount(project);
|
|
1630
1666
|
}
|
|
@@ -1653,44 +1689,8 @@ export function createDecisionServer(options) {
|
|
|
1653
1689
|
function projectSummary() {
|
|
1654
1690
|
const projects = sidebarProjects();
|
|
1655
1691
|
const actor = requestContext.getStore()?.actor ?? '';
|
|
1656
|
-
const
|
|
1657
|
-
|
|
1658
|
-
// what its agent did last; a finished one says Done.
|
|
1659
|
-
const asking = [...states.values()].flatMap(one => one.families.slice(0, RAIL_TASKS)).filter(one => one.state === 'needs-you' || one.state === 'failed').map(one => one.rootId);
|
|
1660
|
-
const needWords = new Map();
|
|
1661
|
-
if (asking.length > 0) {
|
|
1662
|
-
try {
|
|
1663
|
-
// Every row the rail may list, in pages the index allows.
|
|
1664
|
-
for (let at = 0; at < asking.length; at += WORK_INDEX_MAX_LIMIT)
|
|
1665
|
-
for (const item of workIndexPage(store, clock(), workAccess(), { roots: asking.slice(at, at + WORK_INDEX_MAX_LIMIT), limit: WORK_INDEX_MAX_LIMIT, root: evidenceRoot }).items)
|
|
1666
|
-
needWords.set(item.rootId, item.status.detail !== '' ? item.status.detail : item.status.label);
|
|
1667
|
-
}
|
|
1668
|
-
catch { /* the rows keep their state's words */ }
|
|
1669
|
-
}
|
|
1670
|
-
// The chime's subjects: an opaque key per obligation and version, scoped to this reader (no words, no ids in it).
|
|
1671
|
-
const keyOf = (...parts) => createHash('sha256').update([actor, ...parts].join('\u0000')).digest('base64url').slice(0, 22);
|
|
1672
|
-
// Newest first, so a new ask always has a key however many older ones wait (those were heard when they were new).
|
|
1673
|
-
const asksOf = (state) => [
|
|
1674
|
-
...(state?.families ?? []).flatMap(one => one.ask === undefined ? [] : [{ at: one.ask.at, ask: { key: keyOf('task', one.rootId, one.ask.version), task: one.rootId, run: one.ask.run, at: one.ask.at } }]),
|
|
1675
|
-
...(state?.flowAsks ?? []).map(one => ({ at: one.at, ask: { key: keyOf('flow', one.card, one.stage, one.entry), task: null, run: null, at: one.at } })),
|
|
1676
|
-
].sort((a, b) => b.at.localeCompare(a.at)).slice(0, ASKS_PER_PROJECT).map(one => one.ask);
|
|
1677
|
-
const railOf = (state) => {
|
|
1678
|
-
const families = state?.families ?? [];
|
|
1679
|
-
const tasks = families.slice(0, RAIL_TASKS).flatMap((one) => {
|
|
1680
|
-
const task = store.getTask(one.rootId);
|
|
1681
|
-
const line = one.state === 'needs-you' || one.state === 'failed' ? needWords.get(one.rootId) ?? null : one.line;
|
|
1682
|
-
return task === null ? [] : [{ id: one.rootId, title: task.title, href: `/t/${encodeURIComponent(one.rootId)}`, state: one.state, line: line ?? STATE_WORDS[one.state] }];
|
|
1683
|
-
});
|
|
1684
|
-
return { tasks, more: Math.max(0, families.length - RAIL_TASKS), asks: asksOf(state) };
|
|
1685
|
-
};
|
|
1686
|
-
const rows = projects.map(one => {
|
|
1687
|
-
const state = states.get(one.path);
|
|
1688
|
-
return { path: one.path, name: one.name, href: `/work?project=${encodeURIComponent(one.path)}`, state: state?.state ?? 'idle', counts: state?.counts ?? { ...NO_COUNTS }, ...railOf(state) };
|
|
1689
|
-
});
|
|
1690
|
-
const unplaced = states.get(null);
|
|
1691
|
-
if (unplaced !== undefined && unplaced.state !== 'idle')
|
|
1692
|
-
rows.push({ path: null, name: 'No project', href: '/work', state: unplaced.state, counts: unplaced.counts, ...railOf(unplaced) });
|
|
1693
|
-
return boundProjectSummary(rows);
|
|
1692
|
+
const access = workAccess();
|
|
1693
|
+
return chromeRead(summaryKey(actor, access, projects), () => projectSummaryOf(store, clock(), access, actor, projects, evidenceRoot));
|
|
1694
1694
|
}
|
|
1695
1695
|
/** What one account may see now, as the summary room keys it: a narrowed ceiling or grant is a different key, so a
|
|
1696
1696
|
* snapshot read for the wider scope never reaches it. Null: no such account. */
|
|
@@ -1777,13 +1777,15 @@ export function createDecisionServer(options) {
|
|
|
1777
1777
|
...(scope === undefined ? {} : { scope }),
|
|
1778
1778
|
};
|
|
1779
1779
|
}
|
|
1780
|
-
// All project counts are read together
|
|
1781
|
-
//
|
|
1780
|
+
// All project counts are read together, at most once per workspace revision
|
|
1781
|
+
// for this reader's access. No task artifacts or processes are inspected to
|
|
1782
|
+
// paint a badge or a project switcher.
|
|
1782
1783
|
function projectCounts(now) {
|
|
1783
1784
|
const facts = requestContext.getStore();
|
|
1784
1785
|
if (facts?.workCounts !== undefined)
|
|
1785
1786
|
return facts.workCounts;
|
|
1786
|
-
const
|
|
1787
|
+
const access = workAccess();
|
|
1788
|
+
const counts = chromeRead(countsKey(access), () => workCountsByProject(store, now, access));
|
|
1787
1789
|
if (facts !== undefined)
|
|
1788
1790
|
facts.workCounts = counts;
|
|
1789
1791
|
return counts;
|
|
@@ -1793,6 +1795,64 @@ export function createDecisionServer(options) {
|
|
|
1793
1795
|
.reduce((sum, one) => sum + one.totals['needs-you'], 0);
|
|
1794
1796
|
return { count, saturated: false };
|
|
1795
1797
|
}
|
|
1798
|
+
/** One work index page for an already-admitted reader, through the chrome cache: a whole view the chat landing reads
|
|
1799
|
+
* on every refresh. */
|
|
1800
|
+
function sharedIndexPage(access, options) {
|
|
1801
|
+
// Receipt reconciliation, task and result reads always read work afresh (disk state included).
|
|
1802
|
+
if (requestContext.getStore()?.freshWork === true)
|
|
1803
|
+
return workIndexPage(store, clock(), access, options);
|
|
1804
|
+
return chromeRead(indexKey(access, options), () => workIndexPage(store, clock(), access, options));
|
|
1805
|
+
}
|
|
1806
|
+
/** A chrome read for this request: what it already read (its prefetch included) stands for the whole request, else
|
|
1807
|
+
* the revision cache's value, else `compute()` now. */
|
|
1808
|
+
function chromeRead(key, compute) {
|
|
1809
|
+
const facts = requestContext.getStore();
|
|
1810
|
+
const held = facts?.chromeReads?.get(key);
|
|
1811
|
+
if (held !== undefined)
|
|
1812
|
+
return held;
|
|
1813
|
+
const value = chromeReads.read(key, compute);
|
|
1814
|
+
if (facts !== undefined)
|
|
1815
|
+
(facts.chromeReads ??= new Map()).set(key, value);
|
|
1816
|
+
return value;
|
|
1817
|
+
}
|
|
1818
|
+
/** The chat landing's three whole views (chat.ts chatHomeOf), as it asks for them. */
|
|
1819
|
+
const chatHomeViews = () => ["all", "needs-you", "completed"]
|
|
1820
|
+
.map(view => ({ view, limit: WORK_INDEX_MAX_LIMIT, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) }));
|
|
1821
|
+
/** Pages that wear the chrome (the sidebar's states and the Tasks count). */
|
|
1822
|
+
const CHROME_PAGES = new Set(["home", "work", "board", "agents", "agents.live", "task.page", "run.page", "chat.page", "review", "tasks", "inbox",
|
|
1823
|
+
"next", "done", "projects.page", "flows.page", "flow.page", "settings.page", "decision.page", "menu"]);
|
|
1824
|
+
/** Before a chromed page renders: its chrome reads (and the chat landing's views) run on a read worker, so the
|
|
1825
|
+
* request thread finds them cached instead of computing them. The reader is the one this request admitted; the keys
|
|
1826
|
+
* carry its exact access (console-chrome.ts). Without a worker, or when one fails, the page computes them as before. */
|
|
1827
|
+
async function prefetchChrome(route, url, who) {
|
|
1828
|
+
if (reads === null || who.via !== "cookie" || !CHROME_PAGES.has(route.id))
|
|
1829
|
+
return;
|
|
1830
|
+
try {
|
|
1831
|
+
const actor = requestContext.getStore()?.actor ?? "";
|
|
1832
|
+
const access = workAccess(), projects = sidebarProjects(), now = clock();
|
|
1833
|
+
// Populate only the revision cache. Each answer must still pass its revision/expiry check when rendering:
|
|
1834
|
+
// another job can remain pending while an earlier answer's approval or Needs-you state changes.
|
|
1835
|
+
const fill = async (key, read) => { await chromeReads.fill(key, read); };
|
|
1836
|
+
const jobs = [
|
|
1837
|
+
fill(countsKey(access), () => reads.workCounts(now, access)),
|
|
1838
|
+
fill(summaryKey(actor, access, projects), () => reads.projectSummary(now, access, actor, projects, evidenceRoot)),
|
|
1839
|
+
];
|
|
1840
|
+
if (route.id === "chat.page") {
|
|
1841
|
+
if (!["project", "room", "team"].some(name => url.searchParams.has(name)) && requestContext.getStore()?.freshWork !== true)
|
|
1842
|
+
for (const options of chatHomeViews())
|
|
1843
|
+
jobs.push(fill(indexKey(access, options), () => reads.workIndexPage(now, access, options)));
|
|
1844
|
+
// The conversation's briefing (chat.ts: the fleet snapshot's states), read as chat reads it.
|
|
1845
|
+
const repos = managedRepos();
|
|
1846
|
+
if (repos.length > 0 && requestContext.getStore()?.freshWork !== true) {
|
|
1847
|
+
const fleet = { principal: "operator", repos, includeUnplaced: false, viewer: who.name };
|
|
1848
|
+
const options = { limit: 100, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) };
|
|
1849
|
+
jobs.push(fill(indexKey(fleet, options), () => reads.workIndexPage(now, fleet, options)));
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
await Promise.all(jobs);
|
|
1853
|
+
}
|
|
1854
|
+
catch { /* the page computes what is missing */ }
|
|
1855
|
+
}
|
|
1796
1856
|
/** A live page's own beat without a docked chat: 10 s while any admitted task is building, else 30 s. */
|
|
1797
1857
|
function liveRefreshSeconds() {
|
|
1798
1858
|
return projectCounts(clock()).some(one => one.totals.running > 0) ? 10 : 30;
|
|
@@ -2073,6 +2133,7 @@ export function createDecisionServer(options) {
|
|
|
2073
2133
|
get projectFamilyPeek() { return projectFamilyPeek; },
|
|
2074
2134
|
get needsYouBadge() { return needsYouBadge; },
|
|
2075
2135
|
get liveRefreshSeconds() { return liveRefreshSeconds; },
|
|
2136
|
+
get sharedIndexPage() { return sharedIndexPage; },
|
|
2076
2137
|
get chatKeyFor() { return chatKeyFor; },
|
|
2077
2138
|
get chatCatalog() { return chatCatalog; },
|
|
2078
2139
|
get chatCatalogKnown() { return chatCatalogKnown; },
|
package/dist/server/chat.js
CHANGED
|
@@ -44,7 +44,7 @@ import {} from "./render-pages.js";
|
|
|
44
44
|
import { completionForm, hardBlockerOf, homePhaseWords, owedAcceptanceOf, resultPanelHtml } from "./render-results.js";
|
|
45
45
|
import { browserCaller, oneLineUa } from "./session.js";
|
|
46
46
|
export function createChatHandlers(runtime) {
|
|
47
|
-
const { firstRunStepsNow, managedRepos, leadWords, store, options, phoneSetup, visible, liveTurns, leadSaid, evidenceRoot, clock, sessions, CHAT_CANDIDATE_TTL_MS, chatFetcher, CHAT_CANDIDATES_PER_APPROVER, chatCeilingDigest, workAccess, familyOf, firstTasks, leadPrincipal, taskChatFocus, chatScopeOf, leadConversationRows, demoLeadHere, sendScreen, chromeFor, teamBrowserReply, team, teamChatProvider, runVisible, runIsLive, resultDetailOf, pullRequestTargetOf, chatEnablement, startLeadConversation, projectFamilyPeek, needsYouBadge, liveRefreshSeconds, chatKeyFor, chatCatalog, chatCatalogKnown, providerHome, mintApprovalNonce, checkLocalAgents, agentSignInCommand, bustBadge, authenticateApprover, revisionDestination, armTaskResume, consumeApprovalNonce } = runtime;
|
|
47
|
+
const { firstRunStepsNow, managedRepos, leadWords, store, options, phoneSetup, visible, liveTurns, leadSaid, evidenceRoot, clock, sessions, CHAT_CANDIDATE_TTL_MS, chatFetcher, CHAT_CANDIDATES_PER_APPROVER, chatCeilingDigest, workAccess, familyOf, firstTasks, leadPrincipal, taskChatFocus, chatScopeOf, leadConversationRows, demoLeadHere, sendScreen, chromeFor, teamBrowserReply, team, teamChatProvider, runVisible, runIsLive, resultDetailOf, pullRequestTargetOf, chatEnablement, sharedIndexPage, startLeadConversation, projectFamilyPeek, needsYouBadge, liveRefreshSeconds, chatKeyFor, chatCatalog, chatCatalogKnown, providerHome, mintApprovalNonce, checkLocalAgents, agentSignInCommand, bustBadge, authenticateApprover, revisionDestination, armTaskResume, consumeApprovalNonce } = runtime;
|
|
48
48
|
// The read-only refresh (package 2): the same JSON status as before —
|
|
49
49
|
// session binding, the send receipt, the live turn — plus a version
|
|
50
50
|
// of the DISPLAYED facts and, only when the caller's version differs,
|
|
@@ -273,7 +273,7 @@ export function createChatHandlers(runtime) {
|
|
|
273
273
|
if (repos.length > 0) {
|
|
274
274
|
try {
|
|
275
275
|
fleetSnapshot = store.chatSnapshot(repos, now);
|
|
276
|
-
const summaries =
|
|
276
|
+
const summaries = sharedIndexPage({ principal: 'operator', repos, includeUnplaced: false, viewer: who.name }, { limit: 100, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) }).items;
|
|
277
277
|
fleetSnapshot.assignmentStates = Object.fromEntries(summaries.flatMap(value => [value.rootId, value.activeTaskId].map(id => [id, { state: value.assignmentState, label: value.status.label, detail: value.status.detail }])));
|
|
278
278
|
fleetSnapshot.attentionCount = needsYouBadge(null);
|
|
279
279
|
}
|
|
@@ -1452,9 +1452,9 @@ export function createChatHandlers(runtime) {
|
|
|
1452
1452
|
agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt,
|
|
1453
1453
|
activity: runActivityOf(store, run, now) };
|
|
1454
1454
|
});
|
|
1455
|
-
|
|
1456
|
-
const
|
|
1457
|
-
const
|
|
1455
|
+
// The three whole views, read at most once per workspace revision (and on a read worker first when one is attached).
|
|
1456
|
+
const view = (name) => sharedIndexPage(access, { view: name, limit: WORK_INDEX_MAX_LIMIT, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) });
|
|
1457
|
+
const all = view("all"), needs = view("needs-you"), done = view("completed");
|
|
1458
1458
|
const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
|
|
1459
1459
|
const counts = [
|
|
1460
1460
|
{ key: "working", label: "Working now", value: agents.length, href: "/work?view=running" },
|
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: [
|
|
@@ -533,6 +533,8 @@ export declare function taskBodyParts(data: {
|
|
|
533
533
|
kind: "checking" | "approved";
|
|
534
534
|
candidate: string;
|
|
535
535
|
} | null;
|
|
536
|
+
/** A release task filed while its pull request's CI still runs (a person may approve now; nothing deploys before CI passes). */
|
|
537
|
+
releaseCiRunning?: boolean;
|
|
536
538
|
/** One approval per release (release-once.md): the release this task belongs to, and whether this reader may stop it. */
|
|
537
539
|
release?: ReleaseView | null;
|
|
538
540
|
canStopRelease?: boolean;
|