@runuai/host 0.9.149 → 0.9.151
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/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1876 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1847 -236
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +623 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1062 -140
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §13 on the runner: the activation authority. A runner moves from
|
|
3
|
+
* its own credentials (`legacy`) to cloud ones once, and never back.
|
|
4
|
+
*
|
|
5
|
+
* 1. *Prepare*, changing nothing that cannot be undone. The cloud agrees
|
|
6
|
+
* (`credentials.activate`, phase `prepare`, which the cloud records: from
|
|
7
|
+
* here it honors this runner's commit whatever its switch says later).
|
|
8
|
+
* The runner admits no new start on its own credentials and waits for the
|
|
9
|
+
* starts under way, and plans the retirement of every task runtime
|
|
10
|
+
* (finding every blocker known in advance:
|
|
11
|
+
* lib/legacy-runtime-retirement.ts). A refusal or a blocker admits starts
|
|
12
|
+
* again, on v0, with every runtime and store as it was; the next attempt
|
|
13
|
+
* prepares again.
|
|
14
|
+
* 2. *Local commit*, from which it only goes forward. The durable record
|
|
15
|
+
* `local`, written under the managed-operation lock once the runner's
|
|
16
|
+
* next boot is proven a release that reads it and standalone recovery
|
|
17
|
+
* reads it too (managed-runtime.ts commitCredentialsSwitchLocally): from
|
|
18
|
+
* it, no release built before the cutover runs here again, neither
|
|
19
|
+
* through an update or rollback nor through standalone recovery.
|
|
20
|
+
* Then leaving its own credentials (its refreshers, its v0 lease and every
|
|
21
|
+
* credential write admitted from the bridge stop and drain), the
|
|
22
|
+
* retirement of every task runtime (each task stops and its container
|
|
23
|
+
* goes with its worktree kept), and the credentials floor's own file. It
|
|
24
|
+
* is `pending` from the record on: it runs no task, and a restart resumes
|
|
25
|
+
* here.
|
|
26
|
+
* 3. *Cloud commit*: the cloud records the runner's v1 floor; record
|
|
27
|
+
* `cloud`.
|
|
28
|
+
* 4. *Purge*, then record `active`: the runner advertises v1, reports the
|
|
29
|
+
* purge (main.ts sends it on every connection and tick, since the cloud
|
|
30
|
+
* acknowledges none and keeps the first), and runs its tasks on cloud
|
|
31
|
+
* credentials.
|
|
32
|
+
*
|
|
33
|
+
* Every retired task is owed a stopped report at the exact run epoch it was
|
|
34
|
+
* stopped at, for the cloud may still show it running: the report is
|
|
35
|
+
* best-effort (no acknowledgement; the cloud drops one it fails to write),
|
|
36
|
+
* so the record keeps each owed report through every step, `active`
|
|
37
|
+
* included, and the runner sends them again on every attempt and tick until
|
|
38
|
+
* each is moot (a newer run of the task, or its row gone). The cloud ignores
|
|
39
|
+
* a report about a task that is not running at that epoch, so sending one
|
|
40
|
+
* again is harmless.
|
|
41
|
+
*
|
|
42
|
+
* Leaving its own credentials is one-way in a process: if that fails (a
|
|
43
|
+
* worker's stop threw), the process still retires every task runtime, so no
|
|
44
|
+
* engine goes on running on them, and then asks its supervisor for a
|
|
45
|
+
* restart; it comes back pending, with nothing of its own credentials
|
|
46
|
+
* started, and resumes the switch.
|
|
47
|
+
*
|
|
48
|
+
* Nothing irreversible runs on a record not known to be durable: written
|
|
49
|
+
* whole, renamed into place and its directory synced, in this process. One
|
|
50
|
+
* renamed into place whose directory could not then be synced is the record
|
|
51
|
+
* (it may well be on disk: the runner's own credentials do not resume), but
|
|
52
|
+
* a crash could still lose it, and the runner would boot back onto its own
|
|
53
|
+
* credentials after retiring its tasks. So the switch waits, pending, until
|
|
54
|
+
* the directory syncs. A record found at boot is synced before anything
|
|
55
|
+
* runs on it too: an earlier process may have left one it could not sync.
|
|
56
|
+
* What is synced is proven to be the record this process holds, exactly:
|
|
57
|
+
* one removed, replaced or unreadable since is written again from it.
|
|
58
|
+
*
|
|
59
|
+
* The runner says it is active (`onActive`, and `mode()`, which every v1
|
|
60
|
+
* surface follows: its advertisement, its lease frames, the purge report,
|
|
61
|
+
* task starts) only once its `active` record is durable and the recovery
|
|
62
|
+
* sweep of what the last process left (ADR-154 §5, §6) is done, so that no
|
|
63
|
+
* grant is asked for before it: at boot the container runtime's own pass
|
|
64
|
+
* sweeps (and should it give up, the authority, on every attempt), at the
|
|
65
|
+
* transition the authority does. Should saying so fail, the
|
|
66
|
+
* runner is pending again, withdraws what it advertised, and says so later.
|
|
67
|
+
*
|
|
68
|
+
* The record holds no secret: `{"schemaVersion":1,"step":"local",
|
|
69
|
+
* "stopped":[{"taskId":"…","runEpoch":3}]}`. One the runner cannot read
|
|
70
|
+
* counts as `local`, never as `legacy`: a damaged record may stand for a
|
|
71
|
+
* switch under way. That is why the local step leaves, plans the retirement
|
|
72
|
+
* and claims the floor itself (each idempotent, and a damaged record must
|
|
73
|
+
* not skip them); the reports a damaged record lost are owed again by that
|
|
74
|
+
* retirement, which finds the same runtimes gone.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
import {
|
|
78
|
+
closeSync,
|
|
79
|
+
constants,
|
|
80
|
+
fstatSync,
|
|
81
|
+
fsyncSync,
|
|
82
|
+
lstatSync,
|
|
83
|
+
openSync,
|
|
84
|
+
readFileSync,
|
|
85
|
+
renameSync,
|
|
86
|
+
writeFileSync,
|
|
87
|
+
} from "node:fs";
|
|
88
|
+
import { dirname } from "node:path";
|
|
89
|
+
|
|
90
|
+
import type { CredentialsActivationPhase } from "../src/cloud-credentials";
|
|
91
|
+
import type { CredentialsMode } from "./cloud-credential-host";
|
|
92
|
+
import type { RetirementPlan } from "./legacy-runtime-retirement";
|
|
93
|
+
import { isSafeHostTaskId } from "./task-identity";
|
|
94
|
+
|
|
95
|
+
export type ActivationStep = "local" | "cloud" | "active";
|
|
96
|
+
|
|
97
|
+
/** A retired task's stopped report: its exact run epoch, or null for a task
|
|
98
|
+
* with no assignment (it predates run epochs). */
|
|
99
|
+
export interface StoppedReport {
|
|
100
|
+
taskId: string;
|
|
101
|
+
runEpoch: number | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ActivationRecord {
|
|
105
|
+
step: ActivationStep;
|
|
106
|
+
/** Reports owed: the cloud may not have taken them yet. */
|
|
107
|
+
stopped: StoppedReport[];
|
|
108
|
+
/** The record on disk could not be read, and this one stands for it. */
|
|
109
|
+
damaged?: true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type ActivationAnswer = { ok: true } | { ok: false; error: string };
|
|
113
|
+
|
|
114
|
+
const RECORD_SCHEMA_VERSION = 1;
|
|
115
|
+
const STEPS: readonly ActivationStep[] = ["local", "cloud", "active"];
|
|
116
|
+
|
|
117
|
+
export interface ActivationAuthorityDeps {
|
|
118
|
+
/** The durable record (non-secret), beside the runner's database. */
|
|
119
|
+
recordPath: string;
|
|
120
|
+
/** The credentials floor's file exists: the switch began, even with no
|
|
121
|
+
* record to say so (managed-runtime.ts credentialsCapabilityFloorExists). */
|
|
122
|
+
floorClaimed: () => boolean;
|
|
123
|
+
/** Whether this runner moves to cloud credentials in this release. */
|
|
124
|
+
eligible: () => boolean;
|
|
125
|
+
/** One phase asked of the cloud. Rejects when it could not be asked or
|
|
126
|
+
* answered (no bridge, a timeout): the next attempt asks again. */
|
|
127
|
+
ask: (phase: CredentialsActivationPhase) => Promise<ActivationAnswer>;
|
|
128
|
+
/** Admit no new start on the runner's own credentials (cloud-credential-host). */
|
|
129
|
+
pauseLegacyStarts: () => { drained: Promise<void>; resume(): void };
|
|
130
|
+
/** The retirement's plan: changes nothing, rejects with every blocker. */
|
|
131
|
+
planRetirement: () => Promise<RetirementPlan>;
|
|
132
|
+
/** Under the managed-operation lock, prove the next boot reads the record
|
|
133
|
+
* and fence standalone recovery, then call `record` to write it
|
|
134
|
+
* (managed-runtime.ts commitCredentialsSwitchLocally). */
|
|
135
|
+
commitLocally: (record: () => void) => Promise<void>;
|
|
136
|
+
/** Claim the credentials floor: no older release runs here again. Idempotent. */
|
|
137
|
+
claimFloor: () => Promise<void> | void;
|
|
138
|
+
/** Leave the runner's own credentials (cloud-credential-host). */
|
|
139
|
+
leaveLegacy: () => Promise<void>;
|
|
140
|
+
/** Carry the plan out (idempotent). `stopped` names each task whose
|
|
141
|
+
* runtime is gone, at its exact run epoch; `problem` says what could not
|
|
142
|
+
* be proven removed. */
|
|
143
|
+
retire: (plan: RetirementPlan) => Promise<{ stopped: StoppedReport[]; problem: string | null }>;
|
|
144
|
+
/** Send each owed report again; returns those still owed (the moot ones
|
|
145
|
+
* dropped: a newer run of the task, or its row gone). */
|
|
146
|
+
reportStopped: (owed: readonly StoppedReport[]) => StoppedReport[];
|
|
147
|
+
/** Delete every runner-local credential store; rejects unless all went. */
|
|
148
|
+
purge: () => Promise<void>;
|
|
149
|
+
/** The recovery sweep has succeeded in this process
|
|
150
|
+
* (cloud-credential-host.ts cloudCredentialsSwept). */
|
|
151
|
+
swept: () => boolean;
|
|
152
|
+
/** Run the recovery sweep, or join the one under way; rejects when it
|
|
153
|
+
* fails (recoverCloudCredentials). */
|
|
154
|
+
sweep: () => Promise<void>;
|
|
155
|
+
/** The runner's tasks now run on cloud credentials: at the transition, or
|
|
156
|
+
* at a boot that found the record `active` (never both in one process).
|
|
157
|
+
* `mode()` is `active` while it runs. May throw: the authority is then
|
|
158
|
+
* pending again, calls `onActiveWithdrawn`, and calls this again later. */
|
|
159
|
+
onActive: (how: "transition" | "boot") => void;
|
|
160
|
+
/** Saying the runner is active failed and it is pending again: withdraw
|
|
161
|
+
* what was advertised. */
|
|
162
|
+
onActiveWithdrawn: () => void;
|
|
163
|
+
/** Ask the runner's supervisor for a restart. */
|
|
164
|
+
restart: (why: string) => void;
|
|
165
|
+
log: (line: string) => void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class CredentialsActivationAuthority {
|
|
169
|
+
readonly #deps: ActivationAuthorityDeps;
|
|
170
|
+
#record: ActivationRecord | null;
|
|
171
|
+
/** `#record` is known to be durable (see the header). */
|
|
172
|
+
#durable = false;
|
|
173
|
+
/** `onActive` has run, and returned, in this process. */
|
|
174
|
+
#announced = false;
|
|
175
|
+
/** `onActive` is running. */
|
|
176
|
+
#announcing = false;
|
|
177
|
+
/** The container runtime's own recovery gave up: the sweep is this
|
|
178
|
+
* authority's to retry (`sweepItself`). */
|
|
179
|
+
#sweepsItself = false;
|
|
180
|
+
/** The record was `active` when this process started. */
|
|
181
|
+
readonly #activeAtBoot: boolean;
|
|
182
|
+
#advancing: Promise<void> | null = null;
|
|
183
|
+
#lastNote: string | null = null;
|
|
184
|
+
/** Leaving the runner's own credentials failed in this process. */
|
|
185
|
+
#stuck = false;
|
|
186
|
+
|
|
187
|
+
constructor(deps: ActivationAuthorityDeps) {
|
|
188
|
+
this.#deps = deps;
|
|
189
|
+
this.#record = readActivationRecord(deps.recordPath);
|
|
190
|
+
if (this.#record === null && deps.floorClaimed()) {
|
|
191
|
+
// Only the switch claims the floor, after its record: a floor with no
|
|
192
|
+
// record means the record was lost, never a runner still on v0.
|
|
193
|
+
this.#record = { step: "local", stopped: [], damaged: true };
|
|
194
|
+
deps.log(
|
|
195
|
+
`[credentials] the credentials floor exists but ${deps.recordPath} does not: the runner treats it as the local ` +
|
|
196
|
+
"commit, runs no task, and retires every task runtime again before it completes the switch",
|
|
197
|
+
);
|
|
198
|
+
} else if (this.#record?.damaged) {
|
|
199
|
+
deps.log(
|
|
200
|
+
`[credentials] the activation record ${deps.recordPath} could not be read: the runner treats it as the local ` +
|
|
201
|
+
"commit, runs no task, and claims the floor and retires every task runtime again before it completes the switch",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
this.#activeAtBoot = this.#record?.step === "active";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The step the durable record names; null on the runner's own credentials. */
|
|
208
|
+
step(): ActivationStep | null {
|
|
209
|
+
return this.#record?.step ?? null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
mode(): CredentialsMode {
|
|
213
|
+
if (this.#record === null) return "legacy";
|
|
214
|
+
return this.#record.step === "active" && (this.#announced || this.#announcing) ? "active" : "pending";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** At boot, once the composer and the bridge's handlers exist: a runner
|
|
218
|
+
* already active says so to whoever follows it once it may (`advance`
|
|
219
|
+
* again when the boot recovery sweep is done). */
|
|
220
|
+
booted(): void {
|
|
221
|
+
if (this.#record?.step === "active") void this.advance();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The container runtime's recovery gave up (its bounded retries spent)
|
|
225
|
+
* before the boot sweep succeeded: from now on every attempt runs the
|
|
226
|
+
* sweep itself, so a runner already active is not left pending for good. */
|
|
227
|
+
sweepItself(): void {
|
|
228
|
+
this.#sweepsItself = true;
|
|
229
|
+
void this.advance();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The owed stopped reports again, then as far as the runner can get now:
|
|
234
|
+
* from `legacy` only when eligible and the cloud agrees; from `pending`
|
|
235
|
+
* always. Single-flight; never rejects (each failure is logged, and the
|
|
236
|
+
* next call tries again).
|
|
237
|
+
*/
|
|
238
|
+
advance(): Promise<void> {
|
|
239
|
+
if (this.#advancing === null) {
|
|
240
|
+
this.#advancing = this.#advanceOnce()
|
|
241
|
+
.catch((error: unknown) => this.#note(`activation stopped: ${message(error)}; it tries again later`))
|
|
242
|
+
.finally(() => {
|
|
243
|
+
this.#advancing = null;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return this.#advancing;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async #advanceOnce(): Promise<void> {
|
|
250
|
+
this.#reportOwed();
|
|
251
|
+
if (this.#stuck) return;
|
|
252
|
+
if (this.#record?.step === "active") {
|
|
253
|
+
await this.#activate();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
let plan: RetirementPlan | null = null;
|
|
257
|
+
if (this.#record === null) {
|
|
258
|
+
if (!this.#deps.eligible()) return;
|
|
259
|
+
const prepared = await this.#deps.ask("prepare");
|
|
260
|
+
if (!prepared.ok) {
|
|
261
|
+
this.#note(`the cloud did not agree to cloud credentials yet: ${prepared.error}`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const paused = this.#deps.pauseLegacyStarts();
|
|
265
|
+
try {
|
|
266
|
+
await paused.drained;
|
|
267
|
+
plan = await this.#deps.planRetirement();
|
|
268
|
+
// From the record on, the switch only goes forward.
|
|
269
|
+
await this.#deps.commitLocally(() => this.#write({ step: "local", stopped: [] }));
|
|
270
|
+
} catch (error) {
|
|
271
|
+
// A failure after the record was written (the lock's release, say)
|
|
272
|
+
// changes nothing about it: the switch goes on. Whether it was
|
|
273
|
+
// written is read back, never assumed from the error: a rename that
|
|
274
|
+
// failed ambiguously may have landed, and a record on disk, readable
|
|
275
|
+
// or not, is the local commit.
|
|
276
|
+
this.#record ??= readActivationRecord(this.#deps.recordPath);
|
|
277
|
+
if (this.#record === null) {
|
|
278
|
+
paused.resume();
|
|
279
|
+
this.#note(`the runner stays on its own credentials, nothing changed: ${message(error)}`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
this.#deps.log("[credentials] switching to cloud credentials: leaving the runner's own");
|
|
284
|
+
}
|
|
285
|
+
if (!this.#synced()) return;
|
|
286
|
+
if (this.#record?.step === "local") {
|
|
287
|
+
// A process that started pending has nothing of its own credentials
|
|
288
|
+
// running; leaving them is then immediate.
|
|
289
|
+
try {
|
|
290
|
+
await this.#deps.leaveLegacy();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
this.#stuck = true;
|
|
293
|
+
this.#deps.log(
|
|
294
|
+
`[credentials] the runner's own credentials did not stop (${message(error)}): it retires every task runtime ` +
|
|
295
|
+
"now, then restarts to resume the switch",
|
|
296
|
+
);
|
|
297
|
+
try {
|
|
298
|
+
await this.#retire(plan ?? (await this.#deps.planRetirement()));
|
|
299
|
+
} finally {
|
|
300
|
+
this.#deps.restart("the runner's own credentials did not stop while it switched to cloud credentials");
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
plan ??= await this.#deps.planRetirement();
|
|
305
|
+
await this.#retire(plan);
|
|
306
|
+
await this.#deps.claimFloor();
|
|
307
|
+
const committed = await this.#deps.ask("commit");
|
|
308
|
+
if (!committed.ok) {
|
|
309
|
+
this.#note(`the cloud did not record this runner's switch yet: ${committed.error}; it runs no task meanwhile`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.#write({ ...this.#record, step: "cloud" });
|
|
313
|
+
}
|
|
314
|
+
if (this.#record?.step === "cloud") {
|
|
315
|
+
await this.#deps.purge();
|
|
316
|
+
this.#write({ ...this.#record, step: "active" });
|
|
317
|
+
this.#deps.log("[credentials] local credential stores purged");
|
|
318
|
+
await this.#activate();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Whether the record is durable, proving it if that is not known yet:
|
|
323
|
+
* the record this process holds, exactly, synced at its path. One removed,
|
|
324
|
+
* replaced or unreadable since is written again from it (no process
|
|
325
|
+
* holds one behind the one on disk). Noted (once) while it cannot be. */
|
|
326
|
+
#synced(): boolean {
|
|
327
|
+
if (this.#durable) return true;
|
|
328
|
+
const record = this.#record;
|
|
329
|
+
if (record === null) return false;
|
|
330
|
+
try {
|
|
331
|
+
if (sameRecord(readActivationRecord(this.#deps.recordPath), record)) {
|
|
332
|
+
syncRecordExactly(this.#deps.recordPath, record);
|
|
333
|
+
this.#durable = true;
|
|
334
|
+
} else {
|
|
335
|
+
this.#write(record);
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
this.#note(
|
|
339
|
+
`the activation record is not known to be on disk yet (${message(error)}): the switch waits, running no task, ` +
|
|
340
|
+
"until it is",
|
|
341
|
+
);
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
return this.#durable;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Says `active` once the runner may: its `active` record durable, and the
|
|
348
|
+
* recovery sweep done (see the header). */
|
|
349
|
+
async #activate(): Promise<void> {
|
|
350
|
+
if (this.#announced || !this.#synced()) return;
|
|
351
|
+
if (!this.#deps.swept()) {
|
|
352
|
+
// At boot the container runtime's pass sweeps, before it reattaches
|
|
353
|
+
// anything; this joins in only once that pass gave up.
|
|
354
|
+
if (this.#activeAtBoot && !this.#sweepsItself) {
|
|
355
|
+
this.#note("the runner says it is active once the boot recovery sweep is done; it runs no task until then");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
await this.#deps.sweep();
|
|
360
|
+
} catch (error) {
|
|
361
|
+
this.#note(
|
|
362
|
+
`the recovery sweep failed (${message(error)}): the runner stays pending, running no task, and sweeps again later`,
|
|
363
|
+
);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
this.#announce();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** `active`, said once per process: at boot for a runner that started
|
|
371
|
+
* active, else the transition. A failure leaves it pending, withdrawn. */
|
|
372
|
+
#announce(): void {
|
|
373
|
+
if (this.#announced) return;
|
|
374
|
+
this.#announcing = true;
|
|
375
|
+
let failure: unknown = null;
|
|
376
|
+
try {
|
|
377
|
+
this.#deps.onActive(this.#activeAtBoot ? "boot" : "transition");
|
|
378
|
+
} catch (error) {
|
|
379
|
+
failure = error ?? new Error("onActive threw");
|
|
380
|
+
} finally {
|
|
381
|
+
this.#announcing = false;
|
|
382
|
+
}
|
|
383
|
+
if (failure === null) {
|
|
384
|
+
this.#announced = true;
|
|
385
|
+
this.#deps.log("[credentials] tasks run on cloud credentials");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.#note(`saying the runner is active failed (${message(failure)}): it is pending again, and tries again later`);
|
|
389
|
+
try {
|
|
390
|
+
this.#deps.onActiveWithdrawn();
|
|
391
|
+
} catch (error) {
|
|
392
|
+
this.#note(`withdrawing what was advertised failed: ${message(error)}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The retirement, its reports owed durably before they are sent, and
|
|
397
|
+
* sent; rejects when anything could not be proven removed. */
|
|
398
|
+
async #retire(plan: RetirementPlan): Promise<void> {
|
|
399
|
+
const { stopped, problem } = await this.#deps.retire(plan);
|
|
400
|
+
const owed = new Map((this.#record?.stopped ?? []).map((report) => [report.taskId, report]));
|
|
401
|
+
for (const report of stopped) owed.set(report.taskId, report);
|
|
402
|
+
this.#write({ step: this.#record?.step ?? "local", stopped: [...owed.values()] });
|
|
403
|
+
this.#reportOwed();
|
|
404
|
+
if (problem !== null) throw new Error(problem);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#reportOwed(): void {
|
|
408
|
+
const record = this.#record;
|
|
409
|
+
if (record === null || record.stopped.length === 0) return;
|
|
410
|
+
const owed = this.#deps.reportStopped(record.stopped);
|
|
411
|
+
if (owed.length !== record.stopped.length) this.#write({ ...record, stopped: owed });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** From its rename on it is the record, even if syncing its directory
|
|
415
|
+
* then fails, so a failure past the rename is never taken for no record
|
|
416
|
+
* at all; but it is known to be durable only once that sync is done, and
|
|
417
|
+
* nothing irreversible runs on it before (`#synced`). */
|
|
418
|
+
#write(record: ActivationRecord): void {
|
|
419
|
+
const written: ActivationRecord = { step: record.step, stopped: record.stopped };
|
|
420
|
+
writeActivationRecord(this.#deps.recordPath, written, () => {
|
|
421
|
+
this.#record = written;
|
|
422
|
+
this.#durable = false;
|
|
423
|
+
this.#lastNote = null;
|
|
424
|
+
});
|
|
425
|
+
syncRecordExactly(this.#deps.recordPath, written);
|
|
426
|
+
this.#durable = true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** The same note twice in a row is logged once: a refusal is asked about
|
|
430
|
+
* again every few minutes. */
|
|
431
|
+
#note(line: string): void {
|
|
432
|
+
if (line === this.#lastNote) return;
|
|
433
|
+
this.#lastNote = line;
|
|
434
|
+
this.#deps.log(`[credentials] ${line}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The record; null when there is none. One the runner cannot read is
|
|
439
|
+
* `local` with nothing owed (see the header). */
|
|
440
|
+
export function readActivationRecord(path: string): ActivationRecord | null {
|
|
441
|
+
let raw: string;
|
|
442
|
+
try {
|
|
443
|
+
raw = readFileSync(path, "utf8");
|
|
444
|
+
} catch (error) {
|
|
445
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
446
|
+
return { step: "local", stopped: [], damaged: true };
|
|
447
|
+
}
|
|
448
|
+
return parseActivationRecord(raw);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** A record's text as the runner reads it: unreadable is `local`, damaged. */
|
|
452
|
+
function parseActivationRecord(raw: string): ActivationRecord {
|
|
453
|
+
try {
|
|
454
|
+
const parsed = JSON.parse(raw) as { schemaVersion?: unknown; step?: unknown; stopped?: unknown };
|
|
455
|
+
const step = parsed.step as ActivationStep;
|
|
456
|
+
if (parsed.schemaVersion === RECORD_SCHEMA_VERSION && STEPS.includes(step)) {
|
|
457
|
+
const stopped = Array.isArray(parsed.stopped) && parsed.stopped.every(isStoppedReport) ? parsed.stopped : null;
|
|
458
|
+
if (stopped !== null) return { step, stopped };
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
// Falls through: unreadable.
|
|
462
|
+
}
|
|
463
|
+
return { step: "local", stopped: [], damaged: true };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function isStoppedReport(value: unknown): value is StoppedReport {
|
|
467
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
468
|
+
const report = value as Record<string, unknown>;
|
|
469
|
+
return (
|
|
470
|
+
Object.keys(report).length === 2 &&
|
|
471
|
+
isSafeHostTaskId(report.taskId) &&
|
|
472
|
+
(report.runEpoch === null || (Number.isSafeInteger(report.runEpoch) && (report.runEpoch as number) >= 1))
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Written whole or not at all, and durable before it counts. `renamed`
|
|
477
|
+
* runs the moment the new record is in place, before its directory is
|
|
478
|
+
* made durable. */
|
|
479
|
+
export function writeActivationRecord(path: string, record: ActivationRecord, renamed?: () => void): void {
|
|
480
|
+
const body = { schemaVersion: RECORD_SCHEMA_VERSION, step: record.step, stopped: record.stopped };
|
|
481
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
482
|
+
writeFileSync(tmp, `${JSON.stringify(body)}\n`, { mode: 0o600 });
|
|
483
|
+
const file = openSync(tmp, "r");
|
|
484
|
+
try {
|
|
485
|
+
fsyncSync(file);
|
|
486
|
+
} finally {
|
|
487
|
+
closeSync(file);
|
|
488
|
+
}
|
|
489
|
+
renameSync(tmp, path);
|
|
490
|
+
renamed?.();
|
|
491
|
+
syncDirectoryOf(path);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Both say the same record; a damaged one is none. */
|
|
495
|
+
function sameRecord(a: ActivationRecord | null, b: ActivationRecord): boolean {
|
|
496
|
+
return (
|
|
497
|
+
a !== null &&
|
|
498
|
+
!a.damaged &&
|
|
499
|
+
!b.damaged &&
|
|
500
|
+
a.step === b.step &&
|
|
501
|
+
JSON.stringify(a.stopped) === JSON.stringify(b.stopped)
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Proves `path` names `expected` on disk: the regular file it names (no
|
|
506
|
+
* link followed) says exactly that, its bytes are synced, its directory
|
|
507
|
+
* is synced, and it is still that file at that name afterwards (what the
|
|
508
|
+
* directory sync made durable is it, not a removal or a replacement). */
|
|
509
|
+
function syncRecordExactly(path: string, expected: ActivationRecord): void {
|
|
510
|
+
const file = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
511
|
+
try {
|
|
512
|
+
const opened = fstatSync(file);
|
|
513
|
+
if (!opened.isFile()) throw new Error(`${path} is not a regular file`);
|
|
514
|
+
const onDisk = parseActivationRecord(readFileSync(file, "utf8"));
|
|
515
|
+
if (!sameRecord(onDisk, expected)) throw new Error(`${path} is not the record this runner holds`);
|
|
516
|
+
fsyncSync(file);
|
|
517
|
+
syncDirectoryOf(path);
|
|
518
|
+
const named = lstatSync(path);
|
|
519
|
+
if (named.dev !== opened.dev || named.ino !== opened.ino) {
|
|
520
|
+
throw new Error(`${path} was replaced while it was synced`);
|
|
521
|
+
}
|
|
522
|
+
} finally {
|
|
523
|
+
closeSync(file);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Makes the entries of `path`'s directory durable: a rename into it. */
|
|
528
|
+
function syncDirectoryOf(path: string): void {
|
|
529
|
+
const dir = openSync(dirname(path), "r");
|
|
530
|
+
try {
|
|
531
|
+
fsyncSync(dir);
|
|
532
|
+
} finally {
|
|
533
|
+
closeSync(dir);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** An error as the runner's log says it. A purge's names everything it could
|
|
538
|
+
* not remove and what to do about each: all of them are listed, one to a
|
|
539
|
+
* line, so that the operator can resolve them in one go. */
|
|
540
|
+
function message(error: unknown): string {
|
|
541
|
+
if (error instanceof AggregateError) {
|
|
542
|
+
const each = error.errors.map((inner: unknown) => `\n - ${message(inner)}`).join("");
|
|
543
|
+
return `${error.message}:${each}`.slice(0, 8_000);
|
|
544
|
+
}
|
|
545
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 600);
|
|
546
|
+
}
|