@sema-agent/server 7.124.0 → 7.125.0
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/MIGRATION.md +138 -0
- package/USAGE.md +46 -8
- package/dist/audit.d.ts +27 -5
- package/dist/audit.js +42 -8
- package/dist/boot/execution-env.d.ts +33 -1
- package/dist/boot/execution-env.js +31 -2
- package/dist/boot/resolve-spec.js +4 -3
- package/dist/boot/session-faces.d.ts +14 -1
- package/dist/boot/session-faces.js +6 -5
- package/dist/boot/stage-02-web-admin-pull.js +5 -1
- package/dist/boot/stage-05-execution-env.js +6 -2
- package/dist/boot/stage-09-leader.d.ts +1 -1
- package/dist/boot/stage-10-http-server.d.ts +1 -1
- package/dist/boot/web-admin.js +130 -140
- package/dist/capabilities/center-wiring.d.ts +2 -1
- package/dist/capabilities/composite-members.d.ts +1 -0
- package/dist/capabilities/composite-members.js +1 -0
- package/dist/capability-facts.d.ts +28 -3
- package/dist/capability-facts.js +20 -0
- package/dist/config-catalog.js +4 -1
- package/dist/config-d-migration.js +2 -1
- package/dist/config-lkg.d.ts +4 -1
- package/dist/config-lkg.js +16 -12
- package/dist/config-provider.d.ts +4 -1
- package/dist/config-provider.js +1 -0
- package/dist/config-types.d.ts +10 -3
- package/dist/config.js +44 -14
- package/dist/env-facts.d.ts +2 -2
- package/dist/env-facts.js +3 -2
- package/dist/fleet/fleet-bus.d.ts +24 -12
- package/dist/fleet/fleet-bus.js +4 -1
- package/dist/fleet/workspace-snapshot-residual.d.ts +10 -0
- package/dist/fleet/workspace-snapshot-residual.js +35 -0
- package/dist/hooks/hook-runner.d.ts +15 -27
- package/dist/hooks/hook-runner.js +19 -59
- package/dist/http/active-run-conflict.d.ts +3 -0
- package/dist/http/active-run-conflict.js +3 -0
- package/dist/http/admission.js +7 -7
- package/dist/http/approval-sweeps.js +2 -4
- package/dist/http/device-placement.js +1 -0
- package/dist/http/dispatch.js +1 -1
- package/dist/http/durable-run-leg.d.ts +20 -6
- package/dist/http/durable-run-leg.js +4 -4
- package/dist/http/idempotency.d.ts +83 -38
- package/dist/http/idempotency.js +38 -13
- package/dist/http/request-guards.d.ts +18 -0
- package/dist/http/request-guards.js +12 -3
- package/dist/http/resume-legs.js +7 -1
- package/dist/http/route-ctx.d.ts +3 -3
- package/dist/http/route-ctx.js +1 -1
- package/dist/http/routes/a2a-serve.js +27 -11
- package/dist/http/routes/capabilities.js +1 -0
- package/dist/http/routes/fleet.js +2 -0
- package/dist/http/routes/images.js +2 -2
- package/dist/http/routes/leader.d.ts +4 -0
- package/dist/http/routes/leader.js +13 -3
- package/dist/http/routes/observability.js +2 -0
- package/dist/http/routes/runs.d.ts +6 -2
- package/dist/http/routes/runs.js +71 -101
- package/dist/http/routes/session-sync.d.ts +3 -0
- package/dist/http/routes/session-sync.js +30 -1
- package/dist/http/routes/sessions-list.d.ts +11 -0
- package/dist/http/routes/sessions-list.js +6 -6
- package/dist/http/routes/sessions.d.ts +6 -1
- package/dist/http/routes/sessions.js +41 -10
- package/dist/http/routes/tasks.js +63 -21
- package/dist/http/run-read-face.d.ts +26 -0
- package/dist/http/run-read-face.js +67 -0
- package/dist/http/server.d.ts +4 -4
- package/dist/http/server.js +1 -2
- package/dist/http/submit-replay.d.ts +40 -0
- package/dist/http/submit-replay.js +22 -0
- package/dist/leader/endpoint.d.ts +10 -5
- package/dist/leader/endpoint.js +27 -5
- package/dist/observability/cost-quota.d.ts +51 -7
- package/dist/observability/cost-quota.js +35 -17
- package/dist/observability/fail-open.d.ts +1 -1
- package/dist/observability/fail-open.js +1 -1
- package/dist/observability/run-terminal.d.ts +5 -9
- package/dist/observability/run-terminal.js +2 -7
- package/dist/plugins/approval-ask-store-sql.js +1 -1
- package/dist/plugins/caching-session-store.d.ts +7 -2
- package/dist/plugins/caching-session-store.js +6 -0
- package/dist/plugins/device-store-sql.js +1 -1
- package/dist/plugins/ensure-index.js +22 -0
- package/dist/plugins/file-run-store.d.ts +24 -14
- package/dist/plugins/file-run-store.js +52 -12
- package/dist/plugins/leader-run-store-sql.d.ts +1 -11
- package/dist/plugins/leader-run-store-sql.js +1 -5
- package/dist/plugins/local-session-store.d.ts +35 -6
- package/dist/plugins/local-session-store.js +87 -18
- package/dist/plugins/memory-run-store.d.ts +13 -12
- package/dist/plugins/memory-run-store.js +25 -0
- package/dist/plugins/one-time-migrations.js +1 -1
- package/dist/plugins/pg-cost-quota.d.ts +1 -1
- package/dist/plugins/pg-cost-quota.js +2 -5
- package/dist/plugins/pg-pool.js +3 -0
- package/dist/plugins/pg-session-storage.d.ts +11 -1
- package/dist/plugins/pg-session-storage.js +44 -8
- package/dist/plugins/remote-env-k8s.d.ts +127 -12
- package/dist/plugins/remote-env-k8s.js +270 -27
- package/dist/plugins/run-store-sql.d.ts +26 -12
- package/dist/plugins/run-store-sql.js +50 -25
- package/dist/plugins/schema-consistency.d.ts +1 -13
- package/dist/plugins/schema-consistency.js +1 -41
- package/dist/plugins/schema-indexes.js +1 -0
- package/dist/plugins/schema-live-columns.d.ts +20 -0
- package/dist/plugins/schema-live-columns.js +42 -0
- package/dist/plugins/store-contracts.d.ts +40 -2
- package/dist/plugins/tidb-cost-quota.d.ts +1 -1
- package/dist/plugins/tidb-cost-quota.js +2 -5
- package/dist/plugins/tidb-pool.js +14 -3
- package/dist/plugins/tidb-session-store.d.ts +22 -2
- package/dist/plugins/tidb-session-store.js +33 -6
- package/dist/plugins/write-behind-counter.d.ts +12 -5
- package/dist/plugins/write-behind-counter.js +14 -7
- package/dist/prompts-domain-validate.js +3 -2
- package/dist/rules-consent.d.ts +2 -2
- package/dist/rules-consent.js +2 -2
- package/dist/run-local.js +31 -23
- package/dist/runs.d.ts +9 -0
- package/dist/runs.js +8 -0
- package/dist/runtime-governance.d.ts +3 -3
- package/dist/runtime-governance.js +2 -4
- package/dist/security.d.ts +19 -2
- package/dist/session-row.d.ts +53 -0
- package/dist/session-row.js +47 -0
- package/dist/session-sync.d.ts +6 -0
- package/dist/session-sync.js +5 -1
- package/dist/session-titler.d.ts +9 -5
- package/dist/session-titler.js +3 -1
- package/dist/spec-fields.js +2 -2
- package/dist/task-cwd.d.ts +0 -20
- package/dist/task-cwd.js +2 -10
- package/dist/tool-approval.d.ts +5 -7
- package/dist/trace/redact.d.ts +23 -50
- package/dist/trace/redact.js +58 -18
- package/dist/trace/self-minted-wire.d.ts +25 -13
- package/dist/trace/self-minted-wire.js +9 -4
- package/dist/web-admin-client/apply-effective.d.ts +10 -12
- package/dist/web-admin-client/apply-effective.js +2 -3
- package/dist/web-admin-client/candidate-gate.d.ts +69 -0
- package/dist/web-admin-client/candidate-gate.js +27 -0
- package/dist/web-admin-client/http-client.d.ts +2 -1
- package/dist/web-admin-client/http-client.js +2 -1
- package/dist/web-admin-client/pull-state.d.ts +6 -4
- package/dist/web-admin-client/read-warnings.d.ts +29 -5
- package/dist/web-admin-client/read-warnings.js +24 -4
- package/dist/web-admin-client/stage-limits.js +4 -1
- package/dist/workspace-space.d.ts +41 -0
- package/dist/workspace-space.js +5 -0
- package/package.json +3 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WorkspaceSyncCapability } from "../workspace-space.js";
|
|
1
2
|
import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, type BackgroundShellCapability, type BackgroundShellId, type BackgroundShellError, type BackgroundSpawnOptions, type BackgroundPoll } from "@sema-agent/core";
|
|
2
3
|
import type { PodSpecPatch } from "./k8s-exec-protocol.js";
|
|
3
4
|
type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
|
|
@@ -27,7 +28,8 @@ export interface K8sEnvConfig {
|
|
|
27
28
|
homeDir?: string;
|
|
28
29
|
/** Workspace root inside the pod; created on connect. Default "/workspace". */
|
|
29
30
|
mountPath?: string;
|
|
30
|
-
/** Pod lifetime (activeDeadlineSeconds) — the leak guard
|
|
31
|
+
/** Pod lifetime (`activeDeadlineSeconds`; env `K8S_TIMEOUT_MS`) — the whole-pod leak guard, and the ceiling background-shell
|
|
32
|
+
* timeouts are clamped to. NOT a per-command timeout: each command's bound comes from its tool call. Default 30min. */
|
|
31
33
|
timeoutMs?: number;
|
|
32
34
|
/** Pod schedule+pull+start bound. Default 120s (image pull can be slow once per node). */
|
|
33
35
|
readyTimeoutMs?: number;
|
|
@@ -55,6 +57,12 @@ export interface K8sEnvConfig {
|
|
|
55
57
|
* (never the access/secret key) — a credential reaching code that runs untrusted work is the [ref] lethal
|
|
56
58
|
* trifecta. The presigned URL is used only inside an adapter-issued exec at suspend/resume time, so it never
|
|
57
59
|
* reaches an agent turn.
|
|
60
|
+
*
|
|
61
|
+
* WORKSPACE SPACE (`workspacePersist`, default `off` — opt-in `space`): the same store also carries the workspace ACROSS
|
|
62
|
+
* RUNS, in its own sub-prefix `<prefix>/<spaceIncarnation>/space/` — `space/latest` (body = the newest snapshotId) names the
|
|
63
|
+
* snapshot `connect()` restores; `destroy()` snapshots into `space/`, moves `latest`, keeps the newest `workspaceSnapshotKeep`
|
|
64
|
+
* (the same one upload / one download as suspend / resume, see `SnapshotTarget`). The pointer read / write, the listing and the deletes are
|
|
65
|
+
* WORKER-side requests to `endpoint` (the snapshot-BLOB offload dials the same `MINIO_ENDPOINT` from the worker).
|
|
58
66
|
*/
|
|
59
67
|
s3Snapshot?: {
|
|
60
68
|
endpoint: string;
|
|
@@ -64,9 +72,36 @@ export interface K8sEnvConfig {
|
|
|
64
72
|
region?: string;
|
|
65
73
|
keyPrefix?: string;
|
|
66
74
|
presignTtlSec?: number;
|
|
75
|
+
/** The workspace-space policy (the capability fact `workspaceSpace`'s witness, handed in by the factory; the adapter
|
|
76
|
+
* never resolves defaults itself). Absent or `persist: "off"` ⇒ no persistence across runs. */
|
|
77
|
+
space?: WorkspaceSyncCapability;
|
|
67
78
|
};
|
|
68
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* The PARK lane's partition (`<prefix>/<sessionId>/<snapshotId>.tar.gz`), set by {@link k8sExecutionEnvFactory} from
|
|
81
|
+
* `ExecutionEnvFactoryContext.sessionId` — the key form parked runs have always used, so stored parks stay readable.
|
|
82
|
+
* Why the session id is right HERE: a park archive is only ever read back by its own parked run's checkpoint (the
|
|
83
|
+
* snapshotId lives in that checkpoint; deleting the session deletes its checkpoints) — the partition only namespaces,
|
|
84
|
+
* it never decides who reads. Segment shape is validated in exactly one place ({@link RemoteK8sExecutionEnv.presignSnapshot}).
|
|
85
|
+
*/
|
|
69
86
|
sessionId?: string;
|
|
87
|
+
/**
|
|
88
|
+
* The SPACE lane's partition (`<prefix>/<spaceIncarnation>/space/…`): the session OCCUPANT's incarnation token
|
|
89
|
+
* (`session-incarnation.ts`, the one owner of "is this the same occupant"), handed in per run by the factory binding
|
|
90
|
+
* (`boot/execution-env.ts`) from the same seat the memory engine reads. Why not the session id: the space lane is read
|
|
91
|
+
* by whoever runs next in the session, so its partition decides WHO reads — a session id deleted and registered again
|
|
92
|
+
* (by anyone) is a new occupant and starts from an empty workspace. Absent while persistence is on ⇒ the space lane
|
|
93
|
+
* does not open for this env (connect restores nothing, destroy saves nothing) and one `workspace_space_unavailable`
|
|
94
|
+
* line says so — never a fallback to the session id. Never logged whole: operator lines carry `incarnationLogPrefix`.
|
|
95
|
+
*/
|
|
96
|
+
spaceIncarnation?: string;
|
|
97
|
+
/** Operator log seat (the e2b adapter's shape): `workspace_snapshot_failed` / `workspace_snapshot_prune_failed` /
|
|
98
|
+
* `workspace_space_unavailable` / `workspace_commit_abandoned` / `workspace_commit_marker_stuck`. */
|
|
99
|
+
logger?: {
|
|
100
|
+
warn(event: string, fields?: Record<string, unknown>): void;
|
|
101
|
+
};
|
|
102
|
+
/** Run-end outcome seat — called once per `destroy()` that attempted a space snapshot (the factory's caller binds it
|
|
103
|
+
* to the run, so the run's terminal frame can carry `workspaceSnapshot: "failed"`). */
|
|
104
|
+
onWorkspaceSnapshot?: (outcome: "saved" | "failed") => void;
|
|
70
105
|
/**
|
|
71
106
|
* 🔴 ADDITIVE pod-spec patch (a SERVICE-side adapter seam, NOT a core seam: the K8s pod shape is
|
|
72
107
|
* provider-specific, so it stays in the adapter per [ref]). A deployment supplies extra volumes / volumeMounts
|
|
@@ -112,6 +147,8 @@ export interface K8sEnvDeps {
|
|
|
112
147
|
}>;
|
|
113
148
|
openExec?: (url: string, headers: Record<string, string>) => ExecSocket;
|
|
114
149
|
connectOnce?: (signal?: AbortSignal) => Promise<ConnectResult>;
|
|
150
|
+
/** Worker-side object-store requests (the space pointer, listing, deletes). Default: global `fetch`. */
|
|
151
|
+
fetch?: typeof fetch;
|
|
115
152
|
}
|
|
116
153
|
export { execSocketTlsOptions } from "./k8s-exec-protocol.js";
|
|
117
154
|
/** `Error`-or-event → message. Bun's ws shim emits browser-style ErrorEvents (NOT instanceof Error) whose
|
|
@@ -150,6 +187,12 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
|
|
|
150
187
|
readonly homeDir?: string | undefined;
|
|
151
188
|
private readonly cfg;
|
|
152
189
|
private readonly deps;
|
|
190
|
+
/** This env's space lane (resolved once from the policy + the occupant's incarnation; see {@link SpaceLane}). */
|
|
191
|
+
private readonly spaceLane;
|
|
192
|
+
/** `workspace_space_unavailable` is said once per env. */
|
|
193
|
+
private spaceUnavailableSaid;
|
|
194
|
+
private readonly logger?;
|
|
195
|
+
private readonly onWorkspaceSnapshot?;
|
|
153
196
|
constructor(config: K8sEnvConfig, deps?: K8sEnvDeps);
|
|
154
197
|
/** 166-T2 — ExecutionEnv 寿命声明:pod `activeDeadlineSeconds = ceil(timeoutMs/1000)` 是平台真硬死线
|
|
155
198
|
* (k8s 到点杀 pod,无 keepalive 续期机制)⇒ 恒声明,core 在死线−60s 处 suspend(基建合格)或响亮
|
|
@@ -163,7 +206,23 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
|
|
|
163
206
|
/** Control-plane REST call (pod create/get/delete), bounded by rpcTimeoutMs. Injectable for tests. */
|
|
164
207
|
private request;
|
|
165
208
|
private openExec;
|
|
209
|
+
/**
|
|
210
|
+
* Connect = the workspace SPACE's restore point. Space lane open ⇒ first wait out a commit still in flight
|
|
211
|
+
* ({@link awaitSpaceCommit}), then read `<prefix>/<incarnation>/space/latest`: present ⇒
|
|
212
|
+
* restore it through {@link restoreSnapshot} (the one tar download, shared with `resumeVM`; a missing archive is
|
|
213
|
+
* refused); absent (404) ⇒ a fresh pod; a read that FAILS ⇒ a loud
|
|
214
|
+
* `connect_failed` — never a silent fresh workspace (a run that believes it resumed while it did not is the worst form).
|
|
215
|
+
* Persistence off / no store ⇒ a fresh pod, exactly as before. Persistence on but the occupant unknown ⇒ a fresh pod
|
|
216
|
+
* and one `workspace_space_unavailable` line (no restore by any other key).
|
|
217
|
+
*/
|
|
166
218
|
connect(config?: RemoteConnectConfig): Promise<ConnectResult>;
|
|
219
|
+
/** The space lane is on but this env's occupant is unknown — said once per env (at connect, or at a destroy with a pod). */
|
|
220
|
+
private sayUnavailable;
|
|
221
|
+
/** Every space-lane operator line: the space is named by its incarnation's log prefix only, and free text (a curl
|
|
222
|
+
* stderr, a fetch error) passes {@link redactIncarnations} — the full token never reaches a log body. */
|
|
223
|
+
private spaceLine;
|
|
224
|
+
/** A fresh pod (create → Running → workspace root), bounded retry. Shared by {@link connect} and {@link resumeVM}. */
|
|
225
|
+
private provision;
|
|
167
226
|
/**
|
|
168
227
|
* Lazy-connect on first use — 🔴 core NEVER calls `connect()` (verified: the Runner only calls `resumeVM` on
|
|
169
228
|
* the resume path), so an adapter that demands an explicit connect can never work through the production
|
|
@@ -173,14 +232,45 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
|
|
|
173
232
|
private ensureConnected;
|
|
174
233
|
private connectOnce;
|
|
175
234
|
/**
|
|
176
|
-
* Build a validated, presigned URL for a snapshot object —
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
235
|
+
* Build a validated, presigned URL for a snapshot object of either lane (see {@link SnapshotTarget}) — the ONE place a
|
|
236
|
+
* snapshot object key is spelled, and the one place each lane's partition is chosen (park = the session id, space = the
|
|
237
|
+
* occupant's incarnation carried by the request).
|
|
238
|
+
* 🔴 the park partition is the session id, which comes from the client (`body.sessionId`, security.ts) and is NOT
|
|
239
|
+
* charset-validated upstream, so each path segment is allowlisted here (`^[A-Za-z0-9][A-Za-z0-9._-]*$`) before it enters
|
|
240
|
+
* the object key: a segment of `..` / `../x` / one containing `/` would otherwise let one tenant's snapshot key normalize into
|
|
241
|
+
* another tenant's prefix (cross-tenant read on resume / clobber on suspend). The space partition (a minted v4 uuid) goes
|
|
242
|
+
* through the same one allowlist. Returns a typed error instead of signing a traversing key. Folds the old
|
|
243
|
+
* snapshotKey()+s3Presign() pair so the two call sites can't diverge.
|
|
182
244
|
*/
|
|
183
245
|
private presignSnapshot;
|
|
246
|
+
/** Worker-side object-store request, bounded by the control-plane RPC wall-clock — or a tighter caller deadline — and
|
|
247
|
+
* the caller's abort. */
|
|
248
|
+
private storeFetch;
|
|
249
|
+
/** Read one space pointer object: its trimmed body, or `undefined` = absent (404); any other failure is a typed
|
|
250
|
+
* `connect_failed` naming the pointer. */
|
|
251
|
+
private readPointer;
|
|
252
|
+
/** Read the space's `latest` pointer. The body's shape is checked where it is used — restoreSnapshot → presignSnapshot
|
|
253
|
+
* (the one segment check). */
|
|
254
|
+
private readLatest;
|
|
255
|
+
/** PUT one space pointer object (a single-object PUT replaces the object atomically) or DELETE it; `timeoutMs` = a
|
|
256
|
+
* caller deadline tighter than the RPC bound. */
|
|
257
|
+
private writePointer;
|
|
258
|
+
/**
|
|
259
|
+
* Wait out the previous run's space commit. `space/committing` present and younger than the upload's own wall-clock
|
|
260
|
+
* bound (`dataTimeoutMs` — the SAME number `uploadSnapshot` gives its tar + curl exec) ⇒ re-read every
|
|
261
|
+
* {@link SPACE_COMMIT_POLL_MS} until it is gone or has aged past that bound. A marker at / past the bound = a commit
|
|
262
|
+
* whose worker died inside it (a live upload fails at the bound and clears its marker) ⇒ one
|
|
263
|
+
* `workspace_commit_abandoned` line; `latest` still names the previous COMPLETE snapshot. A marker read that fails, or
|
|
264
|
+
* a body that is not an epoch-ms timestamp, is a loud `connect_failed` (the same form as a failed `latest` read).
|
|
265
|
+
*/
|
|
266
|
+
private awaitSpaceCommit;
|
|
267
|
+
/**
|
|
268
|
+
* Keep the newest `keep` snapshots of this space (the one `latest` now names always among them); delete the rest.
|
|
269
|
+
* Works inside the `space/` sub-prefix only — park archives are never in its view. The bucket listing is the only
|
|
270
|
+
* record (no index). A failure loses nothing (the space just keeps more objects) —
|
|
271
|
+
* it is reported, never thrown.
|
|
272
|
+
*/
|
|
273
|
+
private pruneSpace;
|
|
184
274
|
/** Redact a presigned URL (and any lingering signature) from text before it enters an error/log — the URL is a
|
|
185
275
|
* short-lived bearer credential and must never reach a durable store (secret boundary). */
|
|
186
276
|
private redactUrl;
|
|
@@ -200,10 +290,15 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
|
|
|
200
290
|
ok: false;
|
|
201
291
|
error: RemoteExecutionError;
|
|
202
292
|
}>;
|
|
293
|
+
/**
|
|
294
|
+
* The ONE tar upload (both lanes): tar the workspace to a pod-local file, presigned PUT into the lane's namespace,
|
|
295
|
+
* release the pod. `suspendVM` = lane `park`; the run-end space snapshot = lane `space` (its open space).
|
|
296
|
+
*/
|
|
297
|
+
private uploadSnapshot;
|
|
203
298
|
/**
|
|
204
299
|
* Resume a workspace snapshot onto a FRESH pod (resume = executionEnvFactory rebuild + resumeVM):
|
|
205
|
-
* provision a pod, then download + untar the snapshot into the workspace
|
|
206
|
-
* snapshot) resumes as a clean workspace.
|
|
300
|
+
* provision a pod, then download + untar the snapshot into the workspace ({@link restoreSnapshot}, lane `park`). A
|
|
301
|
+
* missing object (empty/plan-only snapshot) resumes as a clean workspace.
|
|
207
302
|
*
|
|
208
303
|
* [ref]①(priorHandle 根保真)——k8s 走**诚实臂**而非绑定臂:pod 的 workspace volume 挂在**当前**
|
|
209
304
|
* 配置根(cfg.mountPath),往 priorHandle.mountPath 绑=往容器 rootfs untar 大工作区,ephemeral-storage
|
|
@@ -212,6 +307,14 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
|
|
|
212
307
|
* 变更窗的分歧由 core 侧 fail-closed reopen 兜底,审批不烧错根。
|
|
213
308
|
*/
|
|
214
309
|
resumeVM(snapshotId: SnapshotId, options?: VmLifecycleOptions): Promise<ConnectResult>;
|
|
310
|
+
/**
|
|
311
|
+
* The ONE tar download (both lanes): provision a FRESH pod, download + untar the lane's archive into the workspace.
|
|
312
|
+
* `missing: "empty"` (park: a plan-only suspend has no object) tolerates a 404 as a clean workspace; `missing:
|
|
313
|
+
* "refuse"` (space: its pointer is only ever written after a successful upload ⇒ a 404 there is lost data) refuses it.
|
|
314
|
+
* A restore that does not complete releases the pod it provisioned: no tool and no run-end snapshot may ever see a
|
|
315
|
+
* torn or empty-by-accident workspace.
|
|
316
|
+
*/
|
|
317
|
+
private restoreSnapshot;
|
|
215
318
|
/**
|
|
216
319
|
* Honest post-resume consistency ([ref] §5 council #8): the workspace FILES are restored byte-for-byte
|
|
217
320
|
* from the snapshot, so read-before-edit hashing already lines up — there's nothing to re-fetch. In-VM
|
|
@@ -227,9 +330,21 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
|
|
|
227
330
|
}>;
|
|
228
331
|
reconnect(_sessionToken: SessionToken): Promise<ConnectResult>;
|
|
229
332
|
private deletePod;
|
|
230
|
-
/**
|
|
333
|
+
/**
|
|
334
|
+
* Delete the sandbox pod. Idempotent, never throws. Space lane open and a pod exists ⇒ FIRST the run-end space
|
|
335
|
+
* snapshot ({@link persistWorkspace}); its failure is loud and never blocks the delete (the pod lifetime is the
|
|
336
|
+
* leak guard). A run that never connected has no workspace to save — `latest` stays where it was. Space lane
|
|
337
|
+
* unavailable (occupant unknown) ⇒ nothing is saved (the once-per-env line says so).
|
|
338
|
+
*/
|
|
231
339
|
destroy(): Promise<void>;
|
|
232
340
|
cleanup(): Promise<void>;
|
|
341
|
+
/**
|
|
342
|
+
* Run-end space snapshot: PUT the `committing` marker → the one tar upload ({@link uploadSnapshot}, lane `space` — it
|
|
343
|
+
* also releases the pod) → move `latest` → keep N → DELETE the marker (always, saved or failed).
|
|
344
|
+
* Upload or pointer failure ⇒ one `workspace_snapshot_failed` line + the `failed` outcome; `latest` keeps naming the
|
|
345
|
+
* previous snapshot, so the next run restores THAT — never an empty workspace dressed up as a restore.
|
|
346
|
+
*/
|
|
347
|
+
private persistWorkspace;
|
|
233
348
|
private execUrl;
|
|
234
349
|
/**
|
|
235
350
|
* Run a command over the exec WebSocket. Frames: byte0=channel (1 stdout / 2 stderr / 3 status JSON with the
|
|
@@ -347,5 +462,5 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
|
|
|
347
462
|
* created lazily on connect and deleted by `destroy()` (Runner-owned lifetime; `activeDeadlineSeconds` is the
|
|
348
463
|
* leak guard if destroy never runs).
|
|
349
464
|
*/
|
|
350
|
-
export declare function k8sExecutionEnvFactory(config: K8sEnvConfig): ExecutionEnvFactory;
|
|
465
|
+
export declare function k8sExecutionEnvFactory(config: K8sEnvConfig, deps?: K8sEnvDeps): ExecutionEnvFactory;
|
|
351
466
|
//# sourceMappingURL=remote-env-k8s.d.ts.map
|
|
@@ -5,7 +5,8 @@ import http from "node:http";
|
|
|
5
5
|
import { StringDecoder } from "node:string_decoder";
|
|
6
6
|
import WebSocket from "ws";
|
|
7
7
|
import { buildCwdEnvCommand, createCwdGuardedCall, shellQuote, kindFromMode, numEnvOr } from "./remote-shell.js";
|
|
8
|
-
import { presignS3Url } from "./s3-presign.js";
|
|
8
|
+
import { presignS3Url, presignS3ListUrl } from "./s3-presign.js";
|
|
9
|
+
import { incarnationLogPrefix, redactIncarnations } from "../session-incarnation.js";
|
|
9
10
|
import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
|
|
10
11
|
import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
|
|
11
12
|
import { createPosixShellFs } from "./posix-shell-fs.js";
|
|
@@ -31,6 +32,21 @@ const EXEC_SUBPROTOCOLS = ["v5.channel.k8s.io", "v4.channel.k8s.io"];
|
|
|
31
32
|
const WRITE_CHUNK_BYTES = 64 * 1024;
|
|
32
33
|
import { applyPodSpecPatch } from "./k8s-exec-protocol.js";
|
|
33
34
|
export { applyPodSpecPatch } from "./k8s-exec-protocol.js";
|
|
35
|
+
function spaceLaneOf(policy, incarnation) {
|
|
36
|
+
if (policy === undefined)
|
|
37
|
+
return { state: "off" };
|
|
38
|
+
switch (policy.persist) {
|
|
39
|
+
case "off":
|
|
40
|
+
return { state: "off" };
|
|
41
|
+
case "space":
|
|
42
|
+
return incarnation ? { state: "open", space: { incarnation, keep: policy.keep } } : { state: "unavailable" };
|
|
43
|
+
default: {
|
|
44
|
+
const unreachable = policy.persist;
|
|
45
|
+
throw new Error(`unhandled WORKSPACE_PERSIST word: ${String(unreachable)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const SPACE_COMMIT_POLL_MS = 1000;
|
|
34
50
|
import { execSocketTlsOptions, exitCodeFromStatus } from "./k8s-exec-protocol.js";
|
|
35
51
|
export { execSocketTlsOptions } from "./k8s-exec-protocol.js";
|
|
36
52
|
export function errorMessageOf(e) {
|
|
@@ -52,6 +68,10 @@ export class RemoteK8sExecutionEnv {
|
|
|
52
68
|
homeDir;
|
|
53
69
|
cfg;
|
|
54
70
|
deps;
|
|
71
|
+
spaceLane;
|
|
72
|
+
spaceUnavailableSaid = false;
|
|
73
|
+
logger;
|
|
74
|
+
onWorkspaceSnapshot;
|
|
55
75
|
constructor(config, deps) {
|
|
56
76
|
this.cfg = {
|
|
57
77
|
apiUrl: config.apiUrl,
|
|
@@ -78,6 +98,9 @@ export class RemoteK8sExecutionEnv {
|
|
|
78
98
|
this.cwd = this.cfg.mountPath;
|
|
79
99
|
this.homeDir = config.homeDir;
|
|
80
100
|
this.deps = deps ?? {};
|
|
101
|
+
this.spaceLane = spaceLaneOf(config.s3Snapshot?.space, config.spaceIncarnation);
|
|
102
|
+
this.logger = config.logger;
|
|
103
|
+
this.onWorkspaceSnapshot = config.onWorkspaceSnapshot;
|
|
81
104
|
this.capabilities = { isolation: true, suspendable: !!this.cfg.s3Snapshot, canonicalPathAuthoritative: true };
|
|
82
105
|
const bgMax = Math.max(1, Math.min(K8S_BG_MAX_TIMEOUT_SEC, Math.floor(this.cfg.timeoutMs / 1000)));
|
|
83
106
|
this.backgroundCapabilities = {
|
|
@@ -177,13 +200,53 @@ export class RemoteK8sExecutionEnv {
|
|
|
177
200
|
});
|
|
178
201
|
}
|
|
179
202
|
async connect(config) {
|
|
203
|
+
const signal = config?.abortSignal;
|
|
204
|
+
const lane = this.spaceLane;
|
|
205
|
+
switch (lane.state) {
|
|
206
|
+
case "off":
|
|
207
|
+
return this.provision(signal);
|
|
208
|
+
case "unavailable":
|
|
209
|
+
this.sayUnavailable();
|
|
210
|
+
return this.provision(signal);
|
|
211
|
+
case "open": {
|
|
212
|
+
const settled = await this.awaitSpaceCommit(lane.space, signal);
|
|
213
|
+
if (!settled.ok)
|
|
214
|
+
return settled;
|
|
215
|
+
const latest = await this.readLatest(lane.space, signal);
|
|
216
|
+
if (!latest.ok)
|
|
217
|
+
return latest;
|
|
218
|
+
if (latest.value === undefined)
|
|
219
|
+
return this.provision(signal);
|
|
220
|
+
return this.restoreSnapshot({ lane: "space", space: lane.space }, latest.value, { missing: "refuse", ...(signal ? { abortSignal: signal } : {}) });
|
|
221
|
+
}
|
|
222
|
+
default: {
|
|
223
|
+
const unreachable = lane;
|
|
224
|
+
throw new Error(`unhandled space lane state: ${String(unreachable.state)}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
sayUnavailable() {
|
|
229
|
+
if (this.spaceUnavailableSaid)
|
|
230
|
+
return;
|
|
231
|
+
this.spaceUnavailableSaid = true;
|
|
232
|
+
this.logger?.warn("workspace_space_unavailable", {
|
|
233
|
+
reason: "incarnation_unknown",
|
|
234
|
+
sessionId: this.cfg.sessionId,
|
|
235
|
+
effect: "WORKSPACE_PERSIST=space, but this run's session occupant (incarnation) was not read on this replica — its /workspace is neither restored nor saved (it is never keyed by the session id instead)",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
spaceLine(event, space, fields) {
|
|
239
|
+
const safe = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, typeof v === "string" ? redactIncarnations(v) : v]));
|
|
240
|
+
this.logger?.warn(event, { space: incarnationLogPrefix(space.incarnation), ...safe });
|
|
241
|
+
}
|
|
242
|
+
provision(signal) {
|
|
180
243
|
const policy = {
|
|
181
244
|
retryableCodes: ["connect_failed", "timeout", "transport_lost"],
|
|
182
245
|
maxAttempts: this.cfg.retry.maxAttempts,
|
|
183
246
|
backoffMs: (failed) => this.cfg.retry.backoffMs * failed,
|
|
184
247
|
};
|
|
185
248
|
const once = this.deps.connectOnce ?? ((s) => this.connectOnce(s));
|
|
186
|
-
return withRetry(() => once(
|
|
249
|
+
return withRetry(() => once(signal), policy, { signal });
|
|
187
250
|
}
|
|
188
251
|
async ensureConnected() {
|
|
189
252
|
if (this.podName)
|
|
@@ -301,28 +364,138 @@ export class RemoteK8sExecutionEnv {
|
|
|
301
364
|
}
|
|
302
365
|
return ok(this.workspaceHandle());
|
|
303
366
|
}
|
|
304
|
-
presignSnapshot(
|
|
367
|
+
presignSnapshot(req) {
|
|
305
368
|
const s3 = this.cfg.s3Snapshot;
|
|
306
369
|
const prefix = s3.keyPrefix ?? "kata";
|
|
307
|
-
const
|
|
370
|
+
const [label, partition] = req.lane === "space" ? ["spaceIncarnation", req.space.incarnation] : ["sessionId", this.cfg.sessionId ?? "_"];
|
|
371
|
+
const snapshotId = "snapshotId" in req ? req.snapshotId : undefined;
|
|
308
372
|
const safe = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
309
|
-
|
|
373
|
+
const segments = [[label, partition], ...(snapshotId !== undefined ? [["snapshotId", snapshotId]] : []), ["keyPrefix", prefix]];
|
|
374
|
+
for (const [label, seg] of segments) {
|
|
310
375
|
if (!safe.test(seg))
|
|
311
376
|
return { ok: false, error: new RemoteExecutionError("connect_failed", `unsafe snapshot key segment (${label}): refusing to build a traversing object key`) };
|
|
312
377
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
accessKey: s3.accessKey,
|
|
321
|
-
secretKey: s3.secretKey,
|
|
322
|
-
...(s3.region ? { region: s3.region } : {}),
|
|
323
|
-
expiresSec: Math.min(s3.presignTtlSec ?? 600, 3600),
|
|
324
|
-
}),
|
|
378
|
+
const common = {
|
|
379
|
+
endpoint: s3.endpoint,
|
|
380
|
+
bucket: s3.bucket,
|
|
381
|
+
accessKey: s3.accessKey,
|
|
382
|
+
secretKey: s3.secretKey,
|
|
383
|
+
...(s3.region ? { region: s3.region } : {}),
|
|
384
|
+
expiresSec: Math.min(s3.presignTtlSec ?? 600, 3600),
|
|
325
385
|
};
|
|
386
|
+
const base = req.lane === "space" ? `${prefix}/${partition}/space/` : `${prefix}/${partition}/`;
|
|
387
|
+
if (req.method === "LIST")
|
|
388
|
+
return { ok: true, value: presignS3ListUrl({ ...common, prefix: base, maxKeys: 1000 }) };
|
|
389
|
+
const object = "pointer" in req ? req.pointer : `${req.snapshotId}.tar.gz`;
|
|
390
|
+
return { ok: true, value: presignS3Url({ ...common, key: `${base}${object}`, method: req.method }) };
|
|
391
|
+
}
|
|
392
|
+
storeFetch(url, init) {
|
|
393
|
+
const bound = AbortSignal.timeout(Math.max(1, Math.min(this.cfg.rpcTimeoutMs, init.timeoutMs ?? this.cfg.rpcTimeoutMs)));
|
|
394
|
+
const signal = init.signal ? AbortSignal.any([init.signal, bound]) : bound;
|
|
395
|
+
return (this.deps.fetch ?? fetch)(url, { method: init.method, ...(init.body !== undefined ? { body: init.body } : {}), signal });
|
|
396
|
+
}
|
|
397
|
+
async readPointer(space, pointer, signal) {
|
|
398
|
+
const url = this.presignSnapshot({ lane: "space", space, method: "GET", pointer });
|
|
399
|
+
if (!url.ok)
|
|
400
|
+
return url;
|
|
401
|
+
const what = pointer === "latest" ? "pointer" : "commit marker";
|
|
402
|
+
let res;
|
|
403
|
+
let body;
|
|
404
|
+
try {
|
|
405
|
+
res = await this.storeFetch(url.value, { method: "GET", ...(signal ? { signal } : {}) });
|
|
406
|
+
body = await res.text();
|
|
407
|
+
}
|
|
408
|
+
catch (e) {
|
|
409
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} read failed: ${this.redactUrl(errorMessageOf(e), url.value)}`, e instanceof Error ? e : undefined) };
|
|
410
|
+
}
|
|
411
|
+
if (res.status === 404)
|
|
412
|
+
return ok(undefined);
|
|
413
|
+
if (!res.ok)
|
|
414
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} read failed (HTTP ${res.status})`) };
|
|
415
|
+
return ok(body.trim());
|
|
416
|
+
}
|
|
417
|
+
async readLatest(space, signal) {
|
|
418
|
+
const r = await this.readPointer(space, "latest", signal);
|
|
419
|
+
return r.ok ? ok(r.value === undefined ? undefined : r.value) : r;
|
|
420
|
+
}
|
|
421
|
+
async writePointer(space, pointer, body, timeoutMs) {
|
|
422
|
+
const method = body === null ? "DELETE" : "PUT";
|
|
423
|
+
const url = this.presignSnapshot({ lane: "space", space, method, pointer });
|
|
424
|
+
if (!url.ok)
|
|
425
|
+
return url;
|
|
426
|
+
const what = pointer === "latest" ? "pointer" : "commit marker";
|
|
427
|
+
try {
|
|
428
|
+
const res = await this.storeFetch(url.value, { method, ...(body === null ? {} : { body }), ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
|
|
429
|
+
if (!res.ok && !(method === "DELETE" && res.status === 404))
|
|
430
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} ${method === "PUT" ? "write" : "delete"} failed (HTTP ${res.status})`) };
|
|
431
|
+
}
|
|
432
|
+
catch (e) {
|
|
433
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} ${method === "PUT" ? "write" : "delete"} failed: ${this.redactUrl(errorMessageOf(e), url.value)}`, e instanceof Error ? e : undefined) };
|
|
434
|
+
}
|
|
435
|
+
return ok(undefined);
|
|
436
|
+
}
|
|
437
|
+
async awaitSpaceCommit(space, signal) {
|
|
438
|
+
for (;;) {
|
|
439
|
+
const marker = await this.readPointer(space, "committing", signal);
|
|
440
|
+
if (!marker.ok)
|
|
441
|
+
return marker;
|
|
442
|
+
if (marker.value === undefined)
|
|
443
|
+
return ok(undefined);
|
|
444
|
+
const startedAt = /^\d+$/.test(marker.value) ? Number(marker.value) : NaN;
|
|
445
|
+
if (!Number.isFinite(startedAt))
|
|
446
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", "workspace space commit marker is malformed (its body is not an epoch-ms timestamp) — refusing to guess whether a commit is in flight") };
|
|
447
|
+
const ageMs = Date.now() - startedAt;
|
|
448
|
+
if (ageMs >= this.cfg.dataTimeoutMs) {
|
|
449
|
+
this.spaceLine("workspace_commit_abandoned", space, { ageMs });
|
|
450
|
+
return ok(undefined);
|
|
451
|
+
}
|
|
452
|
+
await new Promise((resolveWait) => {
|
|
453
|
+
const t = setTimeout(resolveWait, SPACE_COMMIT_POLL_MS);
|
|
454
|
+
signal?.addEventListener("abort", () => { clearTimeout(t); resolveWait(); }, { once: true });
|
|
455
|
+
});
|
|
456
|
+
if (signal?.aborted)
|
|
457
|
+
return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted while waiting for the previous run's workspace commit") };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async pruneSpace(space, newest) {
|
|
461
|
+
const list = this.presignSnapshot({ lane: "space", space, method: "LIST" });
|
|
462
|
+
if (!list.ok)
|
|
463
|
+
return;
|
|
464
|
+
const failed = (detail) => this.spaceLine("workspace_snapshot_prune_failed", space, { detail });
|
|
465
|
+
let xml;
|
|
466
|
+
try {
|
|
467
|
+
const res = await this.storeFetch(list.value, { method: "GET" });
|
|
468
|
+
xml = await res.text();
|
|
469
|
+
if (!res.ok)
|
|
470
|
+
return failed(`list HTTP ${res.status}`);
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
return failed(`list: ${this.redactUrl(errorMessageOf(e), list.value)}`);
|
|
474
|
+
}
|
|
475
|
+
if (/<IsTruncated>true<\/IsTruncated>/.test(xml))
|
|
476
|
+
return failed("listing truncated (more than 1000 objects under this space) — refusing to prune from a partial view");
|
|
477
|
+
const tail = ".tar.gz";
|
|
478
|
+
const archives = [...xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)]
|
|
479
|
+
.map((m) => ({ key: /<Key>([^<]*)<\/Key>/.exec(m[1])?.[1] ?? "", at: Date.parse(/<LastModified>([^<]*)<\/LastModified>/.exec(m[1])?.[1] ?? "") }))
|
|
480
|
+
.filter((o) => o.key.endsWith(tail))
|
|
481
|
+
.map((o) => ({ ...o, id: o.key.slice(o.key.lastIndexOf("/") + 1, -tail.length) }));
|
|
482
|
+
const mine = archives.find((o) => o.id === newest);
|
|
483
|
+
if (!mine || !Number.isFinite(mine.at))
|
|
484
|
+
return failed("the snapshot just written is not in the listing (or has no LastModified) — refusing to prune without an age anchor");
|
|
485
|
+
const older = archives.filter((o) => Number.isFinite(o.at) && o.at < mine.at).sort((a, b) => b.at - a.at || (a.key < b.key ? 1 : -1));
|
|
486
|
+
for (const o of older.slice(Math.max(0, space.keep - 1))) {
|
|
487
|
+
const del = this.presignSnapshot({ lane: "space", space, method: "DELETE", snapshotId: o.id });
|
|
488
|
+
if (!del.ok)
|
|
489
|
+
continue;
|
|
490
|
+
try {
|
|
491
|
+
const res = await this.storeFetch(del.value, { method: "DELETE" });
|
|
492
|
+
if (!res.ok && res.status !== 404)
|
|
493
|
+
failed(`delete ${o.id}: HTTP ${res.status}`);
|
|
494
|
+
}
|
|
495
|
+
catch (e) {
|
|
496
|
+
failed(`delete ${o.id}: ${this.redactUrl(errorMessageOf(e), del.value)}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
326
499
|
}
|
|
327
500
|
redactUrl(text, url) {
|
|
328
501
|
return text.split(url).join("<presigned-url>").replace(/X-Amz-Signature=[0-9a-f]+/gi, "X-Amz-Signature=<redacted>");
|
|
@@ -330,12 +503,15 @@ export class RemoteK8sExecutionEnv {
|
|
|
330
503
|
async suspendVM(_options) {
|
|
331
504
|
if (!this.cfg.s3Snapshot)
|
|
332
505
|
return unsupported("suspendVM");
|
|
506
|
+
return this.uploadSnapshot({ lane: "park" });
|
|
507
|
+
}
|
|
508
|
+
async uploadSnapshot(target) {
|
|
333
509
|
if (this.inFlightExecs > 0)
|
|
334
510
|
return { ok: false, error: new RemoteExecutionError("command_in_flight", "cannot suspend while a command is in flight (would snapshot a torn workspace)") };
|
|
335
511
|
const snapshotId = `kata-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
336
512
|
if (!this.podName)
|
|
337
513
|
return ok(snapshotId);
|
|
338
|
-
const presigned = this.presignSnapshot("PUT", snapshotId);
|
|
514
|
+
const presigned = this.presignSnapshot({ ...target, method: "PUT", snapshotId });
|
|
339
515
|
if (!presigned.ok)
|
|
340
516
|
return presigned;
|
|
341
517
|
const url = presigned.value;
|
|
@@ -356,10 +532,16 @@ export class RemoteK8sExecutionEnv {
|
|
|
356
532
|
async resumeVM(snapshotId, options) {
|
|
357
533
|
if (!this.cfg.s3Snapshot)
|
|
358
534
|
return unsupported("resumeVM");
|
|
359
|
-
const
|
|
535
|
+
const r = await this.restoreSnapshot({ lane: "park" }, snapshotId, { missing: "empty", ...(options?.abortSignal ? { abortSignal: options.abortSignal } : {}) });
|
|
536
|
+
if (r.ok)
|
|
537
|
+
this.handleSnapshotId = snapshotId;
|
|
538
|
+
return r.ok ? ok(this.workspaceHandle()) : r;
|
|
539
|
+
}
|
|
540
|
+
async restoreSnapshot(target, snapshotId, opts) {
|
|
541
|
+
const presigned = this.presignSnapshot({ ...target, method: "GET", snapshotId });
|
|
360
542
|
if (!presigned.ok)
|
|
361
543
|
return presigned;
|
|
362
|
-
const c = await this.
|
|
544
|
+
const c = await this.provision(opts.abortSignal);
|
|
363
545
|
if (!c.ok)
|
|
364
546
|
return c;
|
|
365
547
|
const url = presigned.value;
|
|
@@ -368,11 +550,21 @@ export class RemoteK8sExecutionEnv {
|
|
|
368
550
|
`if [ "$code" != "200" ]; then echo "snapshot GET HTTP $code" >&2; exit 22; fi; ` +
|
|
369
551
|
`tar xzf /tmp/ws.tgz -C ${shellQuote(this.cfg.mountPath)} && rm -f /tmp/ws.tgz`;
|
|
370
552
|
const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
553
|
+
const failed = !r.ok
|
|
554
|
+
? new RemoteExecutionError("connect_failed", `workspace snapshot restore failed: ${this.redactUrl(r.error.message, url)}`, r.error)
|
|
555
|
+
: r.value.exitCode !== 0
|
|
556
|
+
? new RemoteExecutionError("connect_failed", `workspace snapshot restore exited ${r.value.exitCode}: ${this.redactUrl(r.value.stderr, url).slice(0, 300)}`)
|
|
557
|
+
: opts.missing === "refuse" && r.value.stdout.includes("EMPTY_SNAPSHOT")
|
|
558
|
+
? new RemoteExecutionError("connect_failed", `workspace snapshot ${snapshotId} is named by the space pointer but its archive is missing (HTTP 404) — refusing to start on an empty workspace (delete the space's latest pointer, or WORKSPACE_PERSIST=off)`)
|
|
559
|
+
: undefined;
|
|
560
|
+
if (failed) {
|
|
561
|
+
const name = this.podName;
|
|
562
|
+
this.podName = undefined;
|
|
563
|
+
this.podIP = undefined;
|
|
564
|
+
if (name)
|
|
565
|
+
await this.deletePod(name).catch(() => { });
|
|
566
|
+
return { ok: false, error: failed };
|
|
567
|
+
}
|
|
376
568
|
return ok(this.workspaceHandle());
|
|
377
569
|
}
|
|
378
570
|
async postResumeInit() {
|
|
@@ -397,6 +589,23 @@ export class RemoteK8sExecutionEnv {
|
|
|
397
589
|
async destroy() {
|
|
398
590
|
await this.disposeBackgroundShells().catch(() => { });
|
|
399
591
|
this.destroyed = true;
|
|
592
|
+
const lane = this.spaceLane;
|
|
593
|
+
if (this.podName) {
|
|
594
|
+
switch (lane.state) {
|
|
595
|
+
case "open":
|
|
596
|
+
await this.persistWorkspace(lane.space);
|
|
597
|
+
break;
|
|
598
|
+
case "unavailable":
|
|
599
|
+
this.sayUnavailable();
|
|
600
|
+
break;
|
|
601
|
+
case "off":
|
|
602
|
+
break;
|
|
603
|
+
default: {
|
|
604
|
+
const unreachable = lane;
|
|
605
|
+
void unreachable;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
400
609
|
const name = this.podName;
|
|
401
610
|
this.podName = undefined;
|
|
402
611
|
this.podIP = undefined;
|
|
@@ -406,6 +615,40 @@ export class RemoteK8sExecutionEnv {
|
|
|
406
615
|
async cleanup() {
|
|
407
616
|
return this.destroy();
|
|
408
617
|
}
|
|
618
|
+
async persistWorkspace(space) {
|
|
619
|
+
const failed = (stage, error) => {
|
|
620
|
+
this.spaceLine("workspace_snapshot_failed", space, { stage, code: error.code, error: error.message });
|
|
621
|
+
this.onWorkspaceSnapshot?.("failed");
|
|
622
|
+
};
|
|
623
|
+
const startedAt = Date.now();
|
|
624
|
+
const marked = await this.writePointer(space, "committing", String(startedAt));
|
|
625
|
+
if (!marked.ok)
|
|
626
|
+
return failed("marker", marked.error);
|
|
627
|
+
try {
|
|
628
|
+
let snap;
|
|
629
|
+
try {
|
|
630
|
+
snap = await this.uploadSnapshot({ lane: "space", space });
|
|
631
|
+
}
|
|
632
|
+
catch (e) {
|
|
633
|
+
return failed("upload", new RemoteExecutionError("unknown", errorMessageOf(e)));
|
|
634
|
+
}
|
|
635
|
+
if (!snap.ok)
|
|
636
|
+
return failed("upload", snap.error);
|
|
637
|
+
const remaining = startedAt + this.cfg.dataTimeoutMs - Date.now();
|
|
638
|
+
if (remaining <= 0)
|
|
639
|
+
return failed("pointer", new RemoteExecutionError("timeout", `the commit outlived its bound (${this.cfg.dataTimeoutMs}ms since its marker) — not publishing latest (a next run may already have restored the previous one)`));
|
|
640
|
+
const moved = await this.writePointer(space, "latest", snap.value, remaining);
|
|
641
|
+
if (!moved.ok)
|
|
642
|
+
return failed("pointer", moved.error);
|
|
643
|
+
this.onWorkspaceSnapshot?.("saved");
|
|
644
|
+
await this.pruneSpace(space, snap.value).catch((e) => this.spaceLine("workspace_snapshot_prune_failed", space, { detail: errorMessageOf(e) }));
|
|
645
|
+
}
|
|
646
|
+
finally {
|
|
647
|
+
const cleared = await this.writePointer(space, "committing", null);
|
|
648
|
+
if (!cleared.ok)
|
|
649
|
+
this.spaceLine("workspace_commit_marker_stuck", space, { error: cleared.error.message });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
409
652
|
execUrl(command) {
|
|
410
653
|
const { apiUrl, token } = this.creds();
|
|
411
654
|
const base = apiUrl.replace(/^http/, "ws");
|
|
@@ -965,7 +1208,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
965
1208
|
return dir ? this.resolve(dir) : undefined;
|
|
966
1209
|
}
|
|
967
1210
|
}
|
|
968
|
-
export function k8sExecutionEnvFactory(config) {
|
|
969
|
-
return (ctx) => new RemoteK8sExecutionEnv({ ...config, sessionId: ctx.sessionId });
|
|
1211
|
+
export function k8sExecutionEnvFactory(config, deps) {
|
|
1212
|
+
return (ctx) => new RemoteK8sExecutionEnv({ ...config, sessionId: ctx.sessionId }, deps);
|
|
970
1213
|
}
|
|
971
1214
|
//# sourceMappingURL=remote-env-k8s.js.map
|