toolroll 0.9.75 → 0.9.77
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 +241 -0
- package/README.md +11 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/dist/agent-fence.js +36 -8
- package/dist/approval-authority.js +6 -2
- package/dist/approval-rules-ui.d.ts +11 -0
- package/dist/approval-rules-ui.js +13 -1
- package/dist/assignment.js +3 -0
- package/dist/batch-checks.js +3 -1
- 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/builder.d.ts +14 -0
- package/dist/builder.js +141 -42
- package/dist/chat-decide.js +4 -0
- package/dist/check-failure.d.ts +92 -0
- package/dist/check-failure.js +267 -0
- package/dist/check-progress.d.ts +28 -1
- package/dist/check-progress.js +105 -6
- package/dist/check-report.d.ts +52 -6
- package/dist/check-report.js +193 -8
- package/dist/checkout-cleanup.js +2 -2
- package/dist/cli.js +8 -5
- package/dist/coding-handoff.d.ts +1 -0
- package/dist/coding-handoff.js +20 -11
- package/dist/coding-workspace.js +7 -5
- 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 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/release-check.d.ts +12 -0
- package/dist/contracts/release-check.js +17 -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-supervisor.d.ts +8 -0
- package/dist/controller-supervisor.js +33 -1
- package/dist/demo.js +3 -2
- package/dist/deploy-gate.d.ts +3 -0
- package/dist/deploy-gate.js +8 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discover.js +6 -1
- package/dist/dispose.d.ts +3 -0
- package/dist/dispose.js +6 -3
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +10 -2
- package/dist/envelope.js +2 -1
- package/dist/evidence.js +4 -1
- package/dist/exec.d.ts +7 -0
- package/dist/exec.js +7 -1
- package/dist/first-run.js +3 -1
- package/dist/git-floor.d.ts +16 -0
- package/dist/git-floor.js +36 -0
- package/dist/git-incident.d.ts +49 -0
- package/dist/git-incident.js +69 -0
- package/dist/git-views.d.ts +20 -0
- package/dist/git-views.js +120 -0
- package/dist/grant.js +7 -1
- package/dist/guides.js +4 -3
- package/dist/operate.d.ts +13 -0
- package/dist/operate.js +779 -319
- package/dist/planner.js +5 -1
- 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 +23 -3
- package/dist/proof.js +6 -0
- package/dist/pull-request-flow.js +4 -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 +71 -2
- package/dist/release-adapters.js +471 -12
- package/dist/release-authority.d.ts +46 -0
- package/dist/release-authority.js +68 -0
- package/dist/release-auto.d.ts +184 -0
- package/dist/release-auto.js +612 -0
- package/dist/release-card.d.ts +15 -3
- package/dist/release-card.js +25 -9
- package/dist/release-ci-hold.d.ts +41 -0
- package/dist/release-ci-hold.js +53 -0
- package/dist/release-cli.js +77 -14
- package/dist/release-coverage.js +5 -2
- package/dist/release-recheck.d.ts +1 -1
- package/dist/release-recheck.js +6 -1
- package/dist/release.d.ts +207 -6
- package/dist/release.js +495 -20
- package/dist/repository-context.js +4 -6
- package/dist/result-follow-ups.js +3 -1
- package/dist/review-context.js +8 -6
- package/dist/runner.js +2 -1
- package/dist/sandbox-runtime.d.ts +8 -0
- package/dist/sandbox-runtime.js +14 -2
- package/dist/scope.d.ts +31 -2
- package/dist/scope.js +51 -4
- package/dist/scout.js +5 -1
- package/dist/secret-mint.d.ts +1 -0
- package/dist/secret-mint.js +14 -0
- package/dist/serve.js +101 -51
- package/dist/server/chat.js +5 -5
- package/dist/server/render-tasks.d.ts +12 -0
- package/dist/server/render-tasks.js +25 -14
- package/dist/server/request-context.d.ts +6 -0
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +2 -0
- package/dist/server/settings.js +29 -2
- package/dist/server/task-views.js +15 -2
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +28 -1
- package/dist/store.d.ts +11 -1
- package/dist/store.js +44 -5
- package/dist/task-base.d.ts +4 -0
- package/dist/task-base.js +22 -2
- package/dist/teardown-git.d.ts +90 -13
- package/dist/teardown-git.js +1027 -129
- package/dist/test-infra.d.ts +26 -0
- package/dist/test-infra.js +321 -0
- 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 +77 -0
- package/dist/verification-evidence.d.ts +1 -0
- package/dist/verification-evidence.js +13 -1
- package/dist/worktree.d.ts +4 -0
- package/dist/worktree.js +13 -7
- package/package.json +2 -1
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/** Optional repository context. Callers admit project access; this module grants no authority.
|
|
2
2
|
* The index is disposable. Every read hashes the current bounded tracked source set;
|
|
3
3
|
* a missing/changed/unreadable index falls back to ordinary text search, never a task gate. */
|
|
4
|
-
import { inReadOnlyRequest } from './read-only-request.js';
|
|
5
4
|
import { checkoutGitSync } from './teardown-git.js';
|
|
6
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
-
import { execFileSync } from 'node:child_process';
|
|
8
6
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
7
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
10
8
|
import { scanForSecrets } from './evidence.js';
|
|
@@ -14,10 +12,10 @@ const CODE = /\.(?:[cm]?[jt]sx?)$/i;
|
|
|
14
12
|
const TEXT = /\.(?:[cm]?[jt]sx?|md|txt|css|html|json)$/i;
|
|
15
13
|
const MAX_NODES = 16_000, MAX_EDGES = 12_000;
|
|
16
14
|
const sha = (value) => createHash('sha256').update(value).digest('hex');
|
|
17
|
-
// Read-only and bounded
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
// Read-only and bounded, and always the hardened checkout git (a crew worktree is agent-written, and a page read or a
|
|
16
|
+
// lead's refresh reads it alike): no filter, hook, program or network, its config and info frozen in a view of the
|
|
17
|
+
// plane's own before they're inspected (teardown-git.ts checkoutGitSync).
|
|
18
|
+
const git = (repo, args) => checkoutGitSync(repo, args, { timeoutMs: 10_000, maxBuffer: 2_000_000 }).trimEnd();
|
|
21
19
|
const inside = (root, path) => { const r = relative(root, path); return r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r); };
|
|
22
20
|
function capture(request) {
|
|
23
21
|
const repo = realpathSync(request.repo);
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { belongsToRelease } from "./release-authority.js";
|
|
6
6
|
import { run as execRun } from "./exec.js";
|
|
7
|
+
import { hardenedGit } from "./teardown-git.js";
|
|
7
8
|
import { approvedCommandShell, SETUP_ENV_ALLOWLIST, SETUP_ENV_DENYLIST } from "./builder.js";
|
|
8
9
|
import { runWithIsolatedDatabase } from "./child-database.js";
|
|
9
10
|
import { boundStreamHeadTail, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
|
|
@@ -161,7 +162,8 @@ export function waitingRequestsOfAnyKind(store, now) {
|
|
|
161
162
|
export async function runFollowUpCheck(store, root, request, deps = {}) {
|
|
162
163
|
const now = deps.now ?? (() => new Date());
|
|
163
164
|
const runner = deps.runner ?? execRun;
|
|
164
|
-
|
|
165
|
+
// The project's repository and a checkout of a build's commit, both agent-writable: the hardened git (teardown-git.ts).
|
|
166
|
+
const git = deps.git ?? hardenedGit(execRun);
|
|
165
167
|
const asked = store.handle.prepare("SELECT run_id FROM action_ledger WHERE id = ? AND action = ?").get(request, CHECKS_REQUESTED);
|
|
166
168
|
if (asked === undefined)
|
|
167
169
|
return null;
|
package/dist/review-context.js
CHANGED
|
@@ -45,6 +45,7 @@ import { createHash } from "node:crypto";
|
|
|
45
45
|
import { hasForbiddenControls } from "./decision.js";
|
|
46
46
|
import { EVIDENCE_CAPS, isBinaryAssetPath, isImagePath, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
|
|
47
47
|
import { parseProof } from "./proof.js";
|
|
48
|
+
import { noLazyFetch } from "./teardown-git.js";
|
|
48
49
|
/** Explicit bounds. Content bytes are UTF-8 of the stored (possibly
|
|
49
50
|
* redacted) text; the aggregate is the sum over every stored item. */
|
|
50
51
|
export const LEGACY_REVIEW_CONTEXT_LIMITS = {
|
|
@@ -468,11 +469,12 @@ export function citesSuppliedProvenance(note, supplied) {
|
|
|
468
469
|
return true;
|
|
469
470
|
return false;
|
|
470
471
|
}
|
|
471
|
-
|
|
472
|
+
/** Read flags: --no-lazy-fetch where git has it (the hardened runner refuses a partial clone where it doesn't). */
|
|
473
|
+
const gitRead = () => ["--literal-pathspecs", ...noLazyFetch(), "-c", "protocol.allow=never", "--no-replace-objects", "--no-optional-locks"];
|
|
472
474
|
async function treeEntries(git, cwd, revision, paths) {
|
|
473
475
|
if (paths.length === 0)
|
|
474
476
|
return new Map();
|
|
475
|
-
const listed = await git("git", [...
|
|
477
|
+
const listed = await git("git", [...gitRead(), "ls-tree", "-z", "-l", revision, "--", ...paths], { cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
476
478
|
if (listed.code !== 0)
|
|
477
479
|
return null;
|
|
478
480
|
const entries = new Map();
|
|
@@ -570,7 +572,7 @@ async function ancestorPatch(store, git, args, inventory, path) {
|
|
|
570
572
|
if (!base || !head || !SHA1.test(base) || !SHA1.test(head))
|
|
571
573
|
return fail(`run #${runId} has no exact patch endpoints`);
|
|
572
574
|
for (const from of new Set([base, previousHead ?? base])) {
|
|
573
|
-
const ancestor = await git("git", [...
|
|
575
|
+
const ancestor = await git("git", [...gitRead(), "merge-base", "--is-ancestor", from, head], { cwd: args.worktree });
|
|
574
576
|
if (ancestor.code !== 0)
|
|
575
577
|
return fail(`run #${runId}'s patch ancestry could not be verified`);
|
|
576
578
|
}
|
|
@@ -780,7 +782,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
780
782
|
const sourceCriteria = new Map((sourceScopeVerified ? sourceScope.acceptance : []).map(one => [one.id, one]));
|
|
781
783
|
// 3. Ancestry: is the source head an ancestor of this head?
|
|
782
784
|
if (sourceHead !== null) {
|
|
783
|
-
const check = await git("git", [...
|
|
785
|
+
const check = await git("git", [...gitRead(), "merge-base", "--is-ancestor", sourceHead, args.head], { cwd: args.worktree });
|
|
784
786
|
if (check.code === 0)
|
|
785
787
|
inventory.ancestry = { verified: true, detail: `${sourceHead} is an ancestor of ${args.head}` };
|
|
786
788
|
else if (check.code === 1) {
|
|
@@ -832,7 +834,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
832
834
|
if (generalPaths.length === 0 && sourceScopeVerified) {
|
|
833
835
|
const paths = sourceScope.touches.filter(path => safeContextPath(path.replace(/\/$/, "")) && !/[?*\[\]]/.test(path));
|
|
834
836
|
if (paths.length > 0) {
|
|
835
|
-
const listed = await git("git", [...
|
|
837
|
+
const listed = await git("git", [...gitRead(), "ls-tree", "-r", "-z", "--name-only", args.head, "--", ...paths], { cwd: args.worktree, maxBuffer: 4 * 1024 * 1024 });
|
|
836
838
|
if (listed.code === 0)
|
|
837
839
|
generalPaths.push(...[...new Set(listed.stdout.split("\0").filter(safeContextPath))].sort());
|
|
838
840
|
else
|
|
@@ -949,7 +951,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
949
951
|
gap("aggregate-limit", `${entry.size} bytes would cross the ${REVIEW_CONTEXT_LIMITS.aggregateBytes}-byte aggregate limit`);
|
|
950
952
|
continue;
|
|
951
953
|
}
|
|
952
|
-
const read = await git("git", [...
|
|
954
|
+
const read = await git("git", [...gitRead(), "cat-file", "blob", entry.object], { cwd: args.worktree, maxBuffer: REVIEW_CONTEXT_LIMITS.itemBytes + 4096 });
|
|
953
955
|
if (read.code !== 0) {
|
|
954
956
|
gap("capture-failed", `blob ${entry.object} could not be read (git exit ${read.code})`);
|
|
955
957
|
continue;
|
package/dist/runner.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* again. Reaping on the second tells you a *machine* is gone, and everything
|
|
20
20
|
* it was holding — claims and worktrees alike — needs recovering together.
|
|
21
21
|
*/
|
|
22
|
+
import { mintSecret } from "./secret-mint.js";
|
|
22
23
|
import { isAcpProvider } from "./acp-agents.js";
|
|
23
24
|
import { verifyLaunch } from "./acp-attest.js";
|
|
24
25
|
import { inspectAcpAgent, inspectionWords } from "./acp-inspect.js";
|
|
@@ -352,7 +353,7 @@ export function recoverDead(store, now, livenessMs = DEFAULT_LIVENESS_MS) {
|
|
|
352
353
|
}
|
|
353
354
|
/** 32 bytes of randomness, urlsafe, so it survives being pasted anywhere. */
|
|
354
355
|
function mintToken() {
|
|
355
|
-
return
|
|
356
|
+
return mintSecret(32);
|
|
356
357
|
}
|
|
357
358
|
export function hashToken(token) {
|
|
358
359
|
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
@@ -63,6 +63,14 @@ export declare const SEATBELT_NO_OTHER_TERMINAL: string;
|
|
|
63
63
|
* any program read them (anthropics/sandbox-runtime#755). Build agents keep it (Claude Code signs in from the keychain).
|
|
64
64
|
*/
|
|
65
65
|
export declare const SEATBELT_NO_KEYCHAIN: string;
|
|
66
|
+
/**
|
|
67
|
+
* First after the profile's default deny: a mach service lookup the profile doesn't allow is still denied, unreported.
|
|
68
|
+
* On macOS 26.5 a reported mach-lookup denial now and then never answers: bootstrap_look_up blocks in launchd for good
|
|
69
|
+
* (seen as npm stuck in Node's DNS setup, dns_configuration_copy → com.apple.SystemConfiguration.DNSConfiguration, about
|
|
70
|
+
* 1 launch in 70 under load; never with the report off). Every allow after it still wins, so what is reachable is the
|
|
71
|
+
* same; only the report goes.
|
|
72
|
+
*/
|
|
73
|
+
export declare const SEATBELT_QUIET_MACH_DENY = "(deny mach-lookup (with no-report))";
|
|
66
74
|
/**
|
|
67
75
|
* Chromium, a project check's launch only: Playwright's default browser (its Chromium headless shell) registers its
|
|
68
76
|
* port rendezvous service as org.chromium.Chromium.MachPortRendezvousServer.<its pid> at start, and its helpers look
|
package/dist/sandbox-runtime.js
CHANGED
|
@@ -122,8 +122,16 @@ export const SEATBELT_NO_OTHER_TERMINAL = [
|
|
|
122
122
|
*/
|
|
123
123
|
export const SEATBELT_NO_KEYCHAIN = [
|
|
124
124
|
"; Toolroll: no keychain",
|
|
125
|
-
"(deny mach-lookup (global-name \"com.apple.SecurityServer\") (global-name \"com.apple.securityd.xpc\"))",
|
|
125
|
+
"(deny mach-lookup (with no-report) (global-name \"com.apple.SecurityServer\") (global-name \"com.apple.securityd.xpc\"))",
|
|
126
126
|
].join("\n");
|
|
127
|
+
/**
|
|
128
|
+
* First after the profile's default deny: a mach service lookup the profile doesn't allow is still denied, unreported.
|
|
129
|
+
* On macOS 26.5 a reported mach-lookup denial now and then never answers: bootstrap_look_up blocks in launchd for good
|
|
130
|
+
* (seen as npm stuck in Node's DNS setup, dns_configuration_copy → com.apple.SystemConfiguration.DNSConfiguration, about
|
|
131
|
+
* 1 launch in 70 under load; never with the report off). Every allow after it still wins, so what is reachable is the
|
|
132
|
+
* same; only the report goes.
|
|
133
|
+
*/
|
|
134
|
+
export const SEATBELT_QUIET_MACH_DENY = "(deny mach-lookup (with no-report))";
|
|
127
135
|
/**
|
|
128
136
|
* Chromium, a project check's launch only: Playwright's default browser (its Chromium headless shell) registers its
|
|
129
137
|
* port rendezvous service as org.chromium.Chromium.MachPortRendezvousServer.<its pid> at start, and its helpers look
|
|
@@ -226,11 +234,15 @@ export function withSeatbeltRules(command, rules, expected) {
|
|
|
226
234
|
const profile = words[exec + 2];
|
|
227
235
|
if (!profile.startsWith("(version 1)\n(deny default"))
|
|
228
236
|
throw shape();
|
|
237
|
+
// Where the default deny's line ends (the profile's end when it is the last line).
|
|
238
|
+
const lineEnd = profile.indexOf("\n", "(version 1)\n".length);
|
|
239
|
+
const opening = lineEnd < 0 ? profile.length : lineEnd;
|
|
229
240
|
if (!/^\/[A-Za-z0-9_./-]+$/.test(words[exec + 3]))
|
|
230
241
|
throw shape();
|
|
231
242
|
if (expected !== undefined && words[exec + 5] !== expected)
|
|
232
243
|
throw shape();
|
|
233
|
-
|
|
244
|
+
const quiet = `${profile.slice(0, opening)}\n${SEATBELT_QUIET_MACH_DENY}${profile.slice(opening)}`;
|
|
245
|
+
return srtQuote([...words.slice(0, exec + 2), `${quiet}\n${rules}\n`, ...words.slice(exec + 3)]);
|
|
234
246
|
}
|
|
235
247
|
/** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
|
|
236
248
|
export function srtMacosCommand(file, args, terms, cwd = process.cwd(), temp) {
|
package/dist/scope.d.ts
CHANGED
|
@@ -418,7 +418,10 @@ export type ScopeAuthor = "mate" | "coordinator" | "scout" | "lead";
|
|
|
418
418
|
/** The authorities a seal can stand on (approval-policy plan §1.5). Anything else stands on nothing. */
|
|
419
419
|
/** `carried`: a later commit of a release carried under a person's release approval (release-once.md); only the carry
|
|
420
420
|
* transaction ever writes it. */
|
|
421
|
-
|
|
421
|
+
/** `auto-check` / `auto-green`: a release task the plane approved itself under the project's "Approve automatically when
|
|
422
|
+
* checks pass" (docs/plans/auto-approve-green.md) — `auto-check` lets its check run, `auto-green` is the release
|
|
423
|
+
* approval once CI and that check passed. Only release-auto.ts writes them; neither is ever a person's. */
|
|
424
|
+
export type ApprovalBasis = "password" | "mode" | "chat" | "grant" | "schedule" | "lead" | "carried" | "auto-check" | "auto-green";
|
|
422
425
|
export declare const APPROVAL_BASES: readonly ApprovalBasis[];
|
|
423
426
|
export type Approval = {
|
|
424
427
|
approved: true;
|
|
@@ -865,7 +868,7 @@ export declare function releaseTermsSentence(terms: ReleaseTerms): string;
|
|
|
865
868
|
export declare function releaseAsksAgainWords(terms: ReleaseTerms): string[];
|
|
866
869
|
export { personProof } from "./person-proof.js";
|
|
867
870
|
import { type ReleaseTerms } from "./contracts/release-terms.js";
|
|
868
|
-
import { type ReleaseAuthority, type ReleaseCarry } from "./release-authority.js";
|
|
871
|
+
import { type AutoEvidence, type ReleaseAuthority, type ReleaseCarry } from "./release-authority.js";
|
|
869
872
|
/** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
|
|
870
873
|
export declare const machineActing: () => boolean;
|
|
871
874
|
/** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
|
|
@@ -952,3 +955,29 @@ export declare function mainDebtProblem(store: Store, repo: string, taskId: stri
|
|
|
952
955
|
* chat it is approved only through the release card.
|
|
953
956
|
*/
|
|
954
957
|
export declare function deployGateScope(scope: Pick<Scope, "candidate" | "releaseTerms">): boolean;
|
|
958
|
+
/**
|
|
959
|
+
* The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
|
|
960
|
+
* belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
|
|
961
|
+
* this task, kind, digest and candidate, a release task (candidate and terms signed in), the project's setting on now,
|
|
962
|
+
* and the person who turned it on still an approver there. Never a person's approval.
|
|
963
|
+
*/
|
|
964
|
+
export declare function autoReleaseApprovalOf(store: Store, taskId: string, kind: "auto-check" | "auto-green"): {
|
|
965
|
+
ok: true;
|
|
966
|
+
by: string;
|
|
967
|
+
at: string;
|
|
968
|
+
evidence: AutoEvidence;
|
|
969
|
+
} | {
|
|
970
|
+
ok: false;
|
|
971
|
+
why: string;
|
|
972
|
+
};
|
|
973
|
+
/** A release's approval: a person's (personApprovalOf, unchanged), or the plane's own `auto-green`. Only release
|
|
974
|
+
* readers ask this (liveReleaseOf, and the deploy gates inside `toolroll release`); everything else asks for a person. */
|
|
975
|
+
export declare function releaseApprovalOf(store: Store, taskId: string, _now?: Date): {
|
|
976
|
+
ok: true;
|
|
977
|
+
by: string;
|
|
978
|
+
at: string;
|
|
979
|
+
method: string;
|
|
980
|
+
} | {
|
|
981
|
+
ok: false;
|
|
982
|
+
why: string;
|
|
983
|
+
};
|
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");
|
|
@@ -571,7 +573,7 @@ export function canonicalAcceptance(criteria) {
|
|
|
571
573
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
572
574
|
.map(c => ({ id: c.id, statement: c.statement.trim(), evidence: [...c.evidence].sort() }));
|
|
573
575
|
}
|
|
574
|
-
export const APPROVAL_BASES = ["password", "mode", "chat", "grant", "schedule", "lead", "carried"];
|
|
576
|
+
export const APPROVAL_BASES = ["password", "mode", "chat", "grant", "schedule", "lead", "carried", "auto-check", "auto-green"];
|
|
575
577
|
/**
|
|
576
578
|
* The exact bytes an operator agreed to.
|
|
577
579
|
*
|
|
@@ -1098,6 +1100,10 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
|
|
|
1098
1100
|
const releaseProblem = scope.releaseTerms == null ? null : releaseSealProblem(store, scope.releaseTerms);
|
|
1099
1101
|
if (releaseProblem !== null)
|
|
1100
1102
|
return { ok: false, reason: "policy", message: releaseProblem };
|
|
1103
|
+
// Filed while CI still ran: nobody approves it before the plane read exact-head CI passing (round 1, finding 4).
|
|
1104
|
+
const ciHold = releaseCiHoldOf(store, taskId);
|
|
1105
|
+
if (ciHold !== null)
|
|
1106
|
+
return { ok: false, reason: "policy", message: releaseCiHoldWords(ciHold) };
|
|
1101
1107
|
const gate = store.approvalGate(taskId, by, "person");
|
|
1102
1108
|
if (gate.verdict === "refuse")
|
|
1103
1109
|
return { ok: false, reason: gate.reason };
|
|
@@ -1359,7 +1365,7 @@ function describeApproval(approval) {
|
|
|
1359
1365
|
export { personProof } from "./person-proof.js";
|
|
1360
1366
|
import { personProof } from "./person-proof.js";
|
|
1361
1367
|
import { releaseTermsDigest } from "./contracts/release-terms.js";
|
|
1362
|
-
import { CHECK_ACCESS_WIDTH, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseSealProblem } from "./release-authority.js";
|
|
1368
|
+
import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem } from "./release-authority.js";
|
|
1363
1369
|
/** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
|
|
1364
1370
|
export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
|
|
1365
1371
|
/** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
|
|
@@ -1400,6 +1406,8 @@ export function personApprovalOf(store, taskId) {
|
|
|
1400
1406
|
const basis = scope.approvalBasis ?? null;
|
|
1401
1407
|
if (basis === "lead")
|
|
1402
1408
|
return { ok: false, why: "approved by the lead, not by a person" };
|
|
1409
|
+
if (basis === "auto-check" || basis === "auto-green")
|
|
1410
|
+
return { ok: false, why: "approved automatically, not by a person" };
|
|
1403
1411
|
if (basis !== "password" && basis !== "chat")
|
|
1404
1412
|
return { ok: false, why: `approved by ${basis === null ? "an unknown road" : basis === "grant" ? "a flow" : basis}, not by a person` };
|
|
1405
1413
|
const origin = sealOriginOf(store, taskId, scope.approvedBy, scope.approvedDigest, scope.approvedAt);
|
|
@@ -1474,7 +1482,8 @@ export function liveReleaseOf(store, taskId, action, now = new Date()) {
|
|
|
1474
1482
|
if (a.tipTask !== taskId)
|
|
1475
1483
|
return refuse("this is not the release's current commit");
|
|
1476
1484
|
const anchor = store.getScope(a.anchorTask);
|
|
1477
|
-
|
|
1485
|
+
// A person's yes, or the plane's own automatic one while the project still approves releases automatically.
|
|
1486
|
+
const person = releaseApprovalOf(store, a.anchorTask, now);
|
|
1478
1487
|
if (!person.ok)
|
|
1479
1488
|
return refuse(`the release approval no longer stands: ${person.why}`);
|
|
1480
1489
|
if (anchor === null || anchor.approvedDigest !== a.anchorDigest || anchor.digest !== a.anchorDigest)
|
|
@@ -1539,3 +1548,41 @@ export function mainDebtProblem(store, repo, taskId) {
|
|
|
1539
1548
|
export function deployGateScope(scope) {
|
|
1540
1549
|
return (scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null;
|
|
1541
1550
|
}
|
|
1551
|
+
/**
|
|
1552
|
+
* The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
|
|
1553
|
+
* belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
|
|
1554
|
+
* this task, kind, digest and candidate, a release task (candidate and terms signed in), the project's setting on now,
|
|
1555
|
+
* and the person who turned it on still an approver there. Never a person's approval.
|
|
1556
|
+
*/
|
|
1557
|
+
export function autoReleaseApprovalOf(store, taskId, kind) {
|
|
1558
|
+
const scope = store.getScope(taskId);
|
|
1559
|
+
const ref = store.lookupRef(taskId);
|
|
1560
|
+
if (scope === null || scope.approvedAt === null || scope.approvedBy === null || scope.approvedDigest === null)
|
|
1561
|
+
return { ok: false, why: "not approved yet" };
|
|
1562
|
+
if (scope.approvedDigest !== scope.digest)
|
|
1563
|
+
return { ok: false, why: "the approval is for different scope text" };
|
|
1564
|
+
if (scope.approvalBasis !== kind)
|
|
1565
|
+
return { ok: false, why: kind === "auto-green" ? "not approved automatically" : "not checking automatically" };
|
|
1566
|
+
if ((scope.candidate ?? null) === null || (scope.releaseTerms ?? null) === null || ref === null || ref.repo === null)
|
|
1567
|
+
return { ok: false, why: "only a release task is approved automatically" };
|
|
1568
|
+
const evidence = autoEvidenceOf(store, taskId, scope.modeDigest, kind);
|
|
1569
|
+
if (evidence === null || evidence.digest !== scope.approvedDigest || evidence.candidate !== scope.candidate || evidence.by !== scope.approvedBy)
|
|
1570
|
+
return { ok: false, why: "no record shows what approved it automatically" };
|
|
1571
|
+
if (!releaseAutoApproves(store, ref.repo))
|
|
1572
|
+
return { ok: false, why: "approving releases automatically was turned off" };
|
|
1573
|
+
const account = store.accountOf(scope.approvedBy);
|
|
1574
|
+
if (account === null || account.revokedAt !== null || account.role !== "approver" || !store.accountCanAccess(scope.approvedBy, ref.repo))
|
|
1575
|
+
return { ok: false, why: `${scope.approvedBy}, who turned on automatic release approval, can no longer approve in this project` };
|
|
1576
|
+
return { ok: true, by: scope.approvedBy, at: scope.approvedAt, evidence };
|
|
1577
|
+
}
|
|
1578
|
+
/** A release's approval: a person's (personApprovalOf, unchanged), or the plane's own `auto-green`. Only release
|
|
1579
|
+
* readers ask this (liveReleaseOf, and the deploy gates inside `toolroll release`); everything else asks for a person. */
|
|
1580
|
+
export function releaseApprovalOf(store, taskId, _now = new Date()) {
|
|
1581
|
+
const person = personApprovalOf(store, taskId);
|
|
1582
|
+
if (person.ok)
|
|
1583
|
+
return person;
|
|
1584
|
+
if (store.getScope(taskId)?.approvalBasis !== "auto-green")
|
|
1585
|
+
return person;
|
|
1586
|
+
const auto = autoReleaseApprovalOf(store, taskId, "auto-green");
|
|
1587
|
+
return auto.ok ? { ok: true, by: auto.by, at: auto.at, method: "auto-green" } : auto;
|
|
1588
|
+
}
|
package/dist/scout.js
CHANGED
|
@@ -18,6 +18,7 @@ import { mkdtempSync, realpathSync, rmSync, unlinkSync } from "node:fs";
|
|
|
18
18
|
import { homedir, tmpdir } from "node:os";
|
|
19
19
|
import { dirname, join, relative, isAbsolute } from "node:path";
|
|
20
20
|
import { run } from "./exec.js";
|
|
21
|
+
import { checkoutGit } from "./teardown-git.js";
|
|
21
22
|
import { auditOf } from "./provider.js";
|
|
22
23
|
import { currentClaim, heartbeat } from "./claim.js";
|
|
23
24
|
import { heartbeat as runnerHeartbeat } from "./runner.js";
|
|
@@ -225,7 +226,10 @@ export async function scout(store, request) {
|
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
228
|
async function scoutWith(store, request, outputDir) {
|
|
228
|
-
const { taskId, taskRef, runner, worktree, branch, now, agent, git = run, timeoutMs = DEFAULT_SCOUT_TIMEOUT_MS, maxTurns = DEFAULT_SCOUT_TURNS, } = request;
|
|
229
|
+
const { taskId, taskRef, runner, worktree, branch, now, agent, git: givenGit = run, timeoutMs = DEFAULT_SCOUT_TIMEOUT_MS, maxTurns = DEFAULT_SCOUT_TURNS, } = request;
|
|
230
|
+
// The plane's git on the scout's checkout (agent-written, and its repository shared with every build) runs hardened, against
|
|
231
|
+
// a frozen view of the repository's config and info (teardown-git.ts checkoutGit); an injected runner is used as given.
|
|
232
|
+
const git = givenGit === run ? checkoutGit(run, "build", { repo: store.getWorktree(worktree)?.repo ?? null }) : givenGit;
|
|
229
233
|
const claim = currentClaim(store, taskRef, now);
|
|
230
234
|
if (claim === null || claim.runner !== runner || claim.leaseId !== request.leaseId) {
|
|
231
235
|
return { ok: false, kind: "failure", reason: "not-yours", message: `${taskId} is not held under ${request.leaseId}` };
|
|
@@ -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';
|
|
@@ -255,6 +251,9 @@ export function createDecisionServer(options) {
|
|
|
255
251
|
};
|
|
256
252
|
};
|
|
257
253
|
const workspaceRevision = prepareWorkspaceRevision(store);
|
|
254
|
+
// The sidebar, the Tasks count and the crew: read at most once per workspace revision per reader's exact access
|
|
255
|
+
// (console-chrome.ts), and filled from a read worker before a page renders when one is attached.
|
|
256
|
+
const chromeReads = new RevisionCache(workspaceRevision, clock);
|
|
258
257
|
const workspaceIncarnation = randomBytes(16).toString('hex');
|
|
259
258
|
const workspaceValidators = new WorkspaceValidatorCache();
|
|
260
259
|
const providerHome = options.connectionHome ?? homedir();
|
|
@@ -881,6 +880,8 @@ export function createDecisionServer(options) {
|
|
|
881
880
|
get fresh() { return freshFacts.getStore() != null && ssoNowAvailable(who.name); } } : undefined,
|
|
882
881
|
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
882
|
workspaceRead,
|
|
883
|
+
freshWork: ["request", "task", "result"].some(name => url.searchParams.has(name)),
|
|
884
|
+
...(workspaceRead && typeof request.headers['if-none-match'] === 'string' ? { workspaceIfNoneMatch: request.headers['if-none-match'] } : {}),
|
|
884
885
|
workspaceRequest: url.searchParams.get('request'),
|
|
885
886
|
};
|
|
886
887
|
if (method === "GET" || method === "POST")
|
|
@@ -935,6 +936,16 @@ export function createDecisionServer(options) {
|
|
|
935
936
|
facts.workspaceValidator = { key, revision, expiresAt,
|
|
936
937
|
etag: '"' + createHash('sha256').update(`${key}:${revision}:${expiresAt}`).digest('hex') + '"' };
|
|
937
938
|
}
|
|
939
|
+
await prefetchChrome(route, url, who);
|
|
940
|
+
// The worker wait lets a sign-out, credential/access change or console ceiling change land. Prove the
|
|
941
|
+
// cookie and route again before rendering protected content or answering with a content validator.
|
|
942
|
+
if (who.via === 'cookie') {
|
|
943
|
+
const current = identify(request, false);
|
|
944
|
+
if (current?.via !== 'cookie' || current.session !== who.session)
|
|
945
|
+
return redirect(response, loginHref(url.pathname + url.search));
|
|
946
|
+
if (!projectRequestAllowed(route, route.project, url, who, request, response, null))
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
938
949
|
return handleGet(route, url, who, request, response);
|
|
939
950
|
}
|
|
940
951
|
return handlePost(route, url, who, request, response, body);
|
|
@@ -1480,7 +1491,10 @@ export function createDecisionServer(options) {
|
|
|
1480
1491
|
const project = s.chrome.active === 'chat' ? null : s.chrome.project;
|
|
1481
1492
|
crew = extras.team?.tasks ? browserCrewFromIndex(extras.team.tasks, extras.team.selected?.id) : requestFacts.workCrew?.project === project ? browserCrewFromIndex(requestFacts.workCrew.page)
|
|
1482
1493
|
// The reader's own lead's claims read "<name> is on it" here too.
|
|
1483
|
-
:
|
|
1494
|
+
: (() => {
|
|
1495
|
+
const access = { principal: 'operator', repos: managedRepos(), includeUnplaced: false, viewer: requestFacts.actor ?? null };
|
|
1496
|
+
return chromeRead(`crew\u0000${accessKey(access)}\u0000${project ?? ''}`, () => browserCrewOf(store, clock(), access, { evidenceRoot, project }));
|
|
1497
|
+
})();
|
|
1484
1498
|
}
|
|
1485
1499
|
catch {
|
|
1486
1500
|
notices.push('Crew updates are unavailable. Open Tasks to inspect saved work.');
|
|
@@ -1586,7 +1600,17 @@ export function createDecisionServer(options) {
|
|
|
1586
1600
|
workspaceValidators.set(validator.key, validator);
|
|
1587
1601
|
response.setHeader('ETag', validator.etag);
|
|
1588
1602
|
}
|
|
1589
|
-
|
|
1603
|
+
const body = JSON.stringify(workspace);
|
|
1604
|
+
// Every other page's re-read after a live `change` (chat keeps its own validator, and its receipt, task and
|
|
1605
|
+
// result reads none): when what it renders now is exactly what the page holds, it hears 304 and keeps its view
|
|
1606
|
+
// (no transfer, no re-render).
|
|
1607
|
+
if (status === 200 && validator === undefined && path.pathname !== '/chat') {
|
|
1608
|
+
const etag = '"w' + createHash('sha256').update(body).digest('base64url') + '"';
|
|
1609
|
+
response.setHeader('ETag', etag);
|
|
1610
|
+
if (requestFacts.workspaceIfNoneMatch === etag)
|
|
1611
|
+
return respond(response, 304, 'application/json; charset=utf-8', '');
|
|
1612
|
+
}
|
|
1613
|
+
return respond(response, status, 'application/json; charset=utf-8', body);
|
|
1590
1614
|
}
|
|
1591
1615
|
const nonce = randomBytes(16).toString('base64');
|
|
1592
1616
|
// React owns the conversation and its refresh/draft lifecycle. Native
|
|
@@ -1623,8 +1647,9 @@ export function createDecisionServer(options) {
|
|
|
1623
1647
|
return page(response, status, document, nonce, s.functional?.fetches === true || chromeLayer || sensitiveChrome !== "");
|
|
1624
1648
|
}
|
|
1625
1649
|
/** 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
|
|
1650
|
+
* Work counts (one read per request, kept only while the workspace revision
|
|
1651
|
+
* it was read at stands), so a live page's badge and its Needs you tab
|
|
1652
|
+
* always agree. */
|
|
1628
1653
|
function needsYouBadge(project) {
|
|
1629
1654
|
return needsYouCount(project);
|
|
1630
1655
|
}
|
|
@@ -1653,44 +1678,8 @@ export function createDecisionServer(options) {
|
|
|
1653
1678
|
function projectSummary() {
|
|
1654
1679
|
const projects = sidebarProjects();
|
|
1655
1680
|
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);
|
|
1681
|
+
const access = workAccess();
|
|
1682
|
+
return chromeRead(summaryKey(actor, access, projects), () => projectSummaryOf(store, clock(), access, actor, projects, evidenceRoot));
|
|
1694
1683
|
}
|
|
1695
1684
|
/** What one account may see now, as the summary room keys it: a narrowed ceiling or grant is a different key, so a
|
|
1696
1685
|
* snapshot read for the wider scope never reaches it. Null: no such account. */
|
|
@@ -1777,13 +1766,15 @@ export function createDecisionServer(options) {
|
|
|
1777
1766
|
...(scope === undefined ? {} : { scope }),
|
|
1778
1767
|
};
|
|
1779
1768
|
}
|
|
1780
|
-
// All project counts are read together
|
|
1781
|
-
//
|
|
1769
|
+
// All project counts are read together, at most once per workspace revision
|
|
1770
|
+
// for this reader's access. No task artifacts or processes are inspected to
|
|
1771
|
+
// paint a badge or a project switcher.
|
|
1782
1772
|
function projectCounts(now) {
|
|
1783
1773
|
const facts = requestContext.getStore();
|
|
1784
1774
|
if (facts?.workCounts !== undefined)
|
|
1785
1775
|
return facts.workCounts;
|
|
1786
|
-
const
|
|
1776
|
+
const access = workAccess();
|
|
1777
|
+
const counts = chromeRead(countsKey(access), () => workCountsByProject(store, now, access));
|
|
1787
1778
|
if (facts !== undefined)
|
|
1788
1779
|
facts.workCounts = counts;
|
|
1789
1780
|
return counts;
|
|
@@ -1793,6 +1784,64 @@ export function createDecisionServer(options) {
|
|
|
1793
1784
|
.reduce((sum, one) => sum + one.totals['needs-you'], 0);
|
|
1794
1785
|
return { count, saturated: false };
|
|
1795
1786
|
}
|
|
1787
|
+
/** One work index page for an already-admitted reader, through the chrome cache: a whole view the chat landing reads
|
|
1788
|
+
* on every refresh. */
|
|
1789
|
+
function sharedIndexPage(access, options) {
|
|
1790
|
+
// Receipt reconciliation, task and result reads always read work afresh (disk state included).
|
|
1791
|
+
if (requestContext.getStore()?.freshWork === true)
|
|
1792
|
+
return workIndexPage(store, clock(), access, options);
|
|
1793
|
+
return chromeRead(indexKey(access, options), () => workIndexPage(store, clock(), access, options));
|
|
1794
|
+
}
|
|
1795
|
+
/** A chrome read for this request: what it already read (its prefetch included) stands for the whole request, else
|
|
1796
|
+
* the revision cache's value, else `compute()` now. */
|
|
1797
|
+
function chromeRead(key, compute) {
|
|
1798
|
+
const facts = requestContext.getStore();
|
|
1799
|
+
const held = facts?.chromeReads?.get(key);
|
|
1800
|
+
if (held !== undefined)
|
|
1801
|
+
return held;
|
|
1802
|
+
const value = chromeReads.read(key, compute);
|
|
1803
|
+
if (facts !== undefined)
|
|
1804
|
+
(facts.chromeReads ??= new Map()).set(key, value);
|
|
1805
|
+
return value;
|
|
1806
|
+
}
|
|
1807
|
+
/** The chat landing's three whole views (chat.ts chatHomeOf), as it asks for them. */
|
|
1808
|
+
const chatHomeViews = () => ["all", "needs-you", "completed"]
|
|
1809
|
+
.map(view => ({ view, limit: WORK_INDEX_MAX_LIMIT, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) }));
|
|
1810
|
+
/** Pages that wear the chrome (the sidebar's states and the Tasks count). */
|
|
1811
|
+
const CHROME_PAGES = new Set(["home", "work", "board", "agents", "agents.live", "task.page", "run.page", "chat.page", "review", "tasks", "inbox",
|
|
1812
|
+
"next", "done", "projects.page", "flows.page", "flow.page", "settings.page", "decision.page", "menu"]);
|
|
1813
|
+
/** Before a chromed page renders: its chrome reads (and the chat landing's views) run on a read worker, so the
|
|
1814
|
+
* request thread finds them cached instead of computing them. The reader is the one this request admitted; the keys
|
|
1815
|
+
* carry its exact access (console-chrome.ts). Without a worker, or when one fails, the page computes them as before. */
|
|
1816
|
+
async function prefetchChrome(route, url, who) {
|
|
1817
|
+
if (reads === null || who.via !== "cookie" || !CHROME_PAGES.has(route.id))
|
|
1818
|
+
return;
|
|
1819
|
+
try {
|
|
1820
|
+
const actor = requestContext.getStore()?.actor ?? "";
|
|
1821
|
+
const access = workAccess(), projects = sidebarProjects(), now = clock();
|
|
1822
|
+
// Populate only the revision cache. Each answer must still pass its revision/expiry check when rendering:
|
|
1823
|
+
// another job can remain pending while an earlier answer's approval or Needs-you state changes.
|
|
1824
|
+
const fill = async (key, read) => { await chromeReads.fill(key, read); };
|
|
1825
|
+
const jobs = [
|
|
1826
|
+
fill(countsKey(access), () => reads.workCounts(now, access)),
|
|
1827
|
+
fill(summaryKey(actor, access, projects), () => reads.projectSummary(now, access, actor, projects, evidenceRoot)),
|
|
1828
|
+
];
|
|
1829
|
+
if (route.id === "chat.page") {
|
|
1830
|
+
if (!["project", "room", "team"].some(name => url.searchParams.has(name)) && requestContext.getStore()?.freshWork !== true)
|
|
1831
|
+
for (const options of chatHomeViews())
|
|
1832
|
+
jobs.push(fill(indexKey(access, options), () => reads.workIndexPage(now, access, options)));
|
|
1833
|
+
// The conversation's briefing (chat.ts: the fleet snapshot's states), read as chat reads it.
|
|
1834
|
+
const repos = managedRepos();
|
|
1835
|
+
if (repos.length > 0 && requestContext.getStore()?.freshWork !== true) {
|
|
1836
|
+
const fleet = { principal: "operator", repos, includeUnplaced: false, viewer: who.name };
|
|
1837
|
+
const options = { limit: 100, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) };
|
|
1838
|
+
jobs.push(fill(indexKey(fleet, options), () => reads.workIndexPage(now, fleet, options)));
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
await Promise.all(jobs);
|
|
1842
|
+
}
|
|
1843
|
+
catch { /* the page computes what is missing */ }
|
|
1844
|
+
}
|
|
1796
1845
|
/** A live page's own beat without a docked chat: 10 s while any admitted task is building, else 30 s. */
|
|
1797
1846
|
function liveRefreshSeconds() {
|
|
1798
1847
|
return projectCounts(clock()).some(one => one.totals.running > 0) ? 10 : 30;
|
|
@@ -2073,6 +2122,7 @@ export function createDecisionServer(options) {
|
|
|
2073
2122
|
get projectFamilyPeek() { return projectFamilyPeek; },
|
|
2074
2123
|
get needsYouBadge() { return needsYouBadge; },
|
|
2075
2124
|
get liveRefreshSeconds() { return liveRefreshSeconds; },
|
|
2125
|
+
get sharedIndexPage() { return sharedIndexPage; },
|
|
2076
2126
|
get chatKeyFor() { return chatKeyFor; },
|
|
2077
2127
|
get chatCatalog() { return chatCatalog; },
|
|
2078
2128
|
get chatCatalogKnown() { return chatCatalogKnown; },
|