@runuai/host 0.9.150 → 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 +1830 -224
- 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 +1061 -139
- 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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed boundary between the in-memory lease registry and the durable
|
|
3
|
+
* task credential cleanup procedure.
|
|
4
|
+
*
|
|
5
|
+
* The registry describes why its gate closed and whether the cleanup is
|
|
6
|
+
* selective or whole-task. The durable coordinator uses a smaller audit
|
|
7
|
+
* vocabulary and represents selectivity solely by the presence of
|
|
8
|
+
* `credentialIds`. Keep that lossy conversion here so a new registry reason
|
|
9
|
+
* cannot accidentally become a whole-task cleanup (or vice versa).
|
|
10
|
+
*/
|
|
11
|
+
import type {
|
|
12
|
+
CredentialCleanupReason as LeaseCredentialCleanupReason,
|
|
13
|
+
CredentialCleanupRequest as LeaseCredentialCleanupRequest,
|
|
14
|
+
} from "./cloud-credential-leases";
|
|
15
|
+
import type {
|
|
16
|
+
TaskCredentialCleanupCoordinator,
|
|
17
|
+
TaskCredentialCleanupReason,
|
|
18
|
+
TaskCredentialCleanupRequest,
|
|
19
|
+
} from "./task-credential-cleanup";
|
|
20
|
+
|
|
21
|
+
export type CredentialCleanupRunnerKind = "owned" | "task-machine";
|
|
22
|
+
|
|
23
|
+
type LeaseCleanupMode = LeaseCredentialCleanupRequest["mode"];
|
|
24
|
+
type CleanupReasonByMode = Readonly<
|
|
25
|
+
Record<LeaseCleanupMode, TaskCredentialCleanupReason | null>
|
|
26
|
+
>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `null` marks a reason/mode pair the registry must never emit. The `satisfies`
|
|
30
|
+
* clause makes additions to either the reason union or the mode union a
|
|
31
|
+
* compile-time decision at this boundary.
|
|
32
|
+
*/
|
|
33
|
+
const CLEANUP_REASON_BY_MODE = {
|
|
34
|
+
starting: { selective: null, full: null },
|
|
35
|
+
recovery: { selective: null, full: "restart" },
|
|
36
|
+
superseded: { selective: null, full: "assignment" },
|
|
37
|
+
"minimum-revision": { selective: "removed", full: "removed" },
|
|
38
|
+
"manifest-narrowed": { selective: null, full: "removed" },
|
|
39
|
+
denied: { selective: null, full: "denied" },
|
|
40
|
+
expired: { selective: null, full: "expired" },
|
|
41
|
+
stopped: { selective: null, full: "stop" },
|
|
42
|
+
parked: { selective: null, full: "park" },
|
|
43
|
+
teardown: { selective: null, full: "teardown" },
|
|
44
|
+
// The registry emits this fail-closed fallback when its synchronous gate
|
|
45
|
+
// callback throws during assignment start/recovery. The durable vocabulary
|
|
46
|
+
// has no internal-callback-failure reason; `restart` is the conservative
|
|
47
|
+
// whole-task recovery category and, critically, lets the coordinator write
|
|
48
|
+
// its pending witness before retrying the quarantine.
|
|
49
|
+
"cleanup-failed": { selective: null, full: "restart" },
|
|
50
|
+
// A launch slot that could not be retired in its runtime (ADR-154 §6):
|
|
51
|
+
// the whole run's cleanup, in the same conservative category.
|
|
52
|
+
"fence-failed": { selective: null, full: "restart" },
|
|
53
|
+
// Shutdown ends assignments without a cleanup: the next process's
|
|
54
|
+
// recovery sweep runs it.
|
|
55
|
+
shutdown: { selective: null, full: null },
|
|
56
|
+
"lease-replaced": { selective: "removed", full: null },
|
|
57
|
+
} as const satisfies Readonly<
|
|
58
|
+
Record<LeaseCredentialCleanupReason, CleanupReasonByMode>
|
|
59
|
+
>;
|
|
60
|
+
|
|
61
|
+
function mappedReason(
|
|
62
|
+
reason: LeaseCredentialCleanupReason,
|
|
63
|
+
mode: LeaseCleanupMode,
|
|
64
|
+
): TaskCredentialCleanupReason {
|
|
65
|
+
const mapped = CLEANUP_REASON_BY_MODE[reason][mode];
|
|
66
|
+
if (mapped === null) {
|
|
67
|
+
throw new Error(`invalid ${mode} lease credential cleanup reason: ${reason}`);
|
|
68
|
+
}
|
|
69
|
+
return mapped;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Convert one registry callback into the coordinator's durable request. */
|
|
73
|
+
export function adaptLeaseCredentialCleanupRequest(
|
|
74
|
+
request: Readonly<LeaseCredentialCleanupRequest>,
|
|
75
|
+
runnerKind: CredentialCleanupRunnerKind,
|
|
76
|
+
): TaskCredentialCleanupRequest {
|
|
77
|
+
const reason = mappedReason(request.reason, request.mode);
|
|
78
|
+
|
|
79
|
+
if (request.mode === "selective") {
|
|
80
|
+
if (request.removeFilesSession) {
|
|
81
|
+
throw new Error("selective credential cleanup cannot remove the files session");
|
|
82
|
+
}
|
|
83
|
+
if (request.removed.length === 0) {
|
|
84
|
+
throw new Error("selective credential cleanup requires a removed credential");
|
|
85
|
+
}
|
|
86
|
+
// Several revisions of one stable credential can retire before a floor
|
|
87
|
+
// grant lands. Inventory is keyed by the stable id, so collapse those
|
|
88
|
+
// qualified identities rather than widening or rejecting the cleanup.
|
|
89
|
+
const credentialIds = [
|
|
90
|
+
...new Set(request.removed.map((identity) => identity.credentialId)),
|
|
91
|
+
];
|
|
92
|
+
return {
|
|
93
|
+
taskId: request.assignment.taskId,
|
|
94
|
+
runEpoch: request.assignment.runEpoch,
|
|
95
|
+
reason,
|
|
96
|
+
credentialIds,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (request.removed.length !== 0) {
|
|
101
|
+
throw new Error("full credential cleanup cannot carry selective identities");
|
|
102
|
+
}
|
|
103
|
+
const expectedFilesSession = runnerKind === "task-machine";
|
|
104
|
+
if (request.removeFilesSession !== expectedFilesSession) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${runnerKind} full credential cleanup has the wrong files-session scope`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
taskId: request.assignment.taskId,
|
|
111
|
+
runEpoch: request.assignment.runEpoch,
|
|
112
|
+
reason,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Callback with the exact shape consumed by TaskCredentialLeaseRegistry. */
|
|
117
|
+
export function createLeaseCredentialCleanupAdapter(
|
|
118
|
+
coordinator: Pick<TaskCredentialCleanupCoordinator, "cleanup">,
|
|
119
|
+
runnerKind: CredentialCleanupRunnerKind,
|
|
120
|
+
): (request: LeaseCredentialCleanupRequest) => Promise<void> {
|
|
121
|
+
return (request) =>
|
|
122
|
+
coordinator.cleanup(adaptLeaseCredentialCleanupRequest(request, runnerKind));
|
|
123
|
+
}
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 on this runner: whether its tasks run on cloud credentials, and
|
|
3
|
+
* the one composer that serves them.
|
|
4
|
+
*
|
|
5
|
+
* The mode comes from the runner's durable activation authority (three
|
|
6
|
+
* states, ADR-154 §13; lib/credentials-activation-authority.ts), and only
|
|
7
|
+
* moves forward:
|
|
8
|
+
* - `legacy`: the composer stays unused and every task runs as before;
|
|
9
|
+
* - `pending`: task starts are refused, neither the runner's own credentials
|
|
10
|
+
* nor cloud ones may run;
|
|
11
|
+
* - `active`: every task runs on cloud credentials through the composer, and
|
|
12
|
+
* no local fallback exists.
|
|
13
|
+
* Until main.ts installs that authority, the mode is `legacy`.
|
|
14
|
+
*
|
|
15
|
+
* Leaving `legacy` is a counted transition. Each start on the runner's own
|
|
16
|
+
* credentials holds a permit from its dispatch to its outcome, and checks it
|
|
17
|
+
* again inside each lifecycle slot before anything happens there; its
|
|
18
|
+
* permit's signal ends any wait for a legacy readiness the transition stops.
|
|
19
|
+
* Work on those credentials outside a start (a start's background GitHub
|
|
20
|
+
* sign-in, a scheduled re-injection) runs only through
|
|
21
|
+
* `whileOnOwnCredentials`, and workers register a stop. `leaveLegacy()`
|
|
22
|
+
* closes the permits, aborts their signals and stops the workers at once,
|
|
23
|
+
* then resolves when every start, every such work and every worker's work
|
|
24
|
+
* already under way has ended.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { CloudCredentialRuntime } from "./cloud-credential-runtime";
|
|
28
|
+
import { readTaskRunEpochState, runningTaskRuns } from "./runtime-state";
|
|
29
|
+
|
|
30
|
+
export type CredentialsMode = "legacy" | "pending" | "active";
|
|
31
|
+
|
|
32
|
+
let modeSource: () => CredentialsMode = () => "legacy";
|
|
33
|
+
let runtime: CloudCredentialRuntime | null = null;
|
|
34
|
+
|
|
35
|
+
/** The activation authority answers from here on. */
|
|
36
|
+
export function setCredentialsModeSource(source: () => CredentialsMode): void {
|
|
37
|
+
modeSource = source;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function credentialsMode(): CredentialsMode {
|
|
41
|
+
return modeSource();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function setCloudCredentialRuntime(next: CloudCredentialRuntime | null): void {
|
|
45
|
+
runtime = next;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The composer, once constructed; null before (and in tests that run no
|
|
49
|
+
* task on cloud credentials). */
|
|
50
|
+
export function cloudCredentialRuntime(): CloudCredentialRuntime | null {
|
|
51
|
+
return runtime;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The composer while the runner's tasks run on cloud credentials; null
|
|
55
|
+
* otherwise. Read where it is used, inside the task's lifecycle slot. */
|
|
56
|
+
export function activeCloudCredentials(): CloudCredentialRuntime | null {
|
|
57
|
+
return credentialsMode() === "active" ? runtime : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// -- leaving legacy ------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** One start on the runner's own credentials, from its dispatch to its
|
|
63
|
+
* outcome. */
|
|
64
|
+
export interface LegacyStartPermit {
|
|
65
|
+
/** Still on the runner's own credentials, with no transition begun. Once
|
|
66
|
+
* false, never true again. */
|
|
67
|
+
valid(): boolean;
|
|
68
|
+
/** Aborted when the runner begins leaving its own credentials: a wait for
|
|
69
|
+
* something the transition stops (the v0 lease's readiness) ends then. */
|
|
70
|
+
readonly signal: AbortSignal;
|
|
71
|
+
/** The start ended. Idempotent. */
|
|
72
|
+
release(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Set first, before anything a worker's stop could run. */
|
|
76
|
+
let left = false;
|
|
77
|
+
/** The activation authority is retiring the runner's task runtimes: no new
|
|
78
|
+
* start on its own credentials until it resumes them or leaves them. */
|
|
79
|
+
let startsPaused = false;
|
|
80
|
+
/** A runtime activation has asked the one-shot boot barrier for its
|
|
81
|
+
* recovery (whatever came of it), or leaving the runner's own credentials
|
|
82
|
+
* has begun: every pass from then on recovers afresh, its sweep pass and
|
|
83
|
+
* that pass's own follow-ups alike. */
|
|
84
|
+
let recoverAfresh = false;
|
|
85
|
+
let leaving: Promise<void> | null = null;
|
|
86
|
+
let legacyStarts = 0;
|
|
87
|
+
let legacyDrained: Array<() => void> = [];
|
|
88
|
+
const legacyWorkers = new Set<() => Promise<void> | void>();
|
|
89
|
+
const legacyPermitSignals = new Set<AbortController>();
|
|
90
|
+
const legacyWork = new Set<Promise<unknown>>();
|
|
91
|
+
|
|
92
|
+
function stillLegacy(): boolean {
|
|
93
|
+
return !left && credentialsMode() === "legacy";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The runner is on its own credentials, and has not begun leaving them. */
|
|
97
|
+
export function onOwnCredentials(): boolean {
|
|
98
|
+
return stillLegacy();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A permit for one start on the runner's own credentials; null once the
|
|
102
|
+
* runner is leaving them (or has left), and while starts are paused. */
|
|
103
|
+
export function admitLegacyStart(): LegacyStartPermit | null {
|
|
104
|
+
if (!stillLegacy() || startsPaused) return null;
|
|
105
|
+
legacyStarts += 1;
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
legacyPermitSignals.add(controller);
|
|
108
|
+
let valid = true;
|
|
109
|
+
let released = false;
|
|
110
|
+
return {
|
|
111
|
+
valid: () => (valid &&= stillLegacy()),
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
release: () => {
|
|
114
|
+
if (released) return;
|
|
115
|
+
released = true;
|
|
116
|
+
legacyPermitSignals.delete(controller);
|
|
117
|
+
legacyStarts -= 1;
|
|
118
|
+
if (legacyStarts === 0) for (const drained of legacyDrained.splice(0)) drained();
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* ADR-154 §13, preparing: no new start on the runner's own credentials (each
|
|
125
|
+
* is answered as switching, which the cloud retries), while everything else
|
|
126
|
+
* on them goes on. `drained` resolves once the starts admitted before have
|
|
127
|
+
* ended, so every task runtime they create exists by then. `resume` admits
|
|
128
|
+
* starts again, for a preparation that failed; leaving the runner's own
|
|
129
|
+
* credentials needs no resume. Idempotent.
|
|
130
|
+
*/
|
|
131
|
+
export function pauseLegacyStarts(): { drained: Promise<void>; resume(): void } {
|
|
132
|
+
startsPaused = true;
|
|
133
|
+
const drained =
|
|
134
|
+
legacyStarts === 0 ? Promise.resolve() : new Promise<void>((resolve) => legacyDrained.push(resolve));
|
|
135
|
+
let resumed = false;
|
|
136
|
+
return {
|
|
137
|
+
drained,
|
|
138
|
+
resume: () => {
|
|
139
|
+
if (resumed) return;
|
|
140
|
+
resumed = true;
|
|
141
|
+
startsPaused = false;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Work on the runner's own credentials outside any start (a start's
|
|
148
|
+
* background GitHub sign-in, a scheduled re-injection, the write that ends
|
|
149
|
+
* either): `run` only while the runner is on them, and leaving them waits
|
|
150
|
+
* for it to end. Null, and `run` never called, once the runner is leaving
|
|
151
|
+
* them: the caller writes nothing.
|
|
152
|
+
*/
|
|
153
|
+
export function whileOnOwnCredentials<T>(run: () => Promise<T>): Promise<T> | null {
|
|
154
|
+
if (!stillLegacy()) return null;
|
|
155
|
+
const work = run();
|
|
156
|
+
legacyWork.add(work);
|
|
157
|
+
const forget = (): void => void legacyWork.delete(work);
|
|
158
|
+
work.then(forget, forget);
|
|
159
|
+
return work;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A hold on the runner's own credentials for work whose end is no single
|
|
164
|
+
* promise (an agent's creation through its binding, a pass that decides on
|
|
165
|
+
* them and then starts a process): leaving them waits until it is
|
|
166
|
+
* released. Null, and nothing held, once the runner is leaving them: the
|
|
167
|
+
* caller uses none. The release is idempotent.
|
|
168
|
+
*/
|
|
169
|
+
export function holdOwnCredentials(): (() => void) | null {
|
|
170
|
+
let release!: () => void;
|
|
171
|
+
const held = whileOnOwnCredentials(() => new Promise<void>((resolve) => (release = resolve)));
|
|
172
|
+
return held === null ? null : release;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A process on the runner's own credentials refused at its provider's
|
|
176
|
+
* last moment: the runner has begun leaving them. */
|
|
177
|
+
export class LeftOwnCredentialsError extends Error {
|
|
178
|
+
constructor() {
|
|
179
|
+
super("the runner is switching its tasks to cloud credentials");
|
|
180
|
+
this.name = "LeftOwnCredentialsError";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const LAUNCH_METHODS: ReadonlySet<PropertyKey> = new Set(["exec", "spawn", "spawnSession", "launchDetachedSession"]);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* `environment` (a task environment), for an agent on the runner's own
|
|
188
|
+
* credentials: every process it starts there, then or at any later turn,
|
|
189
|
+
* is admitted at the provider's last moment only while the runner is on
|
|
190
|
+
* them, so none starts once leaving them has begun, however long the
|
|
191
|
+
* provider took to get there (a machine looks up its address first).
|
|
192
|
+
*/
|
|
193
|
+
export function onOwnCredentialsEnvironment<T extends object>(environment: T): T {
|
|
194
|
+
return new Proxy(environment, {
|
|
195
|
+
get(target, property) {
|
|
196
|
+
const value: unknown = Reflect.get(target, property, target);
|
|
197
|
+
if (typeof value !== "function") return value;
|
|
198
|
+
const method = value as (...args: unknown[]) => unknown;
|
|
199
|
+
if (!LAUNCH_METHODS.has(property)) return method.bind(target);
|
|
200
|
+
return (request: { admit?: () => void }, ...rest: unknown[]) =>
|
|
201
|
+
method.call(
|
|
202
|
+
target,
|
|
203
|
+
{
|
|
204
|
+
...request,
|
|
205
|
+
admit: () => {
|
|
206
|
+
if (!stillLegacy()) throw new LeftOwnCredentialsError();
|
|
207
|
+
request.admit?.();
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
...rest,
|
|
211
|
+
);
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Work on the runner's own credentials that must end before the runner
|
|
217
|
+
* leaves them: `stop` stops it at once when called, and resolves once what
|
|
218
|
+
* it had under way has ended. Returns the registration's removal. */
|
|
219
|
+
export function onLeavingLegacy(stop: () => Promise<void> | void): () => void {
|
|
220
|
+
legacyWorkers.add(stop);
|
|
221
|
+
return () => void legacyWorkers.delete(stop);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The activation authority, as the runner leaves its own credentials (it
|
|
226
|
+
* enters `pending`): no start on them is admitted from here on, every
|
|
227
|
+
* registered worker stops now, synchronously, and this resolves once the
|
|
228
|
+
* starts admitted before and the workers' work under way have all ended.
|
|
229
|
+
* Rejects (after all of that) when a worker's stop failed: the runner stays
|
|
230
|
+
* `pending`. Idempotent: every call shares the first one's outcome.
|
|
231
|
+
*/
|
|
232
|
+
export function leaveLegacy(): Promise<void> {
|
|
233
|
+
if (leaving === null) {
|
|
234
|
+
left = true;
|
|
235
|
+
recoverAfresh = true;
|
|
236
|
+
for (const controller of legacyPermitSignals) {
|
|
237
|
+
controller.abort(new Error("the runner is switching its tasks to cloud credentials"));
|
|
238
|
+
}
|
|
239
|
+
leaving = drainLegacy();
|
|
240
|
+
}
|
|
241
|
+
return leaving;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function drainLegacy(): Promise<void> {
|
|
245
|
+
const stopping: Array<Promise<void> | void> = [];
|
|
246
|
+
for (const stop of legacyWorkers) {
|
|
247
|
+
try {
|
|
248
|
+
stopping.push(stop());
|
|
249
|
+
} catch (error) {
|
|
250
|
+
stopping.push(Promise.reject(error));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const starts =
|
|
254
|
+
legacyStarts === 0 ? Promise.resolve() : new Promise<void>((resolve) => legacyDrained.push(resolve));
|
|
255
|
+
const results = await Promise.allSettled([...stopping, starts]);
|
|
256
|
+
// Work launched before leaving began ends too; none starts after.
|
|
257
|
+
while (legacyWork.size > 0) await Promise.allSettled([...legacyWork]);
|
|
258
|
+
const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason as unknown] : []));
|
|
259
|
+
if (failures.length > 0) {
|
|
260
|
+
throw new AggregateError(failures, "work on the runner's own credentials did not stop");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Tests: the runner back on its own credentials, nothing registered. */
|
|
265
|
+
export function resetLegacyTransitionForTests(): void {
|
|
266
|
+
left = false;
|
|
267
|
+
startsPaused = false;
|
|
268
|
+
recoverAfresh = false;
|
|
269
|
+
leaving = null;
|
|
270
|
+
legacyStarts = 0;
|
|
271
|
+
legacyDrained = [];
|
|
272
|
+
legacyWorkers.clear();
|
|
273
|
+
legacyPermitSignals.clear();
|
|
274
|
+
legacyWork.clear();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// -- boot recovery -----------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
let recovering: Promise<void> | null = null;
|
|
280
|
+
let markRecovered!: () => void;
|
|
281
|
+
let recoveredSignal = new Promise<void>((resolve) => (markRecovered = resolve));
|
|
282
|
+
let recovered = false;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* ADR-154 boot recovery (§5, §6): the composer's sweep of every task with
|
|
286
|
+
* cleanup left from the last process, before any surviving agent is
|
|
287
|
+
* reattached or any grant asked for. Once per process: after it succeeds,
|
|
288
|
+
* a call does nothing, so a later runtime re-activation never sweeps the
|
|
289
|
+
* credentials of tasks running now; concurrent calls share one sweep.
|
|
290
|
+
* Rejects, every task attempted, when any could not be cleaned up: the
|
|
291
|
+
* runner stays `pending`, and a later call tries again.
|
|
292
|
+
*/
|
|
293
|
+
export function recoverCloudCredentials(): Promise<void> {
|
|
294
|
+
if (recovered) return Promise.resolve();
|
|
295
|
+
if (recovering !== null) return recovering;
|
|
296
|
+
const composer = runtime;
|
|
297
|
+
if (composer === null) return Promise.reject(new Error("the runner has no cloud credentials composer"));
|
|
298
|
+
const sweep = composer
|
|
299
|
+
.recover((taskId) => readTaskRunEpochState(taskId)?.highestRunEpoch ?? 0)
|
|
300
|
+
.then(() => {
|
|
301
|
+
recovered = true;
|
|
302
|
+
markRecovered();
|
|
303
|
+
})
|
|
304
|
+
.finally(() => {
|
|
305
|
+
if (recovering === sweep) recovering = null;
|
|
306
|
+
});
|
|
307
|
+
recovering = sweep;
|
|
308
|
+
return sweep;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* ADR-154: a runtime activation's currency, on the credentials it began on.
|
|
313
|
+
* One that began on the runner's own credentials stops being current the
|
|
314
|
+
* moment leaving them begins, through its last step, publication included:
|
|
315
|
+
* what its recovery reattached did so on their terms, and a new pass, which
|
|
316
|
+
* sweeps first, must take over (`leftLegacy()` says it was cut short so).
|
|
317
|
+
* One that began off them is current while its runtime generation is.
|
|
318
|
+
*/
|
|
319
|
+
export function credentialsActivationFence(isRuntimeCurrent: () => boolean): {
|
|
320
|
+
isCurrent(): boolean;
|
|
321
|
+
leftLegacy(): boolean;
|
|
322
|
+
/** Cut short by leaving, its runtime still current and not operational
|
|
323
|
+
* (however it was suspended): another pass, which sweeps first. */
|
|
324
|
+
needsSweepPass(runtimeOperational: boolean): boolean;
|
|
325
|
+
} {
|
|
326
|
+
const beganOnOwn = onOwnCredentials();
|
|
327
|
+
const leftLegacy = (): boolean => beganOnOwn && !onOwnCredentials();
|
|
328
|
+
return {
|
|
329
|
+
isCurrent: () => isRuntimeCurrent() && (!beganOnOwn || onOwnCredentials()),
|
|
330
|
+
leftLegacy,
|
|
331
|
+
needsSweepPass: (runtimeOperational) => leftLegacy() && isRuntimeCurrent() && !runtimeOperational,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* ADR-154: a runtime's recovery on the runner's credentials as they are.
|
|
337
|
+
* Off its own, the boot sweep of every task's credential cleanup from the
|
|
338
|
+
* last process comes first (once per process), so no surviving agent is
|
|
339
|
+
* reattached before its task's cleanup; a failed sweep fails the recovery,
|
|
340
|
+
* and the runner stays pending. On its own, the recovery stops where
|
|
341
|
+
* leaving them begins and reports stale, so the next pass sweeps first.
|
|
342
|
+
*/
|
|
343
|
+
export async function recoverRuntimeOnCredentials(
|
|
344
|
+
recover: (isCurrent: () => boolean) => Promise<boolean>,
|
|
345
|
+
isCurrent: () => boolean,
|
|
346
|
+
sweep: () => Promise<void> = recoverCloudCredentials,
|
|
347
|
+
): Promise<boolean> {
|
|
348
|
+
if (!onOwnCredentials()) {
|
|
349
|
+
try {
|
|
350
|
+
await sweep();
|
|
351
|
+
} catch (error) {
|
|
352
|
+
console.warn(
|
|
353
|
+
`[credentials] boot recovery failed; the runner stays pending: ${error instanceof Error ? error.message : String(error)}`,
|
|
354
|
+
);
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
if (!isCurrent()) return false;
|
|
358
|
+
return recover(isCurrent);
|
|
359
|
+
}
|
|
360
|
+
const recovered = await recover(() => isCurrent() && onOwnCredentials());
|
|
361
|
+
return recovered && onOwnCredentials();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
type RuntimeRecovery = (isCurrent: () => boolean) => Promise<boolean>;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* ADR-154: one runtime activation pass on the runner's credentials as they
|
|
368
|
+
* change. `pass` runs the activation under the fence (its currency through
|
|
369
|
+
* publication) with the credentials-aware recovery. A pass that leaving the
|
|
370
|
+
* runner's own credentials cut short, however it ended (an exception
|
|
371
|
+
* included, reported to `onError` as the tail reports any other), asks for
|
|
372
|
+
* the sweep pass (`rerun`). `recover` is a one-shot boot barrier: it
|
|
373
|
+
* answers only the first recovery a pass asks for, before leaving begins.
|
|
374
|
+
* Once asked (whatever its outcome: false, or a throw) or once leaving has
|
|
375
|
+
* begun, every pass recovers afresh (`freshRecover`), never through a result
|
|
376
|
+
* that settled before a retry or before the sweep.
|
|
377
|
+
*/
|
|
378
|
+
export async function activationPassOnCredentials(options: {
|
|
379
|
+
isRuntimeCurrent: () => boolean;
|
|
380
|
+
runtimeOperational: () => boolean;
|
|
381
|
+
recover: RuntimeRecovery;
|
|
382
|
+
freshRecover: RuntimeRecovery;
|
|
383
|
+
pass: (isCurrent: () => boolean, recover: RuntimeRecovery) => Promise<"complete" | "retry" | "rerun">;
|
|
384
|
+
onError?: (error: unknown) => void;
|
|
385
|
+
}): Promise<"complete" | "retry" | "rerun"> {
|
|
386
|
+
const fence = credentialsActivationFence(options.isRuntimeCurrent);
|
|
387
|
+
const recover: RuntimeRecovery = recoverAfresh
|
|
388
|
+
? options.freshRecover
|
|
389
|
+
: (isCurrent) => {
|
|
390
|
+
recoverAfresh = true;
|
|
391
|
+
return options.recover(isCurrent);
|
|
392
|
+
};
|
|
393
|
+
const cutShort = (): boolean => {
|
|
394
|
+
if (!fence.needsSweepPass(options.runtimeOperational())) return false;
|
|
395
|
+
recoverAfresh = true;
|
|
396
|
+
return true;
|
|
397
|
+
};
|
|
398
|
+
let result: "complete" | "retry" | "rerun";
|
|
399
|
+
try {
|
|
400
|
+
result = await options.pass(
|
|
401
|
+
() => fence.isCurrent(),
|
|
402
|
+
(isCurrent) => recoverRuntimeOnCredentials(recover, isCurrent),
|
|
403
|
+
);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (!cutShort()) throw error;
|
|
406
|
+
try {
|
|
407
|
+
options.onError?.(error);
|
|
408
|
+
} catch {
|
|
409
|
+
// A diagnostic must never cost the sweep pass.
|
|
410
|
+
}
|
|
411
|
+
return "rerun";
|
|
412
|
+
}
|
|
413
|
+
return cutShort() ? "rerun" : result;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Boot recovery has succeeded in this process. */
|
|
417
|
+
export function cloudCredentialsSwept(): boolean {
|
|
418
|
+
return recovered;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Resolves once boot recovery has succeeded in this process. */
|
|
422
|
+
export function cloudCredentialsRecovered(): Promise<void> {
|
|
423
|
+
return recoveredSignal;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let activated = false;
|
|
427
|
+
/** The runs `cloudCredentialsActivated` has asked for again, by task and run. */
|
|
428
|
+
const resumedRuns = new Set<string>();
|
|
429
|
+
let recoveredRuntimeSetup:
|
|
430
|
+
| ((run: { taskId: string; runEpoch: number }, signal: AbortSignal) => Promise<void> | void)
|
|
431
|
+
| null = null;
|
|
432
|
+
const activationFollowers = new Set<() => void>();
|
|
433
|
+
|
|
434
|
+
/** What runs on a recovered task's runtime once its recovered grant opens
|
|
435
|
+
* its gate, however late that grant comes (a task machine: its loader,
|
|
436
|
+
* proxy and editor again); `signal` aborts when its run's recovery ends,
|
|
437
|
+
* and nothing of it may start after. Set once, at boot. */
|
|
438
|
+
export function setRecoveredRuntimeSetup(
|
|
439
|
+
setup: ((run: { taskId: string; runEpoch: number }, signal: AbortSignal) => Promise<void> | void) | null,
|
|
440
|
+
): void {
|
|
441
|
+
recoveredRuntimeSetup = setup;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Calls `follower` once, when the runner's tasks first run on cloud
|
|
445
|
+
* credentials in this process. Returns its removal. */
|
|
446
|
+
export function onCloudCredentialsActivated(follower: () => void): () => void {
|
|
447
|
+
activationFollowers.add(follower);
|
|
448
|
+
return () => void activationFollowers.delete(follower);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The activation authority, the moment the runner's tasks run on cloud
|
|
453
|
+
* credentials (its mode `active`, after recovery): every running task that
|
|
454
|
+
* survived this process's start asks for its run's credentials again, as a
|
|
455
|
+
* recovered start, and its runtime is set up again once its gate opens.
|
|
456
|
+
* Once per process: a later call (a repeated activation) changes nothing,
|
|
457
|
+
* and never touches a grant that is live. Its requests go out once the
|
|
458
|
+
* bridge can send them. Done only once every surviving run was asked for:
|
|
459
|
+
* a call that throws on the way is done again by the next, which asks for
|
|
460
|
+
* each run the first one had not.
|
|
461
|
+
*/
|
|
462
|
+
export function cloudCredentialsActivated(): void {
|
|
463
|
+
const composer = activeCloudCredentials();
|
|
464
|
+
if (composer === null || !recovered || activated) return;
|
|
465
|
+
const setup = recoveredRuntimeSetup;
|
|
466
|
+
for (const run of runningTaskRuns()) {
|
|
467
|
+
const key = `${run.taskId}\0${String(run.runEpoch)}`;
|
|
468
|
+
if (resumedRuns.has(key)) continue;
|
|
469
|
+
composer.resumeRecovered(run, setup ? (signal) => setup(run, signal) : undefined);
|
|
470
|
+
resumedRuns.add(key);
|
|
471
|
+
}
|
|
472
|
+
activated = true;
|
|
473
|
+
for (const follower of activationFollowers) {
|
|
474
|
+
try {
|
|
475
|
+
follower();
|
|
476
|
+
} catch (error) {
|
|
477
|
+
console.warn(`[credentials] activation follower failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Tests: no recovery or activation has run in this process. */
|
|
483
|
+
export function resetCloudCredentialRecoveryForTests(): void {
|
|
484
|
+
recoverAfresh = false;
|
|
485
|
+
recovering = null;
|
|
486
|
+
recovered = false;
|
|
487
|
+
recoveredSignal = new Promise<void>((resolve) => (markRecovered = resolve));
|
|
488
|
+
activated = false;
|
|
489
|
+
resumedRuns.clear();
|
|
490
|
+
recoveredRuntimeSetup = null;
|
|
491
|
+
activationFollowers.clear();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* A local operator's Stop of a task on cloud credentials: the start under
|
|
496
|
+
* way, as it is now, ends before Stop queues behind it (never one found
|
|
497
|
+
* later by task id, which could be a replacement). The run Stop closes has
|
|
498
|
+
* its credentials cleaned up inside the stop, by the composer as it is then.
|
|
499
|
+
* Null on a runner not on cloud credentials.
|
|
500
|
+
*/
|
|
501
|
+
export function localStopCredentials(taskId: string): { closeCredentials(runEpoch: number): Promise<void> } | null {
|
|
502
|
+
const credentials = activeCloudCredentials();
|
|
503
|
+
if (credentials === null) return null;
|
|
504
|
+
const start = credentials.currentStart(taskId);
|
|
505
|
+
if (start) credentials.interrupt(start);
|
|
506
|
+
// The task's recovery after a restart ends too.
|
|
507
|
+
credentials.interruptRecovery(taskId);
|
|
508
|
+
return {
|
|
509
|
+
closeCredentials: (runEpoch) =>
|
|
510
|
+
activeCloudCredentials()?.closeAssignment({ taskId, runEpoch }, "stop") ?? Promise.resolve(),
|
|
511
|
+
};
|
|
512
|
+
}
|