@inflexa-ai/harness 0.33.1 → 0.35.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/dist/agents/conversation-agent.d.ts +7 -3
- package/dist/agents/conversation-agent.js +20 -2
- package/dist/agents/sandbox/shared.d.ts +4 -9
- package/dist/agents/sandbox/shared.js +9 -12
- package/dist/config/environment-stores.d.ts +9 -6
- package/dist/execution/artifact-record.d.ts +9 -5
- package/dist/execution/artifact-record.js +3 -3
- package/dist/execution/reconcile-manifest.js +25 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/prompts/sandbox-standards.d.ts +7 -7
- package/dist/prompts/sandbox-standards.js +6 -6
- package/dist/provenance/collector.js +1 -1
- package/dist/provenance/seam.d.ts +23 -2
- package/dist/provenance/types.d.ts +9 -4
- package/dist/provenance/types.js +7 -2
- package/dist/sandbox/client.d.ts +10 -1
- package/dist/sandbox/create-sandbox.d.ts +12 -0
- package/dist/sandbox/create-sandbox.js +45 -8
- package/dist/sandbox/docker-client.d.ts +11 -0
- package/dist/sandbox/docker-client.js +19 -5
- package/dist/sandbox/farm.d.ts +3 -1
- package/dist/sandbox/farm.js +3 -1
- package/dist/sandbox/image-packages.d.ts +107 -0
- package/dist/sandbox/image-packages.js +107 -0
- package/dist/sandbox/k8s-client.d.ts +11 -0
- package/dist/sandbox/k8s-client.js +20 -4
- package/dist/sandbox/sandbox-error.d.ts +40 -5
- package/dist/sandbox/sandbox-error.js +79 -5
- package/dist/tools/sandbox/list-available-packages.d.ts +18 -16
- package/dist/tools/sandbox/list-available-packages.js +90 -52
- package/dist/tools/workspace/edit-file.d.ts +32 -6
- package/dist/tools/workspace/edit-file.js +101 -15
- package/dist/tools/workspace/mutator.d.ts +68 -32
- package/dist/tools/workspace/mutator.js +203 -55
- package/dist/tools/workspace/run-exec.d.ts +5 -5
- package/dist/tools/workspace/run-exec.js +5 -5
- package/dist/tools/workspace/write-file.d.ts +4 -4
- package/dist/tools/workspace/write-file.js +14 -6
- package/dist/workspace/paths.d.ts +1 -1
- package/dist/workspace/paths.js +1 -1
- package/package.json +1 -1
|
@@ -23,6 +23,7 @@ import { awaitExec } from "./await-exec.js";
|
|
|
23
23
|
import { createDockerSandboxOps } from "./docker-client.js";
|
|
24
24
|
import { createK8sSandboxOps } from "./k8s-client.js";
|
|
25
25
|
import { sandboxWriteTail } from "./mount-plan.js";
|
|
26
|
+
import { SandboxFailure } from "./sandbox-error.js";
|
|
26
27
|
import { submitExec } from "./submit-exec.js";
|
|
27
28
|
import { toPersistedRef } from "./types.js";
|
|
28
29
|
/**
|
|
@@ -73,6 +74,39 @@ export async function precreateStepTree(deps, meta) {
|
|
|
73
74
|
export function createSandboxClient(config) {
|
|
74
75
|
const backend = config.backend ?? config.env.backend;
|
|
75
76
|
const transport = config.transport ?? "poll";
|
|
77
|
+
// Normalized once, here, and handed to the backends and to the client
|
|
78
|
+
// alike: the mount plan and the orient-core prompt then key on one value,
|
|
79
|
+
// and an absent declaration means "store" on both sides.
|
|
80
|
+
const toolchainSource = config.toolchainSource ?? "store";
|
|
81
|
+
// The declared fact needs a gate that can prove it. Each combination below
|
|
82
|
+
// leaves the backend with no gate at all, thus the fact could never be
|
|
83
|
+
// proved and every create would pass an unproved store. The refusal is at
|
|
84
|
+
// composition, where the embedder states the fact — the same rule as the
|
|
85
|
+
// tail-beside-readOnly throw in `mount-plan.ts` — because a contradiction
|
|
86
|
+
// found at the first sandbox costs a whole run. A K8s `per-analysis` farm
|
|
87
|
+
// source with no `libStorePvcRoot` is NOT a contradiction: there the
|
|
88
|
+
// resolver owes the proof, and its refusal already stops the create.
|
|
89
|
+
if (config.packageStore === "required") {
|
|
90
|
+
if (backend === "docker" && !config.libStorePath) {
|
|
91
|
+
throw new Error("createSandboxClient: packageStore is required but no libStorePath is configured — the docker lock gate has no store to prove");
|
|
92
|
+
}
|
|
93
|
+
if (backend === "k8s" && !config.libStorePvc) {
|
|
94
|
+
throw new Error("createSandboxClient: packageStore is required but no libStorePvc is configured — the k8s lock gate has no store to prove");
|
|
95
|
+
}
|
|
96
|
+
if (backend === "k8s" && config.farmSource.kind === "fixed" && !config.libStorePvcRoot) {
|
|
97
|
+
throw new Error("createSandboxClient: packageStore is required but a fixed farm source has no libStorePvcRoot — no resolver and no host-side gate can prove the store");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* What the seam throws for a backend failure: the description of the
|
|
102
|
+
* variant as the message, and the variant itself on `cause`. The `mapErr`
|
|
103
|
+
* happens before the bridge because `toThrowable` passes an `Error`
|
|
104
|
+
* through untouched — thus `unwrapOrThrow` stays the single sanctioned
|
|
105
|
+
* Result→throw bridge, no second bridge is introduced, and the
|
|
106
|
+
* `must-use-result` lint patch keeps recognizing every site. The registry
|
|
107
|
+
* unwraps below stay on the plain bridge: they carry a `DbError`.
|
|
108
|
+
*/
|
|
109
|
+
const failing = (e) => new SandboxFailure(e);
|
|
76
110
|
const registerSandbox = async (meta, ref) => {
|
|
77
111
|
unwrapOrThrow(await setSandboxRef(config.pool, meta.runId, meta.stepId, toPersistedRef(ref), meta.execId ?? null));
|
|
78
112
|
};
|
|
@@ -88,7 +122,8 @@ export function createSandboxClient(config) {
|
|
|
88
122
|
libStorePvc: config.libStorePvc,
|
|
89
123
|
libStorePvcRoot: config.libStorePvcRoot,
|
|
90
124
|
farmSource: config.farmSource,
|
|
91
|
-
toolchainSource
|
|
125
|
+
toolchainSource,
|
|
126
|
+
packageStore: config.packageStore,
|
|
92
127
|
refStorePvc: config.refStorePvc,
|
|
93
128
|
nodeSelector: config.nodeSelector,
|
|
94
129
|
tolerations: config.tolerations,
|
|
@@ -103,7 +138,8 @@ export function createSandboxClient(config) {
|
|
|
103
138
|
resolveWorkspaceRoot: config.resolveWorkspaceRoot,
|
|
104
139
|
libStorePath: config.libStorePath,
|
|
105
140
|
farmSource: config.farmSource,
|
|
106
|
-
toolchainSource
|
|
141
|
+
toolchainSource,
|
|
142
|
+
packageStore: config.packageStore,
|
|
107
143
|
refStorePath: config.refStorePath,
|
|
108
144
|
platform: config.platform,
|
|
109
145
|
engineSocketPath: config.engineSocketPath,
|
|
@@ -115,7 +151,7 @@ export function createSandboxClient(config) {
|
|
|
115
151
|
// ref + step coordinates and the wrapper does the clear after the
|
|
116
152
|
// backend-level removal.
|
|
117
153
|
const teardown = async (ref) => {
|
|
118
|
-
unwrapOrThrow(await ops.teardown(ref));
|
|
154
|
+
unwrapOrThrow((await ops.teardown(ref)).mapErr(failing));
|
|
119
155
|
// Sandboxes are 1-per-step (see `sandbox-step.ts` body — each child
|
|
120
156
|
// workflow calls `createSandbox` once then `teardown` once), so the
|
|
121
157
|
// broad WHERE clause matches exactly one row. If that invariant ever
|
|
@@ -132,8 +168,9 @@ export function createSandboxClient(config) {
|
|
|
132
168
|
};
|
|
133
169
|
// One arbiter for both surfaces: the client's `isAlive` method and the
|
|
134
170
|
// poll loop's escalation probe are the same backend inspect.
|
|
135
|
-
const isAlive = async (ref) => unwrapOrThrow(await ops.isAlive(ref));
|
|
171
|
+
const isAlive = async (ref) => unwrapOrThrow((await ops.isAlive(ref)).mapErr(failing));
|
|
136
172
|
return {
|
|
173
|
+
toolchainSource,
|
|
137
174
|
createSandbox: async (meta, identity) => {
|
|
138
175
|
// The config states an engine fact (binds preserve host ownership);
|
|
139
176
|
// which remediation that fact demands — today, a world-writable step
|
|
@@ -150,7 +187,7 @@ export function createSandboxClient(config) {
|
|
|
150
187
|
}
|
|
151
188
|
// Clamp to cluster ceilings so the pod is always quota-admissible.
|
|
152
189
|
const resources = clampResources(meta.resources, config.resourceLimits);
|
|
153
|
-
return unwrapOrThrow(await ops.createSandbox({ ...meta, resources }, identity));
|
|
190
|
+
return unwrapOrThrow((await ops.createSandbox({ ...meta, resources }, identity)).mapErr(failing));
|
|
154
191
|
},
|
|
155
192
|
submitExec: async (ref, body) => submitExec(ref, body, {
|
|
156
193
|
...config.submitDeps,
|
|
@@ -163,10 +200,10 @@ export function createSandboxClient(config) {
|
|
|
163
200
|
// retention budget and still returns whole streams.
|
|
164
201
|
awaitExec: async (ref, execId, emit, deadline) => capExecStreams(await awaitExec(ref, execId, emit, deadline, composeAwaitOptions(config.awaitOptions, transport, isAlive)), config.execStreamByteCap ?? EXEC_STREAM_BYTE_CAP),
|
|
165
202
|
isAlive,
|
|
166
|
-
isAliveById: async (sandboxId) => unwrapOrThrow(await ops.isAliveById(sandboxId)),
|
|
203
|
+
isAliveById: async (sandboxId) => unwrapOrThrow((await ops.isAliveById(sandboxId)).mapErr(failing)),
|
|
167
204
|
teardown,
|
|
168
|
-
teardownById: async (sandboxId) => unwrapOrThrow(await ops.teardownById(sandboxId)),
|
|
169
|
-
listManagedSandboxes: async () => unwrapOrThrow(await ops.listManagedSandboxes()),
|
|
205
|
+
teardownById: async (sandboxId) => unwrapOrThrow((await ops.teardownById(sandboxId)).mapErr(failing)),
|
|
206
|
+
listManagedSandboxes: async () => unwrapOrThrow((await ops.listManagedSandboxes()).mapErr(failing)),
|
|
170
207
|
};
|
|
171
208
|
}
|
|
172
209
|
/** Re-exports so consumers import once from this module. */
|
|
@@ -63,6 +63,17 @@ export interface DockerClientConfig {
|
|
|
63
63
|
farmSource: FarmSource;
|
|
64
64
|
/** The declared toolchain owner. Absent means `"store"`, the legacy environment. */
|
|
65
65
|
toolchainSource?: ToolchainSource;
|
|
66
|
+
/**
|
|
67
|
+
* The deployment cannot run an analysis without the package store. Under
|
|
68
|
+
* the fact, a farm that fails the `inflexa.lock` gate refuses the create
|
|
69
|
+
* with `farm_unusable` and no container is made. Absent keeps the
|
|
70
|
+
* degrade: the store mounts drop, a warning is logged, and the container
|
|
71
|
+
* is still made. The field names the deployment fact rather than the
|
|
72
|
+
* remediation, so how the harness answers it can evolve without changing
|
|
73
|
+
* the embedder surface; a single-valued union (not a boolean) so future
|
|
74
|
+
* store classes extend it without a breaking change.
|
|
75
|
+
*/
|
|
76
|
+
packageStore?: "required";
|
|
66
77
|
/**
|
|
67
78
|
* Host ref store; bind-mounted read-only at `/mnt/refs`. Embedders pass the
|
|
68
79
|
* configured store location unconditionally — existence is (re-)checked at each
|
|
@@ -246,15 +246,29 @@ export function createDockerSandboxOps(config) {
|
|
|
246
246
|
farm = resolved.value;
|
|
247
247
|
}
|
|
248
248
|
// The `inflexa.lock` gate runs at each create: the store may have
|
|
249
|
-
// gone away, or the farm may be half-written.
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
249
|
+
// gone away, or the farm may be half-written. The failure has two
|
|
250
|
+
// outcomes. Under the declared fact the call refuses with
|
|
251
|
+
// `farm_unusable` before any container work, and it logs nothing:
|
|
252
|
+
// the returned value carries the reason, and the consumer logs the
|
|
253
|
+
// throw, thus a warning here would record the one fault twice.
|
|
254
|
+
// Without the fact it degrades — both store mounts drop, the
|
|
255
|
+
// container is still made, and the sandbox reports the store as
|
|
256
|
+
// unavailable. That drop is otherwise silent, thus it is warned.
|
|
254
257
|
let libsMounted = false;
|
|
255
258
|
if (config.libStorePath && farm) {
|
|
256
259
|
const lock = readFarmLock(farm.farmPath);
|
|
257
260
|
libsMounted = lock.isOk();
|
|
261
|
+
if (lock.isErr() && config.packageStore === "required") {
|
|
262
|
+
return err({
|
|
263
|
+
type: "farm_unusable",
|
|
264
|
+
op: "docker.createSandbox",
|
|
265
|
+
analysisId: meta.analysisId,
|
|
266
|
+
farmPath: farm.farmPath,
|
|
267
|
+
lockPath: lock.error.lockPath,
|
|
268
|
+
lockError: lock.error.type,
|
|
269
|
+
cause: lock.error.cause,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
258
272
|
if (lock.isErr()) {
|
|
259
273
|
logger.warn("the farm of the analysis has no usable inflexa.lock at sandbox creation — mounting no package store (sandbox degrades to available:false)", { libStorePath: config.libStorePath, farmPath: farm.farmPath, lockError: lock.error.type, sandboxId });
|
|
260
274
|
}
|
package/dist/sandbox/farm.d.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - A resolver refusal (`unavailable`, or a throw) is a `farm_unavailable`
|
|
11
11
|
* `SandboxError` — the call refuses and no container is made.
|
|
12
12
|
* - A lock-gate failure degrades — the backend drops both store mounts,
|
|
13
|
-
* logs a warning, and still makes the container.
|
|
13
|
+
* logs a warning, and still makes the container. Under a config that
|
|
14
|
+
* declares `packageStore: "required"`, the same failure refuses with
|
|
15
|
+
* `farm_unusable`, and no container is made.
|
|
14
16
|
*/
|
|
15
17
|
import { type Result } from "neverthrow";
|
|
16
18
|
import { z } from "zod";
|
package/dist/sandbox/farm.js
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - A resolver refusal (`unavailable`, or a throw) is a `farm_unavailable`
|
|
11
11
|
* `SandboxError` — the call refuses and no container is made.
|
|
12
12
|
* - A lock-gate failure degrades — the backend drops both store mounts,
|
|
13
|
-
* logs a warning, and still makes the container.
|
|
13
|
+
* logs a warning, and still makes the container. Under a config that
|
|
14
|
+
* declares `packageStore: "required"`, the same failure refuses with
|
|
15
|
+
* `farm_unusable`, and no container is made.
|
|
14
16
|
*/
|
|
15
17
|
import { readFileSync } from "node:fs";
|
|
16
18
|
import { join } from "node:path";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `image-packages.json` — the self-description of the sandbox image.
|
|
3
|
+
*
|
|
4
|
+
* The image bakes two package tracks that the content-addressed store cannot
|
|
5
|
+
* hold: the conda tools at `/opt/conda` and the Node packages at `/opt/node`.
|
|
6
|
+
* The image writes one record of them at `/opt/inflexa/image-packages.json`,
|
|
7
|
+
* and the catalog build copies that record verbatim into the root of the
|
|
8
|
+
* package store it packed beside it. Thus every reader of a store learns the
|
|
9
|
+
* image inventory from the store alone — with no container engine, no
|
|
10
|
+
* registry client, and no path outside the store.
|
|
11
|
+
*
|
|
12
|
+
* The record is JSON with a `schema` number, the same convention as
|
|
13
|
+
* {@link FarmLockSchema}: an additive field passes through untouched, and a
|
|
14
|
+
* breaking change to the shape moves the number instead. A structured record
|
|
15
|
+
* is what lets a row carry its version, and what lets a later change compare
|
|
16
|
+
* the recorded image against the configured one.
|
|
17
|
+
*
|
|
18
|
+
* This schema is the ONE definition of the shape. The image writes the bytes
|
|
19
|
+
* and the harness validates them, thus a producer that drifts is refused here
|
|
20
|
+
* rather than half-read downstream.
|
|
21
|
+
*/
|
|
22
|
+
import { type Result } from "neverthrow";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
/**
|
|
25
|
+
* The file name of the record, at the root of the package store. Exported,
|
|
26
|
+
* as `FARM_LOCK_FILE` is, thus an embedder that joins the path onto its own
|
|
27
|
+
* store root never spells the name by hand.
|
|
28
|
+
*/
|
|
29
|
+
export declare const IMAGE_PACKAGES_FILE = "image-packages.json";
|
|
30
|
+
/** The image the record describes. The pair of tags shares one version string. */
|
|
31
|
+
export declare const ImageIdentitySchema: z.ZodObject<{
|
|
32
|
+
repository: z.ZodString;
|
|
33
|
+
version: z.ZodString;
|
|
34
|
+
arch: z.ZodEnum<{
|
|
35
|
+
amd64: "amd64";
|
|
36
|
+
arm64: "arm64";
|
|
37
|
+
}>;
|
|
38
|
+
}, z.core.$loose>;
|
|
39
|
+
/** The interpreter versions of the image, so a reader can compare them against the lock. */
|
|
40
|
+
export declare const ImageRuntimesSchema: z.ZodObject<{
|
|
41
|
+
python: z.ZodString;
|
|
42
|
+
r: z.ZodString;
|
|
43
|
+
node: z.ZodString;
|
|
44
|
+
}, z.core.$loose>;
|
|
45
|
+
/**
|
|
46
|
+
* One conda tool of the image. `executable` carries the binary name when it
|
|
47
|
+
* differs from the package name (the manifest `binaries:` map holds those
|
|
48
|
+
* exceptions), because an agent invokes the binary and not the package.
|
|
49
|
+
*/
|
|
50
|
+
export declare const ImageSystemToolSchema: z.ZodObject<{
|
|
51
|
+
name: z.ZodString;
|
|
52
|
+
version: z.ZodString;
|
|
53
|
+
executable: z.ZodOptional<z.ZodString>;
|
|
54
|
+
}, z.core.$loose>;
|
|
55
|
+
/** One Node package of the image. */
|
|
56
|
+
export declare const ImageNodePackageSchema: z.ZodObject<{
|
|
57
|
+
name: z.ZodString;
|
|
58
|
+
version: z.ZodString;
|
|
59
|
+
}, z.core.$loose>;
|
|
60
|
+
/**
|
|
61
|
+
* `image-packages.json` at schema version 1. The keys `system_tools` and
|
|
62
|
+
* `node` are the keys of the image manifest, thus one name means one track
|
|
63
|
+
* across the build and the reader.
|
|
64
|
+
*/
|
|
65
|
+
export declare const ImagePackagesSchema: z.ZodObject<{
|
|
66
|
+
schema: z.ZodLiteral<1>;
|
|
67
|
+
image: z.ZodObject<{
|
|
68
|
+
repository: z.ZodString;
|
|
69
|
+
version: z.ZodString;
|
|
70
|
+
arch: z.ZodEnum<{
|
|
71
|
+
amd64: "amd64";
|
|
72
|
+
arm64: "arm64";
|
|
73
|
+
}>;
|
|
74
|
+
}, z.core.$loose>;
|
|
75
|
+
runtimes: z.ZodObject<{
|
|
76
|
+
python: z.ZodString;
|
|
77
|
+
r: z.ZodString;
|
|
78
|
+
node: z.ZodString;
|
|
79
|
+
}, z.core.$loose>;
|
|
80
|
+
system_tools: z.ZodArray<z.ZodObject<{
|
|
81
|
+
name: z.ZodString;
|
|
82
|
+
version: z.ZodString;
|
|
83
|
+
executable: z.ZodOptional<z.ZodString>;
|
|
84
|
+
}, z.core.$loose>>;
|
|
85
|
+
node: z.ZodArray<z.ZodObject<{
|
|
86
|
+
name: z.ZodString;
|
|
87
|
+
version: z.ZodString;
|
|
88
|
+
}, z.core.$loose>>;
|
|
89
|
+
}, z.core.$loose>;
|
|
90
|
+
export type ImagePackages = z.infer<typeof ImagePackagesSchema>;
|
|
91
|
+
export type ImagePackagesError = {
|
|
92
|
+
readonly type: "record_unreadable";
|
|
93
|
+
readonly recordPath: string;
|
|
94
|
+
readonly cause: unknown;
|
|
95
|
+
} | {
|
|
96
|
+
readonly type: "record_invalid";
|
|
97
|
+
readonly recordPath: string;
|
|
98
|
+
readonly cause: unknown;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Read and validate one `image-packages.json` by its file path. The read is
|
|
102
|
+
* synchronous, the same as {@link readFarmLockFile}, because the caller reads
|
|
103
|
+
* the record on each call and a store download can land while the process
|
|
104
|
+
* runs. Both errors are normal states of a host that reads a store from
|
|
105
|
+
* before this record existed.
|
|
106
|
+
*/
|
|
107
|
+
export declare function readImagePackagesFile(recordPath: string): Result<ImagePackages, ImagePackagesError>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `image-packages.json` — the self-description of the sandbox image.
|
|
3
|
+
*
|
|
4
|
+
* The image bakes two package tracks that the content-addressed store cannot
|
|
5
|
+
* hold: the conda tools at `/opt/conda` and the Node packages at `/opt/node`.
|
|
6
|
+
* The image writes one record of them at `/opt/inflexa/image-packages.json`,
|
|
7
|
+
* and the catalog build copies that record verbatim into the root of the
|
|
8
|
+
* package store it packed beside it. Thus every reader of a store learns the
|
|
9
|
+
* image inventory from the store alone — with no container engine, no
|
|
10
|
+
* registry client, and no path outside the store.
|
|
11
|
+
*
|
|
12
|
+
* The record is JSON with a `schema` number, the same convention as
|
|
13
|
+
* {@link FarmLockSchema}: an additive field passes through untouched, and a
|
|
14
|
+
* breaking change to the shape moves the number instead. A structured record
|
|
15
|
+
* is what lets a row carry its version, and what lets a later change compare
|
|
16
|
+
* the recorded image against the configured one.
|
|
17
|
+
*
|
|
18
|
+
* This schema is the ONE definition of the shape. The image writes the bytes
|
|
19
|
+
* and the harness validates them, thus a producer that drifts is refused here
|
|
20
|
+
* rather than half-read downstream.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { err, ok } from "neverthrow";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
/**
|
|
26
|
+
* The file name of the record, at the root of the package store. Exported,
|
|
27
|
+
* as `FARM_LOCK_FILE` is, thus an embedder that joins the path onto its own
|
|
28
|
+
* store root never spells the name by hand.
|
|
29
|
+
*/
|
|
30
|
+
export const IMAGE_PACKAGES_FILE = "image-packages.json";
|
|
31
|
+
/** The image the record describes. The pair of tags shares one version string. */
|
|
32
|
+
export const ImageIdentitySchema = z
|
|
33
|
+
.object({
|
|
34
|
+
repository: z.string(),
|
|
35
|
+
version: z.string(),
|
|
36
|
+
arch: z.enum(["amd64", "arm64"]),
|
|
37
|
+
})
|
|
38
|
+
.passthrough();
|
|
39
|
+
/** The interpreter versions of the image, so a reader can compare them against the lock. */
|
|
40
|
+
export const ImageRuntimesSchema = z
|
|
41
|
+
.object({
|
|
42
|
+
python: z.string(),
|
|
43
|
+
r: z.string(),
|
|
44
|
+
node: z.string(),
|
|
45
|
+
})
|
|
46
|
+
.passthrough();
|
|
47
|
+
/**
|
|
48
|
+
* One conda tool of the image. `executable` carries the binary name when it
|
|
49
|
+
* differs from the package name (the manifest `binaries:` map holds those
|
|
50
|
+
* exceptions), because an agent invokes the binary and not the package.
|
|
51
|
+
*/
|
|
52
|
+
export const ImageSystemToolSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
name: z.string(),
|
|
55
|
+
version: z.string(),
|
|
56
|
+
executable: z.string().optional(),
|
|
57
|
+
})
|
|
58
|
+
.passthrough();
|
|
59
|
+
/** One Node package of the image. */
|
|
60
|
+
export const ImageNodePackageSchema = z
|
|
61
|
+
.object({
|
|
62
|
+
name: z.string(),
|
|
63
|
+
version: z.string(),
|
|
64
|
+
})
|
|
65
|
+
.passthrough();
|
|
66
|
+
/**
|
|
67
|
+
* `image-packages.json` at schema version 1. The keys `system_tools` and
|
|
68
|
+
* `node` are the keys of the image manifest, thus one name means one track
|
|
69
|
+
* across the build and the reader.
|
|
70
|
+
*/
|
|
71
|
+
export const ImagePackagesSchema = z
|
|
72
|
+
.object({
|
|
73
|
+
schema: z.literal(1),
|
|
74
|
+
image: ImageIdentitySchema,
|
|
75
|
+
runtimes: ImageRuntimesSchema,
|
|
76
|
+
system_tools: z.array(ImageSystemToolSchema),
|
|
77
|
+
node: z.array(ImageNodePackageSchema),
|
|
78
|
+
})
|
|
79
|
+
.passthrough();
|
|
80
|
+
/**
|
|
81
|
+
* Read and validate one `image-packages.json` by its file path. The read is
|
|
82
|
+
* synchronous, the same as {@link readFarmLockFile}, because the caller reads
|
|
83
|
+
* the record on each call and a store download can land while the process
|
|
84
|
+
* runs. Both errors are normal states of a host that reads a store from
|
|
85
|
+
* before this record existed.
|
|
86
|
+
*/
|
|
87
|
+
export function readImagePackagesFile(recordPath) {
|
|
88
|
+
let raw;
|
|
89
|
+
try {
|
|
90
|
+
raw = readFileSync(recordPath, "utf8");
|
|
91
|
+
}
|
|
92
|
+
catch (cause) {
|
|
93
|
+
return err({ type: "record_unreadable", recordPath, cause });
|
|
94
|
+
}
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(raw);
|
|
98
|
+
}
|
|
99
|
+
catch (cause) {
|
|
100
|
+
return err({ type: "record_invalid", recordPath, cause });
|
|
101
|
+
}
|
|
102
|
+
const record = ImagePackagesSchema.safeParse(parsed);
|
|
103
|
+
if (!record.success) {
|
|
104
|
+
return err({ type: "record_invalid", recordPath, cause: record.error });
|
|
105
|
+
}
|
|
106
|
+
return ok(record.data);
|
|
107
|
+
}
|
|
@@ -71,6 +71,17 @@ export interface K8sClientConfig {
|
|
|
71
71
|
farmSource: FarmSource;
|
|
72
72
|
/** The declared toolchain owner. Absent means `"store"`, the legacy environment. */
|
|
73
73
|
toolchainSource?: ToolchainSource;
|
|
74
|
+
/**
|
|
75
|
+
* The deployment cannot run an analysis without the package store. Under
|
|
76
|
+
* the fact, a farm that fails the `inflexa.lock` gate refuses the create
|
|
77
|
+
* with `farm_unusable` and no Job is made. Absent keeps the degrade: the
|
|
78
|
+
* store mounts drop, a warning is logged, and the Job is still made. The
|
|
79
|
+
* field names the deployment fact rather than the remediation, so how the
|
|
80
|
+
* harness answers it can evolve without changing the embedder surface; a
|
|
81
|
+
* single-valued union (not a boolean) so future store classes extend it
|
|
82
|
+
* without a breaking change.
|
|
83
|
+
*/
|
|
84
|
+
packageStore?: "required";
|
|
74
85
|
/** PVC claim mounted read-only at `/mnt/refs` when set. */
|
|
75
86
|
refStorePvc?: string;
|
|
76
87
|
/** Node selector pinning sandbox pods to the dedicated agent pool. Omit for default scheduling. */
|
|
@@ -465,14 +465,30 @@ export function createK8sSandboxOps(config) {
|
|
|
465
465
|
// The `inflexa.lock` gate, for a host that mounts the libs
|
|
466
466
|
// volume itself. The resolver owes the proof either way,
|
|
467
467
|
// but a farm half-written between the resolution and this
|
|
468
|
-
// create still mounts without a second look.
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
468
|
+
// create still mounts without a second look. The failure
|
|
469
|
+
// has the two outcomes of the Docker backend. Under the
|
|
470
|
+
// declared fact the call refuses with `farm_unusable`
|
|
471
|
+
// before any cluster work, and it logs nothing: the
|
|
472
|
+
// returned value carries the reason, and the consumer logs
|
|
473
|
+
// the throw, thus a warning here would record the one fault
|
|
474
|
+
// twice. Without the fact it degrades — no store mount, the
|
|
475
|
+
// Job is still made, and the sandbox reports the store as
|
|
476
|
+
// unavailable.
|
|
472
477
|
let libsMounted = !!config.libStorePvc;
|
|
473
478
|
if (config.libStorePvc && config.libStorePvcRoot && farm) {
|
|
474
479
|
const lock = readFarmLock(join(config.libStorePvcRoot, farm.farmPath));
|
|
475
480
|
libsMounted = lock.isOk();
|
|
481
|
+
if (lock.isErr() && config.packageStore === "required") {
|
|
482
|
+
return err({
|
|
483
|
+
type: "farm_unusable",
|
|
484
|
+
op: "k8s.createSandbox",
|
|
485
|
+
analysisId: meta.analysisId,
|
|
486
|
+
farmPath: farm.farmPath,
|
|
487
|
+
lockPath: lock.error.lockPath,
|
|
488
|
+
lockError: lock.error.type,
|
|
489
|
+
cause: lock.error.cause,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
476
492
|
if (lock.isErr()) {
|
|
477
493
|
(config.logger ?? createNoopLogger())
|
|
478
494
|
.named("k8s-client")
|
|
@@ -26,10 +26,12 @@
|
|
|
26
26
|
* ever wraps the single SDK/HTTP call handed to it.
|
|
27
27
|
*
|
|
28
28
|
* The public `SandboxClient` interface keeps `Promise<T>` method signatures.
|
|
29
|
-
* `create-sandbox.ts` is the composition seam: it builds the backend ops,
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* `create-sandbox.ts` is the composition seam: it builds the backend ops, maps
|
|
30
|
+
* each `err` of an op to a `SandboxFailure`, and `unwrapOrThrow`s it, so the
|
|
31
|
+
* seam throws the described failure with the variant on `.cause` and on
|
|
32
|
+
* `.error`. A `catch` never sees the raw variant. (`submitExec` is the
|
|
33
|
+
* exception — it is already its own DBOS step and throws directly, so the seam
|
|
34
|
+
* forwards its `Promise<void>` as-is.)
|
|
33
35
|
* Workflow consumers (`sandbox-step.ts`) already wrap each seam call in
|
|
34
36
|
* `DBOS.runStep`, so the re-thrown error records the step as failed;
|
|
35
37
|
* non-workflow consumers (`reaper`, `watchdog`,
|
|
@@ -37,6 +39,7 @@
|
|
|
37
39
|
* untouched by this wave.
|
|
38
40
|
*/
|
|
39
41
|
import { ResultAsync } from "neverthrow";
|
|
42
|
+
import type { FarmLockError } from "./farm.js";
|
|
40
43
|
/**
|
|
41
44
|
* A sandbox backend failure. Absence / idempotent "already gone" is NOT
|
|
42
45
|
* modelled — those ride the OK channel (`ok(false)` for a dead/missing machine,
|
|
@@ -91,6 +94,22 @@ export type SandboxError = {
|
|
|
91
94
|
readonly analysisId: string;
|
|
92
95
|
readonly reason: string;
|
|
93
96
|
readonly cause?: unknown;
|
|
97
|
+
} | {
|
|
98
|
+
/**
|
|
99
|
+
* The gate proved the `inflexa.lock` at the resolved farm and the
|
|
100
|
+
* farm is not usable. Raised only when the config declares
|
|
101
|
+
* `packageStore: "required"`; without the fact the same gate
|
|
102
|
+
* failure degrades to no store mount. The sibling
|
|
103
|
+
* `farm_unavailable` is the resolver refusing to name a farm at
|
|
104
|
+
* all — here a farm was named, and its lock does not hold up.
|
|
105
|
+
*/
|
|
106
|
+
readonly type: "farm_unusable";
|
|
107
|
+
readonly op: string;
|
|
108
|
+
readonly analysisId: string;
|
|
109
|
+
readonly farmPath: string;
|
|
110
|
+
readonly lockPath: string;
|
|
111
|
+
readonly lockError: FarmLockError["type"];
|
|
112
|
+
readonly cause: unknown;
|
|
94
113
|
};
|
|
95
114
|
/**
|
|
96
115
|
* The HTTP/driver status a backend SDK error carries. dockerode exposes it on
|
|
@@ -100,8 +119,24 @@ export type SandboxError = {
|
|
|
100
119
|
* `typeof === "number"` to avoid returning it as a status.
|
|
101
120
|
*/
|
|
102
121
|
export declare function sandboxStatusOf(cause: unknown): number | undefined;
|
|
103
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* A one-line, user-facing description of a `SandboxError` for logs/error
|
|
124
|
+
* bodies. The variant gives the head. Where the cause names a reason, that
|
|
125
|
+
* bounded line closes the description, thus the engine or the parser says why
|
|
126
|
+
* on the operator surfaces.
|
|
127
|
+
*/
|
|
104
128
|
export declare function describeSandboxError(e: SandboxError): string;
|
|
129
|
+
/**
|
|
130
|
+
* The throw of the `SandboxClient` seam. The message is
|
|
131
|
+
* `describeSandboxError`, thus a surface that renders a message alone still
|
|
132
|
+
* names the fault and its reason. The variant rides two ways: on `cause`, so
|
|
133
|
+
* a cause-chain reader keeps working, and on `error`, so a consumer that
|
|
134
|
+
* matches the class reads the typed fields without a walk.
|
|
135
|
+
*/
|
|
136
|
+
export declare class SandboxFailure extends Error {
|
|
137
|
+
readonly error: SandboxError;
|
|
138
|
+
constructor(error: SandboxError);
|
|
139
|
+
}
|
|
105
140
|
/**
|
|
106
141
|
* Wrap a single sandbox backend call (Docker SDK op, K8s API op, or the
|
|
107
142
|
* `/exec` POST). A throw becomes `err(toError(status, cause))`; the caller
|
|
@@ -26,10 +26,12 @@
|
|
|
26
26
|
* ever wraps the single SDK/HTTP call handed to it.
|
|
27
27
|
*
|
|
28
28
|
* The public `SandboxClient` interface keeps `Promise<T>` method signatures.
|
|
29
|
-
* `create-sandbox.ts` is the composition seam: it builds the backend ops,
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* `create-sandbox.ts` is the composition seam: it builds the backend ops, maps
|
|
30
|
+
* each `err` of an op to a `SandboxFailure`, and `unwrapOrThrow`s it, so the
|
|
31
|
+
* seam throws the described failure with the variant on `.cause` and on
|
|
32
|
+
* `.error`. A `catch` never sees the raw variant. (`submitExec` is the
|
|
33
|
+
* exception — it is already its own DBOS step and throws directly, so the seam
|
|
34
|
+
* forwards its `Promise<void>` as-is.)
|
|
33
35
|
* Workflow consumers (`sandbox-step.ts`) already wrap each seam call in
|
|
34
36
|
* `DBOS.runStep`, so the re-thrown error records the step as failed;
|
|
35
37
|
* non-workflow consumers (`reaper`, `watchdog`,
|
|
@@ -37,6 +39,7 @@
|
|
|
37
39
|
* untouched by this wave.
|
|
38
40
|
*/
|
|
39
41
|
import { ResultAsync, err, ok } from "neverthrow";
|
|
42
|
+
import { ZodError } from "zod";
|
|
40
43
|
const _assertDomainError = true;
|
|
41
44
|
/**
|
|
42
45
|
* The HTTP/driver status a backend SDK error carries. dockerode exposes it on
|
|
@@ -51,8 +54,62 @@ export function sandboxStatusOf(cause) {
|
|
|
51
54
|
return e.code;
|
|
52
55
|
return e.statusCode ?? e.response?.statusCode;
|
|
53
56
|
}
|
|
54
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* The bound on the appended cause line — the same 200 characters the profile
|
|
59
|
+
* ledger keeps (`tasks/data-profile.ts`), because a description reaches that
|
|
60
|
+
* ledger and the run row. A stack trace and an engine response body must stay
|
|
61
|
+
* out of a row, so the line is cut and marked rather than carried whole.
|
|
62
|
+
*/
|
|
63
|
+
const CAUSE_LINE_MAX = 200;
|
|
64
|
+
/**
|
|
65
|
+
* The one bounded line that names the reason of a cause, or `undefined` when
|
|
66
|
+
* the cause carries no reason to name.
|
|
67
|
+
*
|
|
68
|
+
* A `ZodError` is special-cased: its `message` is multi-line JSON whose first
|
|
69
|
+
* line is `[`, which names nothing. Thus a zod cause renders as its first
|
|
70
|
+
* issue — the path and the message — and the reader sees which field broke.
|
|
71
|
+
* Any other cause gives the first line of its message.
|
|
72
|
+
*/
|
|
73
|
+
function causeLine(cause) {
|
|
74
|
+
let text;
|
|
75
|
+
if (cause instanceof ZodError) {
|
|
76
|
+
const issue = cause.issues[0];
|
|
77
|
+
if (!issue)
|
|
78
|
+
return undefined;
|
|
79
|
+
const path = issue.path.join(".");
|
|
80
|
+
text = path.length > 0 ? `${path}: ${issue.message}` : issue.message;
|
|
81
|
+
}
|
|
82
|
+
else if (cause instanceof Error) {
|
|
83
|
+
text = cause.message;
|
|
84
|
+
}
|
|
85
|
+
else if (typeof cause === "string") {
|
|
86
|
+
text = cause;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const first = text.split("\n", 1)[0].trim();
|
|
92
|
+
if (first.length === 0)
|
|
93
|
+
return undefined;
|
|
94
|
+
return first.length > CAUSE_LINE_MAX ? `${first.slice(0, CAUSE_LINE_MAX - 1)}…` : first;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A one-line, user-facing description of a `SandboxError` for logs/error
|
|
98
|
+
* bodies. The variant gives the head. Where the cause names a reason, that
|
|
99
|
+
* bounded line closes the description, thus the engine or the parser says why
|
|
100
|
+
* on the operator surfaces.
|
|
101
|
+
*/
|
|
55
102
|
export function describeSandboxError(e) {
|
|
103
|
+
// `farm_unavailable` is the one variant whose head already carries its
|
|
104
|
+
// reason, the text of the resolver. A cause line after it would put two
|
|
105
|
+
// dashes on one row, and the throw of a resolver is in the log with its
|
|
106
|
+
// stack. Every other variant names its reason only through the cause.
|
|
107
|
+
const line = e.type === "farm_unavailable" ? undefined : causeLine(e.cause);
|
|
108
|
+
const head = describeVariant(e);
|
|
109
|
+
return line === undefined ? head : `${head} — ${line}`;
|
|
110
|
+
}
|
|
111
|
+
/** The head of a description: what failed, at which op, on which subject. */
|
|
112
|
+
function describeVariant(e) {
|
|
56
113
|
switch (e.type) {
|
|
57
114
|
case "container_create_failed":
|
|
58
115
|
return `sandbox create failed (${e.op}${e.sandboxId ? `: ${e.sandboxId}` : ""})`;
|
|
@@ -68,6 +125,23 @@ export function describeSandboxError(e) {
|
|
|
68
125
|
return `sandbox liveness probe failed (${e.op}${e.sandboxId ? `: ${e.sandboxId}` : ""})`;
|
|
69
126
|
case "farm_unavailable":
|
|
70
127
|
return `farm unavailable (${e.op}: ${e.analysisId}) — ${e.reason}`;
|
|
128
|
+
case "farm_unusable":
|
|
129
|
+
return `farm unusable (${e.op}: ${e.analysisId}, ${e.lockError} at ${e.lockPath})`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The throw of the `SandboxClient` seam. The message is
|
|
134
|
+
* `describeSandboxError`, thus a surface that renders a message alone still
|
|
135
|
+
* names the fault and its reason. The variant rides two ways: on `cause`, so
|
|
136
|
+
* a cause-chain reader keeps working, and on `error`, so a consumer that
|
|
137
|
+
* matches the class reads the typed fields without a walk.
|
|
138
|
+
*/
|
|
139
|
+
export class SandboxFailure extends Error {
|
|
140
|
+
error;
|
|
141
|
+
constructor(error) {
|
|
142
|
+
super(describeSandboxError(error), { cause: error });
|
|
143
|
+
this.error = error;
|
|
144
|
+
this.name = "SandboxFailure";
|
|
71
145
|
}
|
|
72
146
|
}
|
|
73
147
|
/**
|