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
package/dist/operate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mintSecret } from "./secret-mint.js";
|
|
1
2
|
import { ACP_AGENTS, ACP_PROVIDER_IDS, isAcpProvider, isKeyedProvider } from "./acp-agents.js";
|
|
2
3
|
import { inspectAcpAgent } from "./acp-inspect.js";
|
|
3
4
|
import { runAcpChecks } from "./acp-check.js";
|
|
@@ -99,7 +100,7 @@ import { taskReviewBrief, renderReviewBrief } from "./task-review-brief.js";
|
|
|
99
100
|
import { homedir, hostname } from "node:os";
|
|
100
101
|
import { basename, dirname, join, resolve } from "node:path";
|
|
101
102
|
import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
|
|
102
|
-
import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, RESULT_SCREENSHOTS, } from "./store.js";
|
|
103
|
+
import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, } from "./store.js";
|
|
103
104
|
import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
|
|
104
105
|
import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
|
|
105
106
|
import { authorizePlanUnderMode } from "./plan-auto.js";
|
|
@@ -122,6 +123,9 @@ import { admissionRecorded, admitProject, commandLineActor } from "./project-adm
|
|
|
122
123
|
import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
|
|
123
124
|
import { createServer as createNetServer } from "node:net";
|
|
124
125
|
import { spawn as spawnChild } from "node:child_process";
|
|
126
|
+
import { fileURLToPath } from "node:url";
|
|
127
|
+
import { superviseController } from "./controller-supervisor.js";
|
|
128
|
+
import { buildIdentity, memoryChannel, RoleGenerations, roleMessage } from "./up-processes.js";
|
|
125
129
|
// Every envelope is checked against its command's schema (logged, never refused), then serialized unchanged.
|
|
126
130
|
import { checkedEnvelopeJson as envelopeJson } from "./contracts/cli.js";
|
|
127
131
|
import { auditCommand } from "./audit-cli.js";
|
|
@@ -224,6 +228,9 @@ import { scout as scoutTask } from "./scout.js";
|
|
|
224
228
|
import { profileDigestOf } from "./scope.js";
|
|
225
229
|
import { PROVIDER_KEY_ENV, clearProviderKey, keyStatus, readAuthMode, readAuthModeStrict, readProviderKey, saveProviderKey, setAuthMode, verifyProviderKey, verdictWords } from "./keys.js";
|
|
226
230
|
import { run, terminateLiveProviders, run as execRun } from "./exec.js";
|
|
231
|
+
import { hardenedGit, lazyFetchSafeGit, noLazyFetch, PARTIAL_CLONE_REFUSAL } from "./teardown-git.js";
|
|
232
|
+
import { installedGitFloorProblem } from "./git-floor.js";
|
|
233
|
+
import { noticeGitIncidents } from "./git-incident.js";
|
|
227
234
|
import { containmentNotice, containmentStatus, currentContainment, describeContainment, resolveContainment } from "./containment.js";
|
|
228
235
|
import { readPulls } from "./pulls.js";
|
|
229
236
|
import { startMaintenance } from "./maintenance.js";
|
|
@@ -433,6 +440,8 @@ External trackers — build what a tracker nominates, under local approvals
|
|
|
433
440
|
app; they start without a restart.
|
|
434
441
|
--repo still adds an exact path;
|
|
435
442
|
--no-open skips the browser;
|
|
443
|
+
the app and the builder run as two
|
|
444
|
+
processes (--single-process: one);
|
|
436
445
|
--host 0.0.0.0 --allow-host name:port
|
|
437
446
|
reaches a phone over a tailnet;
|
|
438
447
|
--runner names the worker;
|
|
@@ -654,6 +663,8 @@ export const KEYS_ACTIONS = ["status", "set", "clear", "verify", "auth"];
|
|
|
654
663
|
* tests): every value-taking flag any verb reads, and every boolean.
|
|
655
664
|
* A --flag in neither set is a typo, refused by name.
|
|
656
665
|
*/
|
|
666
|
+
/** Flags whose value is a secret someone types or pastes: taken as the next argument even when it starts with "--". */
|
|
667
|
+
export const SECRET_VALUE_FLAGS = new Set(["token", "password"]);
|
|
657
668
|
export const OPERATE_VALUE_FLAGS = new Set([
|
|
658
669
|
"key", "db", "runner", "ttl", "state", "on", "reason", "until", "id", "backend",
|
|
659
670
|
"allow", "selector", "paths", "credentials", "repo", "token", "capacity",
|
|
@@ -704,7 +715,7 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
|
|
|
704
715
|
"no-carry",
|
|
705
716
|
"json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file",
|
|
706
717
|
"clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
|
|
707
|
-
"no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
|
|
718
|
+
"no-open", "single-process", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
|
|
708
719
|
"self-heal", "plan-auto", "chat-approve", "full-access", "repair-auto", "review-retry-auto", "no-local",
|
|
709
720
|
"html", "csv", "alerts-only",
|
|
710
721
|
// project network: what its builds may reach (--open | --allowlist).
|
|
@@ -770,11 +781,11 @@ export function parseOperateArgs(argv, ownValues = new Set()) {
|
|
|
770
781
|
const value = equals === -1 ? argv[++index] : argument.slice(equals + 1);
|
|
771
782
|
// A following --flag is not a value — consuming it would swallow a real
|
|
772
783
|
// flag and leave this one holding a name-shaped lie.
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
// Explicit --name=value
|
|
776
|
-
const
|
|
777
|
-
if (value === undefined || (equals === -1 && value.startsWith("--") && !
|
|
784
|
+
// A secret (a password, a token) is random base64url and may begin with two hyphens: it is the next argument
|
|
785
|
+
// unless that argument is exactly a flag this parser knows (a real flag is never swallowed, finding 22). Other flags
|
|
786
|
+
// never take a following --flag. Explicit --name=value carries arbitrary literal leading hyphens for every flag.
|
|
787
|
+
const namesFlag = (one) => { const flag = one.slice(2).split("=")[0]; return booleans.has(flag) || wantsValue.has(flag); };
|
|
788
|
+
if (value === undefined || (equals === -1 && value.startsWith("--") && (!SECRET_VALUE_FLAGS.has(name) || namesFlag(value))))
|
|
778
789
|
return { error: `--${name} needs a value` };
|
|
779
790
|
// `flows import --param a=1 --param b=2`: each value kept, one per line.
|
|
780
791
|
flags.set(name, name === "param" && typeof flags.get(name) === "string" ? `${flags.get(name)}\n${value}` : value);
|
|
@@ -941,6 +952,8 @@ async function dispatchOn(command, positional, flags, repoList, base, options, e
|
|
|
941
952
|
...(options.installBin === undefined ? {} : { installBin: options.installBin }),
|
|
942
953
|
...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
|
|
943
954
|
...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
|
|
955
|
+
...(options.upRole === undefined ? {} : { upRole: options.upRole }),
|
|
956
|
+
...(upNeedsOneProcess(options) ? { upOneProcess: true } : {}),
|
|
944
957
|
...(activeRemote() === null ? {} : { principal: activeRemote() }),
|
|
945
958
|
});
|
|
946
959
|
return await enter(run);
|
|
@@ -1310,11 +1323,11 @@ async function dispatch(command, positional, flags, context) {
|
|
|
1310
1323
|
case "sync":
|
|
1311
1324
|
return syncCommand(flags, context);
|
|
1312
1325
|
case "serve":
|
|
1313
|
-
return serveCommand(flags, context);
|
|
1326
|
+
return gitTooOld(command, context) ?? serveCommand(flags, context);
|
|
1314
1327
|
case "watch":
|
|
1315
|
-
return watchCommand(flags, context);
|
|
1328
|
+
return gitTooOld(command, context) ?? watchCommand(flags, context);
|
|
1316
1329
|
case "up":
|
|
1317
|
-
return upCommand(flags, context);
|
|
1330
|
+
return gitTooOld(command, context) ?? upCommand(flags, context);
|
|
1318
1331
|
case "onboard":
|
|
1319
1332
|
return onboardCommand(flags, context);
|
|
1320
1333
|
case "daemon":
|
|
@@ -2220,7 +2233,7 @@ function flowIoOf(context, pool, git, base) {
|
|
|
2220
2233
|
replies: { fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dir,
|
|
2221
2234
|
...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) },
|
|
2222
2235
|
// Check and update steps run outside a model: an approved command in a fresh copy of the card's work, or a comment on the issue it came from.
|
|
2223
|
-
steps: { gh: context.flowStepIo?.gh ?? run, git: context.flowStepIo?.git ?? git, shell: context.flowStepIo?.shell ?? run, fetch: context.flowStepIo?.fetch ?? fetch,
|
|
2236
|
+
steps: { gh: context.flowStepIo?.gh ?? run, git: context.flowStepIo?.git ?? (git === run ? hardenedGit(run) : git), shell: context.flowStepIo?.shell ?? run, fetch: context.flowStepIo?.fetch ?? fetch,
|
|
2224
2237
|
dir: context.flowStepIo?.dir ?? dir, scratch: context.flowStepIo?.scratch ?? join(pool, "flow-checks"), base,
|
|
2225
2238
|
...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }) },
|
|
2226
2239
|
...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
|
|
@@ -3103,9 +3116,10 @@ async function tickCommand(flags, context) {
|
|
|
3103
3116
|
buildBase = source.head;
|
|
3104
3117
|
else {
|
|
3105
3118
|
const descendant = exists.code === 0 ? branch : base;
|
|
3106
|
-
|
|
3119
|
+
// On a git without --no-lazy-fetch, through a frozen read view that refuses a partial clone (lazyFetchSafeGit).
|
|
3120
|
+
const ancestry = await lazyFetchSafeGit(git)("git", [...noLazyFetch(), "-c", "protocol.allow=never", "--no-replace-objects", "merge-base", "--is-ancestor", source.head, descendant], { cwd: repo });
|
|
3107
3121
|
if (ancestry.code !== 0)
|
|
3108
|
-
problem = "the requested revision base does not contain its source head";
|
|
3122
|
+
problem = ancestry.stderr.includes(PARTIAL_CLONE_REFUSAL) ? PARTIAL_CLONE_REFUSAL : "the requested revision base does not contain its source head";
|
|
3109
3123
|
}
|
|
3110
3124
|
}
|
|
3111
3125
|
if (problem !== null) {
|
|
@@ -3905,6 +3919,8 @@ async function decideCommand(positional, flags, context) {
|
|
|
3905
3919
|
*/
|
|
3906
3920
|
async function startConsole(options) {
|
|
3907
3921
|
const { context } = options;
|
|
3922
|
+
// A git incident (teardown-git.ts) raises an attention notification in this console's outbox.
|
|
3923
|
+
noticeGitIncidents(context.store, context.clock);
|
|
3908
3924
|
const server = createDecisionServer({
|
|
3909
3925
|
store: context.store,
|
|
3910
3926
|
// Sign-in, integration and model checks, and the tidy-ups no page visit does (docs/plans/get-side-effects.md).
|
|
@@ -6041,6 +6057,8 @@ let staleStatusesRepaired = false;
|
|
|
6041
6057
|
async function runWatchLoop(args) {
|
|
6042
6058
|
const { context, flags, runner, token, repo, progress } = args;
|
|
6043
6059
|
const { store } = context;
|
|
6060
|
+
// A git incident in this worker's builds raises an attention notification in its outbox (once per store).
|
|
6061
|
+
noticeGitIncidents(store, context.clock);
|
|
6044
6062
|
const tickEveryMs = Number(text(flags, "tick-every") ?? 60_000);
|
|
6045
6063
|
const bridgeEveryMs = Number(text(flags, "bridge-every") ?? 45_000);
|
|
6046
6064
|
const reconcileEveryMs = Number(text(flags, "reconcile-every") ?? 5 * 60_000);
|
|
@@ -6050,7 +6068,19 @@ async function runWatchLoop(args) {
|
|
|
6050
6068
|
}
|
|
6051
6069
|
// The owner carries lifetime without changing the lease schema. Bounded watches cannot host automatic sweeps.
|
|
6052
6070
|
const incarnation = `${runFor === null ? "toolroll-service:" : ""}${randomUUID()}`;
|
|
6053
|
-
|
|
6071
|
+
let lease = acquireWatchLeaseAuthed(store, { runner, token, repo, owner: incarnation, ttlMs: WATCH_LEASE_MS }, new Date());
|
|
6072
|
+
// Only expiry hands the lease over (store.acquireWatchLease): a predecessor still renewing keeps it, and the takeover
|
|
6073
|
+
// recovers its incarnation in the same transaction, exactly as a fresh `up` after a crash would.
|
|
6074
|
+
const waitUntil = Date.now() + (args.leaseWaitMs ?? 0);
|
|
6075
|
+
let saidWaiting = false;
|
|
6076
|
+
const waitStopping = args.leaseWaitStopping ?? args.isStopping;
|
|
6077
|
+
while (!lease.ok && lease.reason === "watch-busy" && Date.now() < waitUntil && !waitStopping()) {
|
|
6078
|
+
if (!saidWaiting)
|
|
6079
|
+
progress(`watch: ${repo} is still held by the previous worker until ${lease.until ?? "its lease lapses"}; taking it over then`);
|
|
6080
|
+
saidWaiting = true;
|
|
6081
|
+
await new Promise(resolve => setTimeout(resolve, 1_000));
|
|
6082
|
+
lease = acquireWatchLeaseAuthed(store, { runner, token, repo, owner: incarnation, ttlMs: WATCH_LEASE_MS }, new Date());
|
|
6083
|
+
}
|
|
6054
6084
|
if (!lease.ok) {
|
|
6055
6085
|
return {
|
|
6056
6086
|
ok: false,
|
|
@@ -6650,8 +6680,9 @@ async function watchCommand(flags, context) {
|
|
|
6650
6680
|
*
|
|
6651
6681
|
* COMPOSITION ONLY: identities mint through the atomic doors (a first
|
|
6652
6682
|
* approver only while none exists; a runner only while its name is idle),
|
|
6653
|
-
* then the
|
|
6654
|
-
*
|
|
6683
|
+
* then the console and the worker (one watch loop per repository) run as two
|
|
6684
|
+
* supervised processes over one shared store (isolated-checks.md, stage 1;
|
|
6685
|
+
* up-processes.ts says what each holds). Nothing here
|
|
6655
6686
|
* loosens a ceremony: approval, budgets, publication, and every fence work
|
|
6656
6687
|
* exactly as they do for the long-hand verbs.
|
|
6657
6688
|
*/
|
|
@@ -6722,7 +6753,7 @@ async function resolveUpApprover(context, flags, canPrompt) {
|
|
|
6722
6753
|
// the insert (finding 27). A refused insert unlinks only when an
|
|
6723
6754
|
// UNRELATED approver won (finding 32).
|
|
6724
6755
|
const name = asFlag ?? process.env["USER"] ?? process.env["USERNAME"] ?? "operator";
|
|
6725
|
-
const password =
|
|
6756
|
+
const password = mintSecret(12);
|
|
6726
6757
|
try {
|
|
6727
6758
|
writeLoginFileDurably(loginFile, name, password);
|
|
6728
6759
|
}
|
|
@@ -6954,6 +6985,9 @@ async function onboardCommand(flags, context) {
|
|
|
6954
6985
|
}
|
|
6955
6986
|
async function upCommand(flags, context) {
|
|
6956
6987
|
const { store, write, json, clock } = context;
|
|
6988
|
+
// A role of an `up` that is already running (up-role-child.ts): no validation, identity or port of its own.
|
|
6989
|
+
if (context.upRole !== undefined)
|
|
6990
|
+
return upRoleCommand(context, context.upRole);
|
|
6957
6991
|
const demoFence = refuseDemo(context, "up");
|
|
6958
6992
|
if (demoFence !== null)
|
|
6959
6993
|
return demoFence;
|
|
@@ -7152,292 +7186,133 @@ async function upCommand(flags, context) {
|
|
|
7152
7186
|
finally {
|
|
7153
7187
|
await new Promise(done => probe.close(() => done()));
|
|
7154
7188
|
}
|
|
7155
|
-
// 7. The console
|
|
7156
|
-
//
|
|
7157
|
-
//
|
|
7189
|
+
// 7–8. The console and the worker (docs/plans/isolated-checks.md, stage 1): two processes, each started and
|
|
7190
|
+
// restarted by controller-supervisor, so a long check in the worker never holds a console response. With seams
|
|
7191
|
+
// that can't cross a process (tests, the desktop host) or --single-process, the same two roles run in this one.
|
|
7158
7192
|
const pool = join(dirname(context.databaseFile), "worktrees");
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
const
|
|
7163
|
-
// Cockpit B1: this start's instance secret, minted once it is listening; the console answers its challenges with it.
|
|
7164
|
-
let instanceSecret = null;
|
|
7165
|
-
let console_;
|
|
7166
|
-
try {
|
|
7167
|
-
console_ = await startConsole({
|
|
7168
|
-
instanceSecret: () => instanceSecret,
|
|
7169
|
-
context,
|
|
7170
|
-
host: hostFlag,
|
|
7171
|
-
port,
|
|
7172
|
-
...(allowFlag === undefined ? {} : { allowedHosts: allowFlag.split(",").map(one => one.trim()).filter(one => one !== "") }),
|
|
7173
|
-
...(publicUrlFlag === undefined ? {} : { publicUrl: publicUrlFlag }),
|
|
7174
|
-
localRunner: runnerName,
|
|
7175
|
-
poolRoot: pool,
|
|
7176
|
-
repos,
|
|
7177
|
-
currentRepos: () => [...activeRepos],
|
|
7178
|
-
admittedRepos: () => [...activeRepos],
|
|
7179
|
-
upConsole: true,
|
|
7180
|
-
registryPath,
|
|
7181
|
-
projectRoots,
|
|
7182
|
-
...(text(flags, "public-url") === undefined ? {} : { publicUrl: text(flags, "public-url") }),
|
|
7183
|
-
...(text(flags, "editor") === undefined ? {} : { editorLinks: "vscode" }),
|
|
7184
|
-
});
|
|
7185
|
-
}
|
|
7186
|
-
catch (error) {
|
|
7187
|
-
retireRunnerIfCurrent(store, runnerName, runnerToken, clock());
|
|
7188
|
-
return fail(write, json, "up", "port-busy", `${describe(error)} — the port was taken while starting; try again`, EXIT.refused);
|
|
7189
|
-
}
|
|
7190
|
-
// 8. The project supervisor: one watch loop per proved repository, plus a
|
|
7191
|
-
// tiny registry poll that can add more while this process stays up. The
|
|
7192
|
-
// poll is deliberately plain filesystem I/O (portable across macOS,
|
|
7193
|
-
// Linux, and Windows) and never invokes an agent while nothing changed.
|
|
7193
|
+
const split = !flags.has("single-process") && context.upOneProcess !== true;
|
|
7194
|
+
const build = upBuildIdentity();
|
|
7195
|
+
const containmentFlag = text(flags, "containment") ?? null;
|
|
7196
|
+
const stopGraceMs = Number(text(flags, "stop-grace") ?? 30_000);
|
|
7194
7197
|
let stopping = false;
|
|
7195
7198
|
let fatal = null;
|
|
7196
|
-
let graceTimer;
|
|
7197
7199
|
let runTimer;
|
|
7198
|
-
const followControllers = [];
|
|
7199
7200
|
let resolveStopped = () => { };
|
|
7200
7201
|
const stopped = new Promise(resolveStop => (resolveStopped = resolveStop));
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
progress(`Stopped ${terminated} running build(s). Their work is saved, and each resumes when the builder starts again.`);
|
|
7206
|
-
};
|
|
7202
|
+
// What the worker serves now: relayed to the console (its project rail and chat), and a restarted worker's start.
|
|
7203
|
+
let currentRepos = [...repos];
|
|
7204
|
+
let consoleRole = null;
|
|
7205
|
+
let workerRole = null;
|
|
7207
7206
|
const stop = () => {
|
|
7208
7207
|
if (stopping) {
|
|
7209
|
-
hardStop();
|
|
7208
|
+
workerRole?.hardStop();
|
|
7210
7209
|
return;
|
|
7211
7210
|
}
|
|
7212
7211
|
stopping = true;
|
|
7213
7212
|
resolveStopped();
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
7217
|
-
|
|
7218
|
-
// killed process cannot release it. Its outcome is read again at close.
|
|
7219
|
-
console_.server.closeCoding().catch(() => { });
|
|
7220
|
-
const building = store.liveClaimCount(runnerName, clock());
|
|
7221
|
-
if (building > 0) {
|
|
7222
|
-
progress(`Stopping: ${building} build(s) running. Waiting up to ${Math.round(stopGraceMs / 1000)}s for them to finish; any still running then is saved and resumes when the builder starts again.`);
|
|
7223
|
-
}
|
|
7224
|
-
graceTimer = setTimeout(hardStop, stopGraceMs);
|
|
7225
|
-
graceTimer.unref?.();
|
|
7213
|
+
// The coding owner record is released first, whatever else is still finishing: launchd kills a service that
|
|
7214
|
+
// outlasts its exit window, and a killed process cannot release it. The console keeps serving until the end.
|
|
7215
|
+
consoleRole?.send({ type: "stopping" });
|
|
7216
|
+
workerRole?.stop();
|
|
7226
7217
|
};
|
|
7227
7218
|
process.on("SIGINT", stop);
|
|
7228
7219
|
process.on("SIGTERM", stop);
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
// A bounded `up` must not advertise its child watches as an unbounded sweep service either.
|
|
7236
|
-
if (forGiven !== undefined)
|
|
7237
|
-
copy.set("for", forGiven);
|
|
7238
|
-
copy.set("runner", runnerName);
|
|
7239
|
-
copy.set("token", runnerToken);
|
|
7240
|
-
copy.set("repo", repo);
|
|
7241
|
-
copy.set("pool", pool);
|
|
7242
|
-
// The co-hosted console's stated origin rides into each loop's follower:
|
|
7243
|
-
// a phone link must name THIS console, or not exist.
|
|
7244
|
-
const publicUrl = text(flags, "public-url");
|
|
7245
|
-
if (publicUrl !== undefined)
|
|
7246
|
-
copy.set("public-url", publicUrl);
|
|
7247
|
-
return copy;
|
|
7220
|
+
// A log (a service, an agent, --json or --verbose) says whose each line is; a person at a terminal reads plain lines.
|
|
7221
|
+
const labelled = split && (json || flags.has("verbose") || !(context.upSeams?.terminal ?? process.stdout.isTTY === true));
|
|
7222
|
+
const roleLine = (role) => (line) => progress(labelled ? `[${role}] ${line}` : line);
|
|
7223
|
+
const dropSignals = () => {
|
|
7224
|
+
process.removeListener("SIGINT", stop);
|
|
7225
|
+
process.removeListener("SIGTERM", stop);
|
|
7248
7226
|
};
|
|
7249
|
-
const
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
// terminal), and always when a required policy will refuse spawns.
|
|
7253
|
-
const containmentDetail = flags.has("verbose") || !(context.upSeams?.terminal ?? process.stdout.isTTY === true);
|
|
7254
|
-
let containmentSaid = false;
|
|
7255
|
-
const containmentLines = (effective) => {
|
|
7256
|
-
const notice = containmentNotice(effective);
|
|
7257
|
-
if (notice !== null && !containmentSaid)
|
|
7258
|
-
progress(notice);
|
|
7259
|
-
containmentSaid = true;
|
|
7260
|
-
return containmentDetail || effective.refusal !== null ? [`watch: ${describeContainment(effective)}`] : [];
|
|
7227
|
+
const consoleConfig = {
|
|
7228
|
+
host: hostFlag, port, allowedHosts: allowFlag === undefined ? null : allowFlag.split(",").map(one => one.trim()).filter(one => one !== ""),
|
|
7229
|
+
publicUrl: publicUrlFlag ?? null, localRunner: runnerName, poolRoot: pool, repos: [...repos], projectRoots, registryPath, editor: editorGiven !== undefined,
|
|
7261
7230
|
};
|
|
7262
|
-
const
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
if (!bound.ok) {
|
|
7274
|
-
fatal = `the builder identity changed while adding ${repo} (${bound.reason})`;
|
|
7275
|
-
stop();
|
|
7276
|
-
return;
|
|
7231
|
+
const pendingLinks = new Map();
|
|
7232
|
+
let linkRequests = 0;
|
|
7233
|
+
consoleRole = startUpRole({
|
|
7234
|
+
role: "console", split, context, build, shutdownMs: 15_000,
|
|
7235
|
+
start: () => ({ type: "start", role: "console", databaseFile: context.databaseFile, containment: containmentFlag, build, config: { ...consoleConfig, repos: [...currentRepos] } }),
|
|
7236
|
+
onMessage: message => {
|
|
7237
|
+
if (message.type === "log")
|
|
7238
|
+
roleLine("console")(message.line);
|
|
7239
|
+
else if (message.type === "sign-in-link") {
|
|
7240
|
+
pendingLinks.get(message.id)?.(message.link);
|
|
7241
|
+
pendingLinks.delete(message.id);
|
|
7277
7242
|
}
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7243
|
+
},
|
|
7244
|
+
// A restarted console is told what the worker serves now.
|
|
7245
|
+
onReady: (_, again) => { if (again) {
|
|
7246
|
+
roleLine("console")("started again");
|
|
7247
|
+
consoleRole?.send({ type: "repos", repos: [...currentRepos] });
|
|
7248
|
+
} },
|
|
7249
|
+
onEnded: (detail, restarting) => {
|
|
7250
|
+
if (restarting)
|
|
7251
|
+
roleLine("console")(`${detail}; starting it again`);
|
|
7252
|
+
else if (!stopping) {
|
|
7253
|
+
fatal = detail;
|
|
7254
|
+
stop();
|
|
7281
7255
|
}
|
|
7282
|
-
}
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
|
|
7299
|
-
|
|
7300
|
-
|
|
7256
|
+
},
|
|
7257
|
+
});
|
|
7258
|
+
const consoleReady = await consoleRole.ready;
|
|
7259
|
+
if (!consoleReady.ok) {
|
|
7260
|
+
dropSignals();
|
|
7261
|
+
retireRunnerIfCurrent(store, runnerName, runnerToken, clock());
|
|
7262
|
+
if (consoleReady.reason === "port-busy")
|
|
7263
|
+
return fail(write, json, "up", "port-busy", `${consoleReady.detail} — the port was taken while starting; try again`, EXIT.refused);
|
|
7264
|
+
return fail(write, json, "up", "failed", `the console could not start (${consoleReady.detail}) — nothing was left running`, EXIT.failed);
|
|
7265
|
+
}
|
|
7266
|
+
const url = consoleReady.url;
|
|
7267
|
+
if (!stopping) {
|
|
7268
|
+
workerRole = startUpRole({
|
|
7269
|
+
role: "worker", split, context, build,
|
|
7270
|
+
// The worker drains its builds for the stop grace, then stops them; past that it is stopped outright.
|
|
7271
|
+
shutdownMs: Math.max(1, stopGraceMs) + 15_000,
|
|
7272
|
+
start: generation => ({
|
|
7273
|
+
type: "start", role: "worker", databaseFile: context.databaseFile, containment: containmentFlag, build,
|
|
7274
|
+
config: {
|
|
7275
|
+
runner: runnerName, token: runnerToken, repos: generation === 1 ? [...repos] : [...currentRepos], pinnedRepos: [...pinnedRepos], projectRoots, registryPath, pool,
|
|
7276
|
+
forMs: forGiven === undefined ? null : Number(forGiven), publicUrl: publicUrlFlag ?? null, stopGraceMs, verbose: flags.has("verbose"),
|
|
7277
|
+
terminal: context.upSeams?.terminal ?? process.stdout.isTTY === true,
|
|
7278
|
+
// A restart waits out a crashed predecessor's lease (90 s), then recovers it: as a fresh `up` after a crash.
|
|
7279
|
+
leaseWaitMs: split ? WATCH_LEASE_MS + 15_000 : 0,
|
|
7280
|
+
},
|
|
7281
|
+
}),
|
|
7282
|
+
onMessage: message => {
|
|
7283
|
+
if (message.type === "log")
|
|
7284
|
+
roleLine("worker")(message.line);
|
|
7285
|
+
else if (message.type === "repos") {
|
|
7286
|
+
// The console's admitted projects widen only to what the builder is bound to in the store (the worker binds
|
|
7287
|
+
// through the authenticated door before it reports): a worker's word alone admits nothing.
|
|
7288
|
+
const bound = new Set(store.getRunner(runnerName)?.runner.repos ?? []);
|
|
7289
|
+
currentRepos = message.repos.filter(repo => bound.has(repo));
|
|
7290
|
+
consoleRole?.send({ type: "repos", repos: [...currentRepos] });
|
|
7291
|
+
}
|
|
7301
7292
|
},
|
|
7302
|
-
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7306
|
-
//
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7293
|
+
onReady: (_, again) => { if (again)
|
|
7294
|
+
roleLine("worker")("started again: its builds and checks continue"); },
|
|
7295
|
+
onEnded: (detail, restarting) => {
|
|
7296
|
+
// A builder whose credential no longer proves can't come back by restarting: the whole `up` stops, and the
|
|
7297
|
+
// service's next start registers it again, as before the split.
|
|
7298
|
+
if (restarting && authenticate(store, runnerName, runnerToken).ok) {
|
|
7299
|
+
roleLine("worker")(`${detail}; starting it again — its interrupted builds are recovered first`);
|
|
7300
|
+
return;
|
|
7301
|
+
}
|
|
7302
|
+
if (!stopping) {
|
|
7303
|
+
fatal = restarting ? `the builder identity changed (${detail})` : detail;
|
|
7310
7304
|
stop();
|
|
7311
7305
|
}
|
|
7312
|
-
|
|
7313
|
-
releasedRepos.delete(endedRepo);
|
|
7314
|
-
progress(`project removed — ${projectName(endedRepo)} is no longer built here`);
|
|
7315
|
-
markReady();
|
|
7316
|
-
return;
|
|
7317
|
-
}
|
|
7318
|
-
// A loop that dies while its siblings live must not leave a partial
|
|
7319
|
-
// cockpit standing silently (finding 5).
|
|
7320
|
-
if (!result.ok && !stopping) {
|
|
7321
|
-
fatal = `${prefix(endedRepo)}${result.detail}`;
|
|
7322
|
-
stop();
|
|
7323
|
-
}
|
|
7324
|
-
markReady(); // acquisition failures must not strand startup readiness
|
|
7325
|
-
});
|
|
7326
|
-
await ready;
|
|
7327
|
-
};
|
|
7328
|
-
await Promise.all(repos.map(repo => launchRepo(repo, false)));
|
|
7329
|
-
// Telegram: one follower for the whole service, never one per project
|
|
7330
|
-
// loop. Replies and notifications go out from it, over the same transport
|
|
7331
|
-
// in the same cycle (Oct 2: a reply sent by a project loop's own follower
|
|
7332
|
-
// failed "fetch failed" while notifications went out). A token saved
|
|
7333
|
-
// while the service runs connects within seconds; a follower that dies
|
|
7334
|
-
// is started again.
|
|
7335
|
-
const telegramController = new AbortController();
|
|
7336
|
-
followControllers.push(telegramController);
|
|
7337
|
-
const telegramSupervisor = (async () => {
|
|
7338
|
-
const pause = (ms) => new Promise(resolvePause => {
|
|
7339
|
-
const timer = setTimeout(done, ms);
|
|
7340
|
-
function done() { clearTimeout(timer); telegramController.signal.removeEventListener("abort", done); resolvePause(); }
|
|
7341
|
-
// An aborted signal never fires again: without this a stop waited the whole pause.
|
|
7342
|
-
if (telegramController.signal.aborted)
|
|
7343
|
-
done();
|
|
7344
|
-
else
|
|
7345
|
-
telegramController.signal.addEventListener("abort", done, { once: true });
|
|
7306
|
+
},
|
|
7346
7307
|
});
|
|
7347
|
-
|
|
7348
|
-
|
|
7349
|
-
|
|
7350
|
-
|
|
7351
|
-
});
|
|
7352
|
-
if (follower !== null)
|
|
7353
|
-
await follower;
|
|
7354
|
-
// Without a token (or on a fenced network) look again shortly; the fence line is said once.
|
|
7355
|
-
if (follower === null && loadBotToken(process.env, context.telegramTokenFile) !== null)
|
|
7356
|
-
break;
|
|
7357
|
-
await pause(follower === null ? 2_000 : 5_000);
|
|
7358
|
-
}
|
|
7359
|
-
})();
|
|
7360
|
-
const dynamicCeiling = resolveCeiling(repos, projectRoots).ceiling;
|
|
7361
|
-
const rejected = new Set();
|
|
7362
|
-
const registrySupervisor = (async () => {
|
|
7363
|
-
const sleep = (ms) => new Promise(resolveSleep => setTimeout(resolveSleep, ms));
|
|
7364
|
-
while (!stopping) {
|
|
7365
|
-
const loaded = await loadProjectRegistry(registryPath);
|
|
7366
|
-
if ("error" in loaded) {
|
|
7367
|
-
fatal = loaded.error;
|
|
7368
|
-
stop();
|
|
7369
|
-
break;
|
|
7370
|
-
}
|
|
7371
|
-
// Removed from the saved list: stop building it and take it out of the console. A pinned start keeps its own.
|
|
7372
|
-
const saved = new Set(loaded.repos.map(one => canonicalProject(one) ?? one));
|
|
7373
|
-
for (const repo of [...activeRepos]) {
|
|
7374
|
-
if (stopping || pinnedRepos.has(repo) || saved.has(repo))
|
|
7375
|
-
continue;
|
|
7376
|
-
activeRepos.delete(repo);
|
|
7377
|
-
releasedRepos.add(repo);
|
|
7378
|
-
if (!loopResults.has(repo))
|
|
7379
|
-
releasedRepos.delete(repo);
|
|
7380
|
-
}
|
|
7381
|
-
for (const candidate of loaded.repos) {
|
|
7382
|
-
if (stopping)
|
|
7383
|
-
break;
|
|
7384
|
-
const canonical = canonicalProject(candidate);
|
|
7385
|
-
// Still letting go of an earlier removal: it rejoins on the next pass after its watch has stopped.
|
|
7386
|
-
if (canonical !== null && (activeRepos.has(canonical) || releasedRepos.has(canonical)))
|
|
7387
|
-
continue;
|
|
7388
|
-
const approvedAdditions = context.additionalProjectRepos?.() ?? [];
|
|
7389
|
-
const admittedCeiling = resolveCeiling([...dynamicCeiling.repos, ...approvedAdditions], dynamicCeiling.roots).ceiling;
|
|
7390
|
-
// A project added after this start (`repos add`, the lead, the console) carries its recorded admission; a
|
|
7391
|
-
// registry line alone is not authority. Either way the folder is proved to be that Git checkout again.
|
|
7392
|
-
const allowed = canonical !== null &&
|
|
7393
|
-
((await authorizedProject(admittedCeiling, canonical)) || admissionRecorded(store, canonical)) &&
|
|
7394
|
-
(await proveRepo(canonical)) === canonical;
|
|
7395
|
-
if (!allowed) {
|
|
7396
|
-
if (!rejected.has(candidate)) {
|
|
7397
|
-
rejected.add(candidate);
|
|
7398
|
-
progress(`project not connected — ${candidate} isn't a Git repository added with \`toolroll repos add\` or the console`);
|
|
7399
|
-
}
|
|
7400
|
-
continue;
|
|
7401
|
-
}
|
|
7402
|
-
rejected.delete(candidate);
|
|
7403
|
-
await launchRepo(canonical, true);
|
|
7404
|
-
}
|
|
7405
|
-
if (!stopping)
|
|
7406
|
-
await sleep(500);
|
|
7308
|
+
const workerReady = await workerRole.ready;
|
|
7309
|
+
if (!workerReady.ok && fatal === null) {
|
|
7310
|
+
fatal = workerReady.detail;
|
|
7311
|
+
stop();
|
|
7407
7312
|
}
|
|
7408
|
-
})().catch(error => {
|
|
7409
|
-
fatal = `the project registry stopped (${describe(error)})`;
|
|
7410
|
-
stop();
|
|
7411
|
-
});
|
|
7412
|
-
// A runner with no projects still stays visibly alive while the UI waits
|
|
7413
|
-
// for the first addition. Watch loops heartbeat during their passes; this
|
|
7414
|
-
// machine-level pulse also covers the intentionally empty state.
|
|
7415
|
-
// Provider readiness (v47): this machine's non-spending observations,
|
|
7416
|
-
// recorded under its own runner name ONCE at start — so every route
|
|
7417
|
-
// projection can say ready / unavailable / unknown per provider, and
|
|
7418
|
-
// the dispatch gate can halt a leg this machine cannot run. There is no
|
|
7419
|
-
// periodic readiness daemon: startup, an explicit `providers --report`,
|
|
7420
|
-
// and the narrow pre-dispatch observation are the roads. Production
|
|
7421
|
-
// only: a test's injected agent runner is a fake, and its readiness
|
|
7422
|
-
// would be a fiction.
|
|
7423
|
-
const observeReadiness = () => {
|
|
7424
|
-
if (context.agentRunner !== undefined)
|
|
7425
|
-
return;
|
|
7426
|
-
void observeProviderReadiness((file, args, options) => run(file, args, { timeoutMs: options?.timeoutMs ?? 5_000, ...(options?.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) }), process.env, PROVIDER_IDS, provider => store.acpAttestationFor(provider, runnerName))
|
|
7427
|
-
.then(observed => {
|
|
7428
|
-
if (!stopping)
|
|
7429
|
-
reportProviderReadinessAuthed(store, { name: runnerName, token: runnerToken, observations: observed }, clock());
|
|
7430
|
-
})
|
|
7431
|
-
.catch(() => {
|
|
7432
|
-
// A probe that cannot run leaves readiness unknown — never a fiction.
|
|
7433
|
-
});
|
|
7434
|
-
};
|
|
7435
|
-
observeReadiness();
|
|
7436
|
-
try {
|
|
7437
|
-
notifyVersionLive(context.store, dirname(context.databaseFile), PACKAGE_VERSION, clock());
|
|
7438
7313
|
}
|
|
7439
|
-
|
|
7440
|
-
|
|
7314
|
+
// A runner with no projects still stays visibly alive while the UI waits for the first addition; the worker's
|
|
7315
|
+
// loops also renew it with their leases. Across a worker restart this keeps the name held.
|
|
7441
7316
|
const runnerHeartbeat = setInterval(() => {
|
|
7442
7317
|
const beat = heartbeatRunner(store, runnerName, runnerToken, clock());
|
|
7443
7318
|
if (!beat.ok && !stopping) {
|
|
@@ -7448,29 +7323,13 @@ async function upCommand(flags, context) {
|
|
|
7448
7323
|
runnerHeartbeat.unref?.();
|
|
7449
7324
|
// 9. Readiness, then the ONE startup envelope / greeting (finding 11/20).
|
|
7450
7325
|
const approverPlan2 = approver;
|
|
7451
|
-
const url = console_.url;
|
|
7452
7326
|
const terminal = !agentRunning && (context.upSeams?.terminal ?? process.stdout.isTTY === true);
|
|
7453
7327
|
const upHandoff = buildHandoff({
|
|
7454
7328
|
url,
|
|
7455
7329
|
login: { account: approverPlan2.passwordFile === null ? approverPlan2.approver : loginAccount(approverPlan2.passwordFile) ?? approverPlan2.approver, file: approverPlan2.passwordFile },
|
|
7456
7330
|
});
|
|
7457
7331
|
recordUpConsoleUrl(dirname(context.databaseFile), url, clock());
|
|
7458
|
-
|
|
7459
|
-
// (behind the agent fence), and a discovery-only socket beside it. A socket that can't open only means attaching
|
|
7460
|
-
// isn't offered; the console runs either way.
|
|
7461
|
-
// The socket is taken FIRST: only the console that owns it publishes a record. A second console on the same database
|
|
7462
|
-
// (another port) never replaces the first one's record; it runs, but isn't attached to.
|
|
7463
|
-
let mintedSecret = null;
|
|
7464
|
-
const instanceSocket = await openInstanceSocket(context.databaseFile, { pid: process.pid, build: packageVersion(), database: context.databaseFile, port: console_.port }, () => mintedSecret);
|
|
7465
|
-
if (instanceSocket.ok) {
|
|
7466
|
-
mintedSecret = await claimInstance(store, context.databaseFile, { pid: process.pid, port: console_.port, build: packageVersion(), socket: instanceSocket.name }, clock());
|
|
7467
|
-
instanceSecret = mintedSecret;
|
|
7468
|
-
if (mintedSecret === null)
|
|
7469
|
-
progress("attach: another console is running on this database — `toolroll` in a repository opens that one");
|
|
7470
|
-
}
|
|
7471
|
-
else
|
|
7472
|
-
progress(`attach: ${instanceSocket.problem} — \`toolroll\` in a repository won't find this console`);
|
|
7473
|
-
if (fatal === null) {
|
|
7332
|
+
if (fatal === null && !stopping) {
|
|
7474
7333
|
if (json) {
|
|
7475
7334
|
write(envelopeJson({
|
|
7476
7335
|
ok: true,
|
|
@@ -7498,7 +7357,13 @@ async function upCommand(flags, context) {
|
|
|
7498
7357
|
// one-time sign-in link (single use, ten minutes, this computer only); it is never printed. The password stays
|
|
7499
7358
|
// saved beside the database, for later and for other devices, and is never printed either.
|
|
7500
7359
|
const account = approverPlan2.approver;
|
|
7501
|
-
|
|
7360
|
+
// The console mints it (its sign-in secrets stay there); it comes back only to be opened, never printed.
|
|
7361
|
+
const link = account === null || flags.has("no-open") ? null : await new Promise(resolveLink => {
|
|
7362
|
+
const id = ++linkRequests;
|
|
7363
|
+
const timer = setTimeout(() => { pendingLinks.delete(id); resolveLink(null); }, 10_000);
|
|
7364
|
+
pendingLinks.set(id, value => { clearTimeout(timer); resolveLink(value); });
|
|
7365
|
+
consoleRole?.send({ type: "sign-in-link", id, account });
|
|
7366
|
+
});
|
|
7502
7367
|
const home = homedir();
|
|
7503
7368
|
const file = approverPlan2.passwordFile === null ? null : approverPlan2.passwordFile.startsWith(`${home}/`) ? `~${approverPlan2.passwordFile.slice(home.length)}` : approverPlan2.passwordFile;
|
|
7504
7369
|
const signInWords = account === null
|
|
@@ -7517,35 +7382,26 @@ async function upCommand(flags, context) {
|
|
|
7517
7382
|
}
|
|
7518
7383
|
}
|
|
7519
7384
|
}
|
|
7520
|
-
//
|
|
7385
|
+
// A bounded run (--for) counts from readiness, so a slow start never eats the window it asked for.
|
|
7386
|
+
if (forGiven !== undefined && !stopping) {
|
|
7387
|
+
runTimer = setTimeout(stop, Number(forGiven));
|
|
7388
|
+
runTimer.unref?.();
|
|
7389
|
+
}
|
|
7390
|
+
// 10. Supervise to the end: the worker drains first (its builds finish or are saved), the builder retires, and
|
|
7391
|
+
// only then does the console stop serving.
|
|
7521
7392
|
await stopped;
|
|
7522
|
-
|
|
7523
|
-
|
|
7524
|
-
// The follower was aborted with the stop. A send or a chat turn it is still
|
|
7525
|
-
// finishing keeps its own durable record; the service does not wait past
|
|
7526
|
-
// this for it.
|
|
7527
|
-
await new Promise(done => {
|
|
7528
|
-
const timer = setTimeout(done, TELEGRAM_STOP_WAIT_MS);
|
|
7529
|
-
const finish = () => { clearTimeout(timer); done(); };
|
|
7530
|
-
void telegramSupervisor.then(finish, finish);
|
|
7531
|
-
});
|
|
7393
|
+
if (workerRole !== null)
|
|
7394
|
+
await workerRole.done;
|
|
7532
7395
|
clearInterval(runnerHeartbeat);
|
|
7533
7396
|
if (runTimer !== undefined)
|
|
7534
7397
|
clearTimeout(runTimer);
|
|
7535
|
-
|
|
7536
|
-
clearTimeout(graceTimer);
|
|
7537
|
-
process.removeListener("SIGINT", stop);
|
|
7538
|
-
process.removeListener("SIGTERM", stop);
|
|
7398
|
+
dropSignals();
|
|
7539
7399
|
retireRunnerIfCurrent(store, runnerName, runnerToken, clock());
|
|
7540
|
-
|
|
7541
|
-
|
|
7542
|
-
if (mintedSecret !== null)
|
|
7543
|
-
clearInstance(store, mintedSecret);
|
|
7544
|
-
let closeProblem = null;
|
|
7545
|
-
await new Promise(done => console_.server.close(error => { closeProblem = error ?? null; done(); }));
|
|
7400
|
+
consoleRole.stop();
|
|
7401
|
+
const closed = await consoleRole.done;
|
|
7546
7402
|
// A live terminal that couldn't be proved stopped (or any other close failure) is said, never "stopped".
|
|
7547
|
-
if (
|
|
7548
|
-
fatal =
|
|
7403
|
+
if (closed.problem !== null && fatal === null)
|
|
7404
|
+
fatal = closed.problem;
|
|
7549
7405
|
if (fatal !== null) {
|
|
7550
7406
|
// The startup envelope (when json) already went out; the exit code is
|
|
7551
7407
|
// the health signal (finding 11/20) — never a second envelope.
|
|
@@ -11726,3 +11582,607 @@ async function projectSandboxCommand(positional, flags, context) {
|
|
|
11726
11582
|
"Scopes written from now on use this; scopes already approved keep what they were approved with.",
|
|
11727
11583
|
]);
|
|
11728
11584
|
}
|
|
11585
|
+
/** Starting the console or the worker on a git older than Toolroll's floor (git-floor.ts): refused, with one message
|
|
11586
|
+
* naming the installed and the required version. Other commands aren't held. */
|
|
11587
|
+
function gitTooOld(command, context) {
|
|
11588
|
+
const problem = installedGitFloorProblem();
|
|
11589
|
+
return problem === null ? null : fail(context.write, context.json, command, "git-too-old", problem, EXIT.refused);
|
|
11590
|
+
}
|
|
11591
|
+
/** This build's identity, as each role reports it: the version and the code of this module. */
|
|
11592
|
+
function upBuildIdentity() {
|
|
11593
|
+
return buildIdentity(PACKAGE_VERSION, fileURLToPath(import.meta.url));
|
|
11594
|
+
}
|
|
11595
|
+
/** Seams that live in this process's memory (a test's fake runner, a store wrapper, the desktop host's proof key): a
|
|
11596
|
+
* child process can't receive them, so `up` keeps both of its roles in this process rather than drop them. */
|
|
11597
|
+
function upNeedsOneProcess(options) {
|
|
11598
|
+
return options.openDatabase !== undefined || options.desktopIdentity !== undefined || options.additionalProjectRepos !== undefined ||
|
|
11599
|
+
options.agentRunner !== undefined || options.gitRunner !== undefined || options.telegramTransport !== undefined || options.telegramEnv !== undefined ||
|
|
11600
|
+
options.publishExec !== undefined || options.dispatchAdapter !== undefined || options.flowTriggerIo !== undefined || options.flowStepIo !== undefined ||
|
|
11601
|
+
options.flowEveryMs !== undefined || options.platform !== undefined || options.fenceAvailable !== undefined || options.flowFetch !== undefined ||
|
|
11602
|
+
options.integrationIo !== undefined || options.shouldStop !== undefined || options.leadSeams !== undefined || options.waitSleep !== undefined ||
|
|
11603
|
+
options.releaseIo !== undefined || options.installBin !== undefined || options.tempRoots !== undefined || options.filingSizer !== undefined ||
|
|
11604
|
+
options.now !== undefined;
|
|
11605
|
+
}
|
|
11606
|
+
/** The entry a role child runs: the built sibling when installed, the TypeScript source through tsx in a checkout. */
|
|
11607
|
+
function upRoleChildCommand(role) {
|
|
11608
|
+
const source = import.meta.url.endsWith(".ts");
|
|
11609
|
+
const entry = fileURLToPath(new URL(source ? "./up-role-child.ts" : "./up-role-child.js", import.meta.url));
|
|
11610
|
+
// tsx by its resolved address: the child keeps this process's working directory, which may hold no node_modules.
|
|
11611
|
+
// The role's name is the one argument: a process listing tells the two apart, and carries nothing secret.
|
|
11612
|
+
return { file: process.execPath, argv: [...(source ? ["--import", import.meta.resolve("tsx")] : []), entry, role] };
|
|
11613
|
+
}
|
|
11614
|
+
/**
|
|
11615
|
+
* One role of `up`: a supervised child process (split), or the same role function in this process. Either way its
|
|
11616
|
+
* messages pass the same checks (up-processes.ts), and its build must be this one's.
|
|
11617
|
+
* - onReady fires on every readiness; `again` after a restart.
|
|
11618
|
+
* - onEnded: the role ended while not stopping. `restarting` (split only) when the supervisor will start it again.
|
|
11619
|
+
*/
|
|
11620
|
+
function startUpRole(args) {
|
|
11621
|
+
const { role } = args;
|
|
11622
|
+
let resolveReady = () => { };
|
|
11623
|
+
const ready = new Promise(resolve => (resolveReady = resolve));
|
|
11624
|
+
// Readiness per generation (Codex review round 1, finding 5): a replacement's refusal is never hidden by an earlier
|
|
11625
|
+
// generation having been ready.
|
|
11626
|
+
const generations = new RoleGenerations();
|
|
11627
|
+
let stopping = false;
|
|
11628
|
+
// Reported once: the role ended for good (onEnded with restarting false), whichever path saw it first.
|
|
11629
|
+
let endedForGood = false;
|
|
11630
|
+
const endForGood = (detail) => {
|
|
11631
|
+
if (stopping || endedForGood)
|
|
11632
|
+
return;
|
|
11633
|
+
endedForGood = true;
|
|
11634
|
+
args.onEnded(detail, false);
|
|
11635
|
+
};
|
|
11636
|
+
let failed = null;
|
|
11637
|
+
let fatal = null;
|
|
11638
|
+
let closedProblem = null;
|
|
11639
|
+
let livePid = null;
|
|
11640
|
+
// Shared by both transports: every message is checked against this role before anything acts on it.
|
|
11641
|
+
const receive = (raw, abort) => {
|
|
11642
|
+
const message = roleMessage(raw, role);
|
|
11643
|
+
if (message === null)
|
|
11644
|
+
return;
|
|
11645
|
+
if (message.type === "ready") {
|
|
11646
|
+
if (message.build !== args.build) {
|
|
11647
|
+
// Never a console and a worker from different builds: stop rather than run a mixed pair.
|
|
11648
|
+
fatal = `the ${role} is a different build (${message.build}) from this one (${args.build}) — restart Toolroll`;
|
|
11649
|
+
failed = { reason: "start", detail: fatal };
|
|
11650
|
+
generations.refused(fatal);
|
|
11651
|
+
abort();
|
|
11652
|
+
return;
|
|
11653
|
+
}
|
|
11654
|
+
livePid = message.pid;
|
|
11655
|
+
const again = !generations.ready();
|
|
11656
|
+
if (!again)
|
|
11657
|
+
resolveReady({ ok: true, url: message.url ?? "", port: message.port, pid: message.pid });
|
|
11658
|
+
args.onReady(message, again);
|
|
11659
|
+
return;
|
|
11660
|
+
}
|
|
11661
|
+
if (message.type === "failed") {
|
|
11662
|
+
failed = { reason: message.reason, detail: message.detail };
|
|
11663
|
+
generations.refused(message.detail);
|
|
11664
|
+
return;
|
|
11665
|
+
}
|
|
11666
|
+
if (message.type === "fatal") {
|
|
11667
|
+
fatal = message.detail;
|
|
11668
|
+
return;
|
|
11669
|
+
}
|
|
11670
|
+
if (message.type === "closed") {
|
|
11671
|
+
closedProblem = message.problem;
|
|
11672
|
+
return;
|
|
11673
|
+
}
|
|
11674
|
+
args.onMessage(message);
|
|
11675
|
+
};
|
|
11676
|
+
if (!args.split) {
|
|
11677
|
+
const channel = memoryChannel(role);
|
|
11678
|
+
channel.parent.onMessage(message => receive(message, () => channel.parent.stop(false)));
|
|
11679
|
+
const running = upRoleCommand(args.context, { start: args.start(1), port: channel.port }).catch(error => {
|
|
11680
|
+
fatal = describe(error);
|
|
11681
|
+
return EXIT.failed;
|
|
11682
|
+
});
|
|
11683
|
+
const done = running.then(code => {
|
|
11684
|
+
livePid = null;
|
|
11685
|
+
if (!generations.wasReady())
|
|
11686
|
+
resolveReady({ ok: false, ...(failed ?? { reason: "start", detail: fatal ?? `the ${role} stopped while starting (exit ${code})` }) });
|
|
11687
|
+
else
|
|
11688
|
+
endForGood(fatal ?? `the ${role} stopped (exit ${code})`);
|
|
11689
|
+
return { problem: closedProblem };
|
|
11690
|
+
});
|
|
11691
|
+
return {
|
|
11692
|
+
ready, done,
|
|
11693
|
+
send: message => channel.parent.send(message),
|
|
11694
|
+
stop: () => { stopping = true; channel.parent.stop(false); },
|
|
11695
|
+
hardStop: () => { channel.parent.stop(true); },
|
|
11696
|
+
pid: () => livePid,
|
|
11697
|
+
};
|
|
11698
|
+
}
|
|
11699
|
+
const controller = new AbortController();
|
|
11700
|
+
let child = null;
|
|
11701
|
+
const command = upRoleChildCommand(role);
|
|
11702
|
+
const supervised = superviseController({
|
|
11703
|
+
file: command.file, argv: command.argv, signal: controller.signal, ipc: true, shutdownMs: args.shutdownMs,
|
|
11704
|
+
// Each role's own output carries its name; a JSON log stays one JSON object a line.
|
|
11705
|
+
label: envValue(process.env, "LOG_FORMAT") === "json" ? "" : `[${role}]`,
|
|
11706
|
+
onSpawn: (owned, generation) => {
|
|
11707
|
+
child = owned;
|
|
11708
|
+
fatal = null;
|
|
11709
|
+
failed = null;
|
|
11710
|
+
generations.spawned();
|
|
11711
|
+
owned.on("message", raw => receive(raw, () => controller.abort()));
|
|
11712
|
+
owned.on("error", () => { });
|
|
11713
|
+
// The start, including the runner token for the worker, goes over the private channel only.
|
|
11714
|
+
owned.send(args.start(generation), error => { if (error !== null && error !== undefined)
|
|
11715
|
+
owned.kill("SIGTERM"); });
|
|
11716
|
+
},
|
|
11717
|
+
onState: state => {
|
|
11718
|
+
if (state.phase !== "backoff")
|
|
11719
|
+
return;
|
|
11720
|
+
child = null;
|
|
11721
|
+
livePid = null;
|
|
11722
|
+
const exit = state.exit?.signal ?? state.exit?.code ?? "unknown";
|
|
11723
|
+
if (stopping && generations.wasReady())
|
|
11724
|
+
return;
|
|
11725
|
+
const verdict = generations.ended();
|
|
11726
|
+
if (verdict === "fail-start") {
|
|
11727
|
+
// A role that never became ready is not retried: the start fails as one, as before the split.
|
|
11728
|
+
controller.abort();
|
|
11729
|
+
resolveReady({ ok: false, ...(failed ?? { reason: "start", detail: fatal ?? `the ${role} stopped while starting (exit ${exit})` }) });
|
|
11730
|
+
return;
|
|
11731
|
+
}
|
|
11732
|
+
if (verdict === "end") {
|
|
11733
|
+
// A replacement that refused its start (another build or database), or kept ending before it was ready: a
|
|
11734
|
+
// restart can't fix that, so the whole `up` stops instead of looping with the other role still running.
|
|
11735
|
+
controller.abort();
|
|
11736
|
+
endForGood(generations.refusedWith() ?? fatal ?? `the ${role} kept stopping before it was ready (exit ${exit})`);
|
|
11737
|
+
return;
|
|
11738
|
+
}
|
|
11739
|
+
// A build mismatch already aborted: that is the end, not a restart.
|
|
11740
|
+
if (controller.signal.aborted) {
|
|
11741
|
+
endForGood(fatal ?? `the ${role} stopped (exit ${exit})`);
|
|
11742
|
+
return;
|
|
11743
|
+
}
|
|
11744
|
+
args.onEnded(`${fatal === null ? "" : `${fatal} — `}the ${role} stopped unexpectedly (exit ${exit})`, true);
|
|
11745
|
+
},
|
|
11746
|
+
}).catch(error => { if (!generations.wasReady())
|
|
11747
|
+
resolveReady({ ok: false, reason: "start", detail: describe(error) }); });
|
|
11748
|
+
const done = supervised.then(() => {
|
|
11749
|
+
if (!generations.wasReady())
|
|
11750
|
+
resolveReady({ ok: false, ...(failed ?? { reason: "start", detail: fatal ?? `the ${role} did not start` }) });
|
|
11751
|
+
// The supervisor ended without `up` stopping it (an abort on a refusal emits no backoff): `up` hears it either way.
|
|
11752
|
+
else
|
|
11753
|
+
endForGood(generations.refusedWith() ?? fatal ?? `the ${role} stopped`);
|
|
11754
|
+
return { problem: closedProblem };
|
|
11755
|
+
});
|
|
11756
|
+
return {
|
|
11757
|
+
ready, done,
|
|
11758
|
+
send: message => {
|
|
11759
|
+
const owned = child;
|
|
11760
|
+
if (owned !== null && owned.connected) {
|
|
11761
|
+
try {
|
|
11762
|
+
owned.send(message, () => { });
|
|
11763
|
+
}
|
|
11764
|
+
catch { /* it is ending; the supervisor sees it */ }
|
|
11765
|
+
}
|
|
11766
|
+
},
|
|
11767
|
+
// The supervisor's stop is SIGTERM, which a role takes as "drain"; past shutdownMs its whole tree is stopped.
|
|
11768
|
+
stop: () => { stopping = true; controller.abort(); },
|
|
11769
|
+
hardStop: () => {
|
|
11770
|
+
const owned = child;
|
|
11771
|
+
if (owned !== null && owned.connected) {
|
|
11772
|
+
try {
|
|
11773
|
+
owned.send({ type: "hard-stop" }, () => { });
|
|
11774
|
+
}
|
|
11775
|
+
catch { /* ending */ }
|
|
11776
|
+
}
|
|
11777
|
+
},
|
|
11778
|
+
pid: () => livePid,
|
|
11779
|
+
};
|
|
11780
|
+
}
|
|
11781
|
+
/** A role child's whole run (up-role-child.ts): the ordinary command prelude (containment, secrets, limits), then the
|
|
11782
|
+
* role itself. Only a process started by `up` gets here; no command line selects a role. */
|
|
11783
|
+
export async function runUpRole(start, port) {
|
|
11784
|
+
return runOperate("up", ["--db", start.databaseFile, ...(start.containment === null ? [] : ["--containment", start.containment])], line => port.send({ type: "log", line }), { upRole: { start, port } });
|
|
11785
|
+
}
|
|
11786
|
+
async function upRoleCommand(context, host) {
|
|
11787
|
+
const { start, port } = host;
|
|
11788
|
+
// A role runs exactly this build, on exactly this database: anything else ends it before it touches either.
|
|
11789
|
+
if (start.build !== upBuildIdentity()) {
|
|
11790
|
+
port.send({ type: "failed", reason: "start", detail: `this ${start.role} is a different build (${upBuildIdentity()}) from the \`up\` that started it (${start.build})` });
|
|
11791
|
+
return EXIT.refused;
|
|
11792
|
+
}
|
|
11793
|
+
if (resolve(start.databaseFile) !== resolve(context.databaseFile)) {
|
|
11794
|
+
port.send({ type: "failed", reason: "start", detail: `the ${start.role} opened a different database` });
|
|
11795
|
+
return EXIT.refused;
|
|
11796
|
+
}
|
|
11797
|
+
return start.role === "console" ? runUpConsoleRole(context, start.config, port, start.build) : runUpWorkerRole(context, start.config, port, start.build);
|
|
11798
|
+
}
|
|
11799
|
+
/** The console role: HTTP only — serving, reading, and the person's ceremonies. It never holds the runner token. */
|
|
11800
|
+
async function runUpConsoleRole(context, config, port, build) {
|
|
11801
|
+
const { store, clock } = context;
|
|
11802
|
+
const progress = (line) => port.send({ type: "log", line });
|
|
11803
|
+
// Mutated only by the worker's word (relayed by `up`): the console's project rail and unified chat follow the live
|
|
11804
|
+
// builder instead of freezing at process start.
|
|
11805
|
+
const activeRepos = new Set(config.repos);
|
|
11806
|
+
// Cockpit B1: this start's instance secret, minted once it is listening; the console answers its challenges with it.
|
|
11807
|
+
let instanceSecret = null;
|
|
11808
|
+
let console_;
|
|
11809
|
+
try {
|
|
11810
|
+
console_ = await startConsole({
|
|
11811
|
+
instanceSecret: () => instanceSecret,
|
|
11812
|
+
context,
|
|
11813
|
+
host: config.host,
|
|
11814
|
+
port: config.port,
|
|
11815
|
+
...(config.allowedHosts === null ? {} : { allowedHosts: config.allowedHosts }),
|
|
11816
|
+
...(config.publicUrl === null ? {} : { publicUrl: config.publicUrl }),
|
|
11817
|
+
localRunner: config.localRunner,
|
|
11818
|
+
poolRoot: config.poolRoot,
|
|
11819
|
+
repos: config.repos,
|
|
11820
|
+
currentRepos: () => [...activeRepos],
|
|
11821
|
+
admittedRepos: () => [...activeRepos],
|
|
11822
|
+
upConsole: true,
|
|
11823
|
+
registryPath: config.registryPath,
|
|
11824
|
+
projectRoots: config.projectRoots,
|
|
11825
|
+
...(config.editor ? { editorLinks: "vscode" } : {}),
|
|
11826
|
+
});
|
|
11827
|
+
}
|
|
11828
|
+
catch (error) {
|
|
11829
|
+
port.send({ type: "failed", reason: "port-busy", detail: describe(error) });
|
|
11830
|
+
return EXIT.refused;
|
|
11831
|
+
}
|
|
11832
|
+
let codingClosed = false;
|
|
11833
|
+
const closeCoding = () => {
|
|
11834
|
+
if (codingClosed)
|
|
11835
|
+
return;
|
|
11836
|
+
codingClosed = true;
|
|
11837
|
+
console_.server.closeCoding().catch(() => { });
|
|
11838
|
+
};
|
|
11839
|
+
let resolveStopped = () => { };
|
|
11840
|
+
const stopped = new Promise(resolveStop => (resolveStopped = resolveStop));
|
|
11841
|
+
port.onStop(() => resolveStopped());
|
|
11842
|
+
port.onMessage(message => {
|
|
11843
|
+
if (message.type === "repos") {
|
|
11844
|
+
activeRepos.clear();
|
|
11845
|
+
for (const repo of message.repos)
|
|
11846
|
+
activeRepos.add(repo);
|
|
11847
|
+
}
|
|
11848
|
+
else if (message.type === "stopping")
|
|
11849
|
+
closeCoding();
|
|
11850
|
+
else if (message.type === "sign-in-link") {
|
|
11851
|
+
let link = null;
|
|
11852
|
+
try {
|
|
11853
|
+
link = console_.server.mintSignInLink(message.account);
|
|
11854
|
+
}
|
|
11855
|
+
catch {
|
|
11856
|
+
link = null;
|
|
11857
|
+
}
|
|
11858
|
+
port.send({ type: "sign-in-link", id: message.id, link });
|
|
11859
|
+
}
|
|
11860
|
+
});
|
|
11861
|
+
// Cockpit B1: who this console is, for bare `toolroll` to prove before it attaches: a fresh secret in the database
|
|
11862
|
+
// (behind the agent fence), and a discovery-only socket beside it. A socket that can't open only means attaching
|
|
11863
|
+
// isn't offered; the console runs either way. The socket is taken FIRST: only the console that owns it publishes a
|
|
11864
|
+
// record. A second console on the same database (another port) never replaces the first one's record.
|
|
11865
|
+
let mintedSecret = null;
|
|
11866
|
+
const instanceSocket = await openInstanceSocket(context.databaseFile, { pid: process.pid, build: packageVersion(), database: context.databaseFile, port: console_.port }, () => mintedSecret);
|
|
11867
|
+
if (instanceSocket.ok) {
|
|
11868
|
+
mintedSecret = await claimInstance(store, context.databaseFile, { pid: process.pid, port: console_.port, build: packageVersion(), socket: instanceSocket.name }, clock());
|
|
11869
|
+
instanceSecret = mintedSecret;
|
|
11870
|
+
if (mintedSecret === null)
|
|
11871
|
+
progress("attach: another console is running on this database — `toolroll` in a repository opens that one");
|
|
11872
|
+
}
|
|
11873
|
+
else
|
|
11874
|
+
progress(`attach: ${instanceSocket.problem} — \`toolroll\` in a repository won't find this console`);
|
|
11875
|
+
port.send({ type: "ready", role: "console", build, pid: process.pid, url: console_.url, port: console_.port });
|
|
11876
|
+
await stopped;
|
|
11877
|
+
closeCoding();
|
|
11878
|
+
if (instanceSocket.ok)
|
|
11879
|
+
instanceSocket.close();
|
|
11880
|
+
if (mintedSecret !== null)
|
|
11881
|
+
clearInstance(store, mintedSecret);
|
|
11882
|
+
let closeProblem = null;
|
|
11883
|
+
await new Promise(done => console_.server.close(error => { closeProblem = error === undefined || error === null ? null : error.message; done(); }));
|
|
11884
|
+
port.send({ type: "closed", problem: closeProblem });
|
|
11885
|
+
return closeProblem === null ? EXIT.ok : EXIT.failed;
|
|
11886
|
+
}
|
|
11887
|
+
/** The worker role: one watch loop per proved repository (builds, checks, batch checks, follow-ups, reconcilers, the
|
|
11888
|
+
* storage sweep and checkout sizing), the project registry, and the service's one Telegram follower. It opens no
|
|
11889
|
+
* port and mints no console secret. */
|
|
11890
|
+
async function runUpWorkerRole(context, config, port, build) {
|
|
11891
|
+
const { store, clock } = context;
|
|
11892
|
+
const progress = (line) => port.send({ type: "log", line });
|
|
11893
|
+
const runnerName = config.runner;
|
|
11894
|
+
const runnerToken = config.token;
|
|
11895
|
+
const pool = config.pool;
|
|
11896
|
+
const registryPath = config.registryPath;
|
|
11897
|
+
const pinnedRepos = new Set(config.pinnedRepos);
|
|
11898
|
+
const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
|
|
11899
|
+
const proveRepo = async (input) => {
|
|
11900
|
+
const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd: resolve(input) });
|
|
11901
|
+
if (top.code !== 0)
|
|
11902
|
+
return null;
|
|
11903
|
+
try {
|
|
11904
|
+
return realpathSync(top.stdout.trim());
|
|
11905
|
+
}
|
|
11906
|
+
catch {
|
|
11907
|
+
return null;
|
|
11908
|
+
}
|
|
11909
|
+
};
|
|
11910
|
+
// The proved set this worker builds; `up` relays each change to the console.
|
|
11911
|
+
const activeRepos = new Set(config.repos);
|
|
11912
|
+
const reportRepos = () => port.send({ type: "repos", repos: [...activeRepos] });
|
|
11913
|
+
// The historical signal shell: the first stop ends admission and starts the grace clock; the second — or the
|
|
11914
|
+
// clock — stops every live provider group (M6.12).
|
|
11915
|
+
let stopping = false;
|
|
11916
|
+
let fatal = null;
|
|
11917
|
+
let graceTimer;
|
|
11918
|
+
const followControllers = [];
|
|
11919
|
+
let resolveStopped = () => { };
|
|
11920
|
+
const stopped = new Promise(resolveStop => (resolveStopped = resolveStop));
|
|
11921
|
+
const hardStop = () => {
|
|
11922
|
+
const terminated = terminateLiveProviders();
|
|
11923
|
+
if (terminated > 0)
|
|
11924
|
+
progress(`Stopped ${terminated} running build(s). Their work is saved, and each resumes when the builder starts again.`);
|
|
11925
|
+
};
|
|
11926
|
+
const stop = () => {
|
|
11927
|
+
if (stopping) {
|
|
11928
|
+
hardStop();
|
|
11929
|
+
return;
|
|
11930
|
+
}
|
|
11931
|
+
stopping = true;
|
|
11932
|
+
resolveStopped();
|
|
11933
|
+
for (const controller of followControllers)
|
|
11934
|
+
controller.abort();
|
|
11935
|
+
const building = store.liveClaimCount(runnerName, clock());
|
|
11936
|
+
if (building > 0) {
|
|
11937
|
+
progress(`Stopping: ${building} build(s) running. Waiting up to ${Math.round(config.stopGraceMs / 1000)}s for them to finish; any still running then is saved and resumes when the builder starts again.`);
|
|
11938
|
+
}
|
|
11939
|
+
graceTimer = setTimeout(hardStop, config.stopGraceMs);
|
|
11940
|
+
graceTimer.unref?.();
|
|
11941
|
+
};
|
|
11942
|
+
port.onStop(hard => {
|
|
11943
|
+
if (!stopping)
|
|
11944
|
+
stop();
|
|
11945
|
+
if (hard)
|
|
11946
|
+
hardStop();
|
|
11947
|
+
});
|
|
11948
|
+
const loopFlagsFor = (repo) => {
|
|
11949
|
+
const copy = new Map();
|
|
11950
|
+
// A bounded `up` must not advertise its child watches as an unbounded sweep service either.
|
|
11951
|
+
if (config.forMs !== null)
|
|
11952
|
+
copy.set("for", String(config.forMs));
|
|
11953
|
+
copy.set("runner", runnerName);
|
|
11954
|
+
copy.set("token", runnerToken);
|
|
11955
|
+
copy.set("repo", repo);
|
|
11956
|
+
copy.set("pool", pool);
|
|
11957
|
+
// The co-hosted console's stated origin rides into each loop's follower: a phone link must name THIS console, or not exist.
|
|
11958
|
+
if (config.publicUrl !== null)
|
|
11959
|
+
copy.set("public-url", config.publicUrl);
|
|
11960
|
+
return copy;
|
|
11961
|
+
};
|
|
11962
|
+
const prefix = (repo) => (activeRepos.size > 1 ? `[${projectName(repo)}] ` : "");
|
|
11963
|
+
// Containment at start: one plain sentence, once, for a person at a terminal; the exact status line with --verbose,
|
|
11964
|
+
// in a service log (no terminal), and always when a required policy will refuse spawns.
|
|
11965
|
+
const containmentDetail = config.verbose || !config.terminal;
|
|
11966
|
+
let containmentSaid = false;
|
|
11967
|
+
const containmentLines = (effective) => {
|
|
11968
|
+
const notice = containmentNotice(effective);
|
|
11969
|
+
if (notice !== null && !containmentSaid)
|
|
11970
|
+
progress(notice);
|
|
11971
|
+
containmentSaid = true;
|
|
11972
|
+
return containmentDetail || effective.refusal !== null ? [`watch: ${describeContainment(effective)}`] : [];
|
|
11973
|
+
};
|
|
11974
|
+
const loopResults = new Map();
|
|
11975
|
+
// One record of project passes for the whole service: fair scheduling across its projects (project-concurrency.ts).
|
|
11976
|
+
const projectPasses = new ProjectPasses();
|
|
11977
|
+
// Projects removed while running: each one's watch stops admitting work, finishes what it holds, then lets go.
|
|
11978
|
+
const releasedRepos = new Set();
|
|
11979
|
+
const launchRepo = async (repo, bind, drain = false) => {
|
|
11980
|
+
if (loopResults.has(repo) || stopping)
|
|
11981
|
+
return;
|
|
11982
|
+
// A predecessor's watch on a project this start doesn't name (it was being removed): take it over, recovering what
|
|
11983
|
+
// the predecessor held, and let it go (round 1, finding 2).
|
|
11984
|
+
if (drain)
|
|
11985
|
+
releasedRepos.add(repo);
|
|
11986
|
+
if (bind) {
|
|
11987
|
+
const bound = addRunnerReposAuthed(store, { name: runnerName, token: runnerToken, repos: [repo] }, clock());
|
|
11988
|
+
if (!bound.ok) {
|
|
11989
|
+
fatal = `the builder identity changed while adding ${repo} (${bound.reason})`;
|
|
11990
|
+
stop();
|
|
11991
|
+
return;
|
|
11992
|
+
}
|
|
11993
|
+
activeRepos.add(repo);
|
|
11994
|
+
reportRepos();
|
|
11995
|
+
if (!store.listProjects().some(one => one.path === repo)) {
|
|
11996
|
+
store.upsertProject(repo, projectName(repo), clock());
|
|
11997
|
+
}
|
|
11998
|
+
}
|
|
11999
|
+
let markReady = () => { };
|
|
12000
|
+
const ready = new Promise(resolveReady => (markReady = resolveReady));
|
|
12001
|
+
const loop = runWatchLoop({
|
|
12002
|
+
flags: loopFlagsFor(repo),
|
|
12003
|
+
context,
|
|
12004
|
+
runner: runnerName,
|
|
12005
|
+
token: runnerToken,
|
|
12006
|
+
repo,
|
|
12007
|
+
progress: line => progress(`${prefix(repo)}${line}`),
|
|
12008
|
+
isStopping: () => stopping || releasedRepos.has(repo),
|
|
12009
|
+
onFollowController: controller => followControllers.push(controller),
|
|
12010
|
+
containmentLines,
|
|
12011
|
+
passes: projectPasses,
|
|
12012
|
+
serviceBridge: true,
|
|
12013
|
+
leaseWaitMs: config.leaseWaitMs,
|
|
12014
|
+
...(drain ? { leaseWaitStopping: () => stopping } : {}),
|
|
12015
|
+
onReady: () => {
|
|
12016
|
+
markReady();
|
|
12017
|
+
if (bind)
|
|
12018
|
+
progress(`${prefix(repo)}builder connected — queued work can start`);
|
|
12019
|
+
},
|
|
12020
|
+
}).then(result => ({ repo, result }), error => ({ repo, result: { ok: false, reason: "loop-failed", detail: describe(error), ticks: 0, built: 0, broke: 0 } }));
|
|
12021
|
+
loopResults.set(repo, loop);
|
|
12022
|
+
void loop.then(({ repo: endedRepo, result }) => {
|
|
12023
|
+
if (releasedRepos.has(endedRepo) && !stopping && !result.ok) {
|
|
12024
|
+
// A removed project's watch that never held its lease (or lost it) has recovered nothing: keep the project
|
|
12025
|
+
// bound so the next worker recovers it, and stop rather than strand its work (round 1, finding 2).
|
|
12026
|
+
fatal = `${prefix(endedRepo)}${result.detail}`;
|
|
12027
|
+
stop();
|
|
12028
|
+
markReady();
|
|
12029
|
+
return;
|
|
12030
|
+
}
|
|
12031
|
+
if (releasedRepos.has(endedRepo) && !stopping) {
|
|
12032
|
+
// A removed project's watch has stopped: only now does the builder let go of it.
|
|
12033
|
+
const unbound = removeRunnerRepoAuthed(store, { name: runnerName, token: runnerToken, repo: endedRepo }, clock());
|
|
12034
|
+
if (!unbound.ok) {
|
|
12035
|
+
fatal = `the builder identity changed while removing ${endedRepo} (${unbound.reason})`;
|
|
12036
|
+
stop();
|
|
12037
|
+
}
|
|
12038
|
+
loopResults.delete(endedRepo);
|
|
12039
|
+
releasedRepos.delete(endedRepo);
|
|
12040
|
+
progress(`project removed — ${projectName(endedRepo)} is no longer built here`);
|
|
12041
|
+
markReady();
|
|
12042
|
+
return;
|
|
12043
|
+
}
|
|
12044
|
+
// A loop that dies while its siblings live must not leave a partial cockpit standing silently (finding 5).
|
|
12045
|
+
if (!result.ok && !stopping) {
|
|
12046
|
+
fatal = `${prefix(endedRepo)}${result.detail}`;
|
|
12047
|
+
stop();
|
|
12048
|
+
}
|
|
12049
|
+
markReady(); // acquisition failures must not strand startup readiness
|
|
12050
|
+
});
|
|
12051
|
+
await ready;
|
|
12052
|
+
};
|
|
12053
|
+
// Before any new work: every watch a predecessor left needing recovery on a project this start doesn't name — one
|
|
12054
|
+
// being removed when it died — is taken over (its incarnation recovered in that step) and let go. Only projects the
|
|
12055
|
+
// store still binds to this runner; the start's own projects recover in their own loops below (round 1, finding 2).
|
|
12056
|
+
const named = new Set(config.repos);
|
|
12057
|
+
const boundNow = new Set(store.getRunner(runnerName)?.runner.repos ?? []);
|
|
12058
|
+
const draining = [...new Set(predecessorWatches(store, runnerName, clock()).map(one => one.repo))].filter(repo => !named.has(repo) && boundNow.has(repo));
|
|
12059
|
+
for (const repo of draining)
|
|
12060
|
+
progress(`${projectName(repo)} was being removed when the previous worker stopped: recovering its work first`);
|
|
12061
|
+
await Promise.all(draining.map(repo => launchRepo(repo, false, true)));
|
|
12062
|
+
await Promise.all(config.repos.map(repo => launchRepo(repo, false)));
|
|
12063
|
+
// Telegram: one follower for the whole service, never one per project loop. Replies and notifications go out from
|
|
12064
|
+
// it, over the same transport in the same cycle. A token saved while the service runs connects within seconds; a
|
|
12065
|
+
// follower that dies is started again.
|
|
12066
|
+
const telegramController = new AbortController();
|
|
12067
|
+
followControllers.push(telegramController);
|
|
12068
|
+
if (stopping)
|
|
12069
|
+
telegramController.abort();
|
|
12070
|
+
const telegramSupervisor = (async () => {
|
|
12071
|
+
const pause = (ms) => new Promise(resolvePause => {
|
|
12072
|
+
const timer = setTimeout(done, ms);
|
|
12073
|
+
function done() { clearTimeout(timer); telegramController.signal.removeEventListener("abort", done); resolvePause(); }
|
|
12074
|
+
// An aborted signal never fires again: without this a stop waited the whole pause.
|
|
12075
|
+
if (telegramController.signal.aborted)
|
|
12076
|
+
done();
|
|
12077
|
+
else
|
|
12078
|
+
telegramController.signal.addEventListener("abort", done, { once: true });
|
|
12079
|
+
});
|
|
12080
|
+
while (!stopping && !telegramController.signal.aborted) {
|
|
12081
|
+
const follower = startTelegramFollower({
|
|
12082
|
+
context, publicUrl: config.publicUrl ?? undefined, signal: telegramController.signal, progress, label: "telegram:",
|
|
12083
|
+
died: { prefix: "", then: "starting it again" }, owner: `service-${randomUUID()}`,
|
|
12084
|
+
});
|
|
12085
|
+
if (follower !== null)
|
|
12086
|
+
await follower;
|
|
12087
|
+
// Without a token (or on a fenced network) look again shortly; the fence line is said once.
|
|
12088
|
+
if (follower === null && loadBotToken(process.env, context.telegramTokenFile) !== null)
|
|
12089
|
+
break;
|
|
12090
|
+
await pause(follower === null ? 2_000 : 5_000);
|
|
12091
|
+
}
|
|
12092
|
+
})();
|
|
12093
|
+
const dynamicCeiling = resolveCeiling(config.repos, config.projectRoots).ceiling;
|
|
12094
|
+
const rejected = new Set();
|
|
12095
|
+
const registrySupervisor = (async () => {
|
|
12096
|
+
const sleep = (ms) => new Promise(resolveSleep => setTimeout(resolveSleep, ms));
|
|
12097
|
+
while (!stopping) {
|
|
12098
|
+
const loaded = await loadProjectRegistry(registryPath);
|
|
12099
|
+
if ("error" in loaded) {
|
|
12100
|
+
fatal = loaded.error;
|
|
12101
|
+
stop();
|
|
12102
|
+
break;
|
|
12103
|
+
}
|
|
12104
|
+
// Removed from the saved list: stop building it and take it out of the console. A pinned start keeps its own.
|
|
12105
|
+
const saved = new Set(loaded.repos.map(one => canonicalProject(one) ?? one));
|
|
12106
|
+
let removed = false;
|
|
12107
|
+
for (const repo of [...activeRepos]) {
|
|
12108
|
+
if (stopping || pinnedRepos.has(repo) || saved.has(repo))
|
|
12109
|
+
continue;
|
|
12110
|
+
activeRepos.delete(repo);
|
|
12111
|
+
removed = true;
|
|
12112
|
+
releasedRepos.add(repo);
|
|
12113
|
+
if (!loopResults.has(repo))
|
|
12114
|
+
releasedRepos.delete(repo);
|
|
12115
|
+
}
|
|
12116
|
+
if (removed)
|
|
12117
|
+
reportRepos();
|
|
12118
|
+
for (const candidate of loaded.repos) {
|
|
12119
|
+
if (stopping)
|
|
12120
|
+
break;
|
|
12121
|
+
const canonical = canonicalProject(candidate);
|
|
12122
|
+
// Still letting go of an earlier removal: it rejoins on the next pass after its watch has stopped.
|
|
12123
|
+
if (canonical !== null && (activeRepos.has(canonical) || releasedRepos.has(canonical)))
|
|
12124
|
+
continue;
|
|
12125
|
+
const approvedAdditions = context.additionalProjectRepos?.() ?? [];
|
|
12126
|
+
const admittedCeiling = resolveCeiling([...dynamicCeiling.repos, ...approvedAdditions], dynamicCeiling.roots).ceiling;
|
|
12127
|
+
// A project added after this start (`repos add`, the lead, the console) carries its recorded admission; a
|
|
12128
|
+
// registry line alone is not authority. Either way the folder is proved to be that Git checkout again.
|
|
12129
|
+
const allowed = canonical !== null &&
|
|
12130
|
+
((await authorizedProject(admittedCeiling, canonical)) || admissionRecorded(store, canonical)) &&
|
|
12131
|
+
(await proveRepo(canonical)) === canonical;
|
|
12132
|
+
if (!allowed) {
|
|
12133
|
+
if (!rejected.has(candidate)) {
|
|
12134
|
+
rejected.add(candidate);
|
|
12135
|
+
progress(`project not connected — ${candidate} isn't a Git repository added with \`toolroll repos add\` or the console`);
|
|
12136
|
+
}
|
|
12137
|
+
continue;
|
|
12138
|
+
}
|
|
12139
|
+
rejected.delete(candidate);
|
|
12140
|
+
await launchRepo(canonical, true);
|
|
12141
|
+
}
|
|
12142
|
+
if (!stopping)
|
|
12143
|
+
await sleep(500);
|
|
12144
|
+
}
|
|
12145
|
+
})().catch(error => {
|
|
12146
|
+
fatal = `the project registry stopped (${describe(error)})`;
|
|
12147
|
+
stop();
|
|
12148
|
+
});
|
|
12149
|
+
// Provider readiness (v47): this machine's non-spending observations, recorded under its own runner name ONCE at
|
|
12150
|
+
// start — so every route projection can say ready / unavailable / unknown per provider, and the dispatch gate can
|
|
12151
|
+
// halt a leg this machine cannot run. Production only: a test's injected agent runner is a fake, and its readiness
|
|
12152
|
+
// would be a fiction.
|
|
12153
|
+
if (context.agentRunner === undefined) {
|
|
12154
|
+
void observeProviderReadiness((file, args, options) => run(file, args, { timeoutMs: options?.timeoutMs ?? 5_000, ...(options?.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) }), process.env, PROVIDER_IDS, provider => store.acpAttestationFor(provider, runnerName))
|
|
12155
|
+
.then(observed => {
|
|
12156
|
+
if (!stopping)
|
|
12157
|
+
reportProviderReadinessAuthed(store, { name: runnerName, token: runnerToken, observations: observed }, clock());
|
|
12158
|
+
})
|
|
12159
|
+
.catch(() => {
|
|
12160
|
+
// A probe that cannot run leaves readiness unknown — never a fiction.
|
|
12161
|
+
});
|
|
12162
|
+
}
|
|
12163
|
+
try {
|
|
12164
|
+
notifyVersionLive(context.store, dirname(context.databaseFile), PACKAGE_VERSION, clock());
|
|
12165
|
+
}
|
|
12166
|
+
catch { /* A display fact; never blocks the worker. */ }
|
|
12167
|
+
recordRunnerVersion(dirname(context.databaseFile), runnerName, PACKAGE_VERSION, clock());
|
|
12168
|
+
// Ready once every project's loop holds its lease; a loop that failed to start ends this worker instead.
|
|
12169
|
+
if (fatal === null && !stopping)
|
|
12170
|
+
port.send({ type: "ready", role: "worker", build, pid: process.pid, url: null, port: null });
|
|
12171
|
+
await stopped;
|
|
12172
|
+
await registrySupervisor;
|
|
12173
|
+
await Promise.all([...loopResults.values()]);
|
|
12174
|
+
// The follower was aborted with the stop. A send or a chat turn it is still finishing keeps its own durable record;
|
|
12175
|
+
// the service does not wait past this for it.
|
|
12176
|
+
await new Promise(done => {
|
|
12177
|
+
const timer = setTimeout(done, TELEGRAM_STOP_WAIT_MS);
|
|
12178
|
+
const finish = () => { clearTimeout(timer); done(); };
|
|
12179
|
+
void telegramSupervisor.then(finish, finish);
|
|
12180
|
+
});
|
|
12181
|
+
if (graceTimer !== undefined)
|
|
12182
|
+
clearTimeout(graceTimer);
|
|
12183
|
+
if (fatal !== null) {
|
|
12184
|
+
port.send({ type: "fatal", detail: fatal });
|
|
12185
|
+
return EXIT.failed;
|
|
12186
|
+
}
|
|
12187
|
+
return EXIT.ok;
|
|
12188
|
+
}
|