@inflexa-ai/harness 0.34.0 → 0.35.1
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/README.md +3 -0
- package/dist/agents/sandbox/shared.d.ts +1 -7
- package/dist/agents/sandbox/shared.js +6 -3
- package/dist/config/environment-stores.d.ts +9 -6
- package/dist/index.d.ts +4 -1
- package/dist/index.js +14 -2
- package/dist/loop/run-agent.js +9 -9
- package/dist/prompts/sandbox-standards.d.ts +7 -7
- package/dist/prompts/sandbox-standards.js +6 -6
- package/dist/providers/prompt-cache.d.ts +109 -14
- package/dist/providers/prompt-cache.js +196 -14
- 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/package.json +2 -1
|
@@ -15,12 +15,53 @@
|
|
|
15
15
|
* error — on an OpenAI-compatible model, which is exactly what we want: that
|
|
16
16
|
* family does automatic server-side prefix caching and needs no directive.
|
|
17
17
|
*
|
|
18
|
-
* ## What the Anthropic namespace does
|
|
18
|
+
* ## What the Anthropic namespace does, and where the marker must go
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* The options go on the LAST MESSAGE of the request, never on the request
|
|
21
|
+
* itself. Both placements cache the same bytes — one breakpoint at the end of
|
|
22
|
+
* the messages covers the whole prefix (tools → system → history), because the
|
|
23
|
+
* cache keys on a prefix — but only the message placement survives an
|
|
24
|
+
* intermediary.
|
|
25
|
+
*
|
|
26
|
+
* A REQUEST-level `cacheControl` makes the provider emit a top-level
|
|
27
|
+
* `cache_control` field instead of a per-block marker, and the server then
|
|
28
|
+
* places the breakpoint itself. That is one fewer thing for the harness to get
|
|
29
|
+
* right, and it is why this module used to do it. It is also invisible: an
|
|
30
|
+
* intermediary that counts breakpoints to stay under Anthropic's cap of four
|
|
31
|
+
* counts BLOCKS, so a top-level field is a breakpoint nothing on the way
|
|
32
|
+
* upstream can see. CLIProxyAPI — what the OSS CLI routes through on the Claude
|
|
33
|
+
* OAuth path — injects up to four block markers of its own and trims to four by
|
|
34
|
+
* that blind count, so the harness's invisible fifth breakpoint made every
|
|
35
|
+
* request past a thread's first turn fail with `A maximum of 4 blocks with
|
|
36
|
+
* cache_control may be provided. Found 5.` (HTTP 400, non-retryable — the thread
|
|
37
|
+
* is wedged, because the next turn rebuilds the same shape).
|
|
38
|
+
*
|
|
39
|
+
* A marker on a message block is counted, trimmed, and reasoned about by every
|
|
40
|
+
* hop. Keep it there. {@link withPromptCacheBreakpoint} is the only writer.
|
|
41
|
+
*
|
|
42
|
+
* ## Which vendors need a marker at all
|
|
43
|
+
*
|
|
44
|
+
* Only two of them, and both take it on a message:
|
|
45
|
+
*
|
|
46
|
+
* - **Anthropic** — `anthropic.cacheControl`. Fully opt-in: no marker, no
|
|
47
|
+
* caching, at full price.
|
|
48
|
+
* - **Amazon Bedrock** — `bedrock.cachePoint`. Also fully opt-in.
|
|
49
|
+
*
|
|
50
|
+
* The OpenAI family caches prefixes server-side, unprompted, and exposes no
|
|
51
|
+
* breakpoint to place (`promptCacheKey` and `promptCacheRetention` are request
|
|
52
|
+
* settings for affinity and retention, not placement — a separate concern from
|
|
53
|
+
* this policy). Gemini caches implicitly by default, and its explicit mode is a
|
|
54
|
+
* cache RESOURCE created out of band and referenced by name, which is not a
|
|
55
|
+
* marker and does not fit a ttl policy.
|
|
56
|
+
*
|
|
57
|
+
* So both markers ride together on the one chosen message. A provider reads only
|
|
58
|
+
* its own namespace, thus the pair is free to whichever vendor is not serving
|
|
59
|
+
* the call, and the placement never learns which one that is.
|
|
60
|
+
*
|
|
61
|
+
* The asymmetry worth remembering: under-marking silently costs money, while
|
|
62
|
+
* over-marking hard-fails. Anthropic answers 400 past four breakpoints, and the
|
|
63
|
+
* legacy Bedrock integration answers 400 on a top-level `cache_control`. Every
|
|
64
|
+
* failure in this area comes from asking for too much.
|
|
24
65
|
*
|
|
25
66
|
* ## Cache defeaters — what silently kills the hit rate
|
|
26
67
|
*
|
|
@@ -43,13 +84,27 @@
|
|
|
43
84
|
* `prompts/briefing.ts`). Keep it that way — one interpolated id or path there
|
|
44
85
|
* makes every step's ~20k-char prefix unique.
|
|
45
86
|
*
|
|
46
|
-
* ##
|
|
87
|
+
* ## What the cache-token metrics do and do not prove
|
|
47
88
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
89
|
+
* Caching DOES engage on the Claude OAuth path the OSS CLI routes through a
|
|
90
|
+
* local CLIProxyAPI: measured, 2050-2151 cache-read tokens on the turns after
|
|
91
|
+
* the first. An earlier note here claimed that path ignores cache directives
|
|
92
|
+
* outright. It does not.
|
|
93
|
+
*
|
|
94
|
+
* But a read there is not evidence that OUR breakpoint earned it. While cloaking
|
|
95
|
+
* — which is what that path does — the proxy places breakpoints of its own on
|
|
96
|
+
* the system block, the first user message, the system message it relocates, and
|
|
97
|
+
* the last message. Those cache the prefix whatever the harness sends: with
|
|
98
|
+
* `promptCache: "off"` and no marker of ours on the wire, a repeat request still
|
|
99
|
+
* read 1902 tokens back.
|
|
100
|
+
*
|
|
101
|
+
* So on that path `cache_read_tokens` (`loop/metrics.ts`) reports whether the
|
|
102
|
+
* deployment gets caching at all, which is what the counter is for. It does not
|
|
103
|
+
* attribute the read, and it cannot tell a working harness breakpoint from a
|
|
104
|
+
* proxy that is quietly doing the work. Against a direct API key or a gateway,
|
|
105
|
+
* where nothing injects markers, the counter does mean exactly that — and a
|
|
106
|
+
* flat-zero read counter beside a non-zero write counter is still the signature
|
|
107
|
+
* of an endpoint billing for writes and serving no reads.
|
|
53
108
|
*/
|
|
54
109
|
/**
|
|
55
110
|
* The default policy: cache with the 5-minute TTL.
|
|
@@ -67,11 +122,138 @@ export const DEFAULT_PROMPT_CACHE = { ttl: "5m" };
|
|
|
67
122
|
/**
|
|
68
123
|
* Translate a neutral cache policy into provider wire options.
|
|
69
124
|
*
|
|
70
|
-
* Returns `undefined` for `"off"` so the caller can leave
|
|
71
|
-
*
|
|
125
|
+
* Returns `undefined` for `"off"` so the caller can leave the bag entirely unset
|
|
126
|
+
* rather than sending an empty one.
|
|
127
|
+
*
|
|
128
|
+
* Both vendors that take an explicit marker get one, in their own namespace. A
|
|
129
|
+
* provider reads only its own key, so the pair costs nothing to whichever one is
|
|
130
|
+
* not serving the call, and the placement needs no idea which vendor it is
|
|
131
|
+
* talking to. The two shapes differ in more than spelling: Anthropic marks the
|
|
132
|
+
* last content block of the message, while Bedrock appends a `cachePoint` block
|
|
133
|
+
* after it. Both end up at the same position in the prefix.
|
|
134
|
+
*
|
|
135
|
+
* The `satisfies` clauses are the point of naming the vendor types at all. This
|
|
136
|
+
* bag is a `Record<string, JSONObject>`, thus nothing here is checked by
|
|
137
|
+
* default, and a renamed key would compile and silently stop caching —
|
|
138
|
+
* `cacheControl` has already been renamed once, and the provider still accepts
|
|
139
|
+
* `cache_control` as its alias.
|
|
140
|
+
*
|
|
141
|
+
* CAUTION: this is the marker's VALUE, not its placement. Attaching it to a
|
|
142
|
+
* `ChatRequest` emits the invisible top-level field the module header warns
|
|
143
|
+
* about. Place it with {@link withPromptCacheBreakpoint}.
|
|
72
144
|
*/
|
|
73
145
|
export function promptCacheProviderOptions(policy) {
|
|
74
146
|
if (policy === "off")
|
|
75
147
|
return undefined;
|
|
76
|
-
|
|
148
|
+
const anthropic = { cacheControl: { type: "ephemeral", ttl: policy.ttl } };
|
|
149
|
+
const bedrock = { cachePoint: { type: "default", ttl: policy.ttl } };
|
|
150
|
+
return { anthropic, bedrock };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Every namespace that can carry a breakpoint, and the key it carries it under.
|
|
154
|
+
*
|
|
155
|
+
* The writer and the stripper both read this list, thus they cannot drift apart,
|
|
156
|
+
* and the one-breakpoint invariant covers each vendor rather than only the one
|
|
157
|
+
* that happens to be serving the call. `@ai-sdk/amazon-bedrock` reads either
|
|
158
|
+
* `bedrock` or `amazonBedrock`; its own documentation uses `bedrock`.
|
|
159
|
+
*/
|
|
160
|
+
const BREAKPOINT_SITES = [
|
|
161
|
+
["anthropic", "cacheControl"],
|
|
162
|
+
["bedrock", "cachePoint"],
|
|
163
|
+
];
|
|
164
|
+
/**
|
|
165
|
+
* Whether a message can HOST the breakpoint — that is, whether the marker put on
|
|
166
|
+
* it actually reaches the wire.
|
|
167
|
+
*
|
|
168
|
+
* The Anthropic provider resolves a message-level marker onto the message's last
|
|
169
|
+
* content part. A thinking block cannot carry `cache_control`, so a marker on an
|
|
170
|
+
* assistant message that ends in one is dropped by the provider: no error, no
|
|
171
|
+
* breakpoint, and a silent cache miss on every call after it. Walking back past
|
|
172
|
+
* such a message is what the server-side placement did for us before, so this
|
|
173
|
+
* keeps the behaviour rather than introducing a new failure mode.
|
|
174
|
+
*
|
|
175
|
+
* An empty content array hosts nothing either — there is no last part to mark.
|
|
176
|
+
* The content is read as `unknown` because the four message roles carry four
|
|
177
|
+
* different part unions and this predicate cares about exactly one field.
|
|
178
|
+
*/
|
|
179
|
+
function canHostBreakpoint(message) {
|
|
180
|
+
const content = message.content;
|
|
181
|
+
if (typeof content === "string")
|
|
182
|
+
return content.length > 0;
|
|
183
|
+
if (!Array.isArray(content))
|
|
184
|
+
return false;
|
|
185
|
+
const last = content.at(-1);
|
|
186
|
+
if (typeof last !== "object" || last === null)
|
|
187
|
+
return false;
|
|
188
|
+
return last.type !== "reasoning";
|
|
189
|
+
}
|
|
190
|
+
/** The index of the last message that can host the breakpoint, or `-1` when none can. */
|
|
191
|
+
function lastHostIndex(messages) {
|
|
192
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
193
|
+
const message = messages[i];
|
|
194
|
+
if (message !== undefined && canHostBreakpoint(message))
|
|
195
|
+
return i;
|
|
196
|
+
}
|
|
197
|
+
return -1;
|
|
198
|
+
}
|
|
199
|
+
/** Whether this message already carries a cache marker in ANY breakpoint namespace. */
|
|
200
|
+
function carriesBreakpoint(message) {
|
|
201
|
+
return BREAKPOINT_SITES.some(([namespace, key]) => message.providerOptions?.[namespace]?.[key] !== undefined);
|
|
202
|
+
}
|
|
203
|
+
/** The same message with each namespace of `options` merged into its own bag. */
|
|
204
|
+
function withBreakpoint(message, options) {
|
|
205
|
+
const merged = { ...message.providerOptions };
|
|
206
|
+
for (const [namespace] of BREAKPOINT_SITES) {
|
|
207
|
+
const marker = options[namespace];
|
|
208
|
+
if (marker === undefined)
|
|
209
|
+
continue;
|
|
210
|
+
merged[namespace] = { ...message.providerOptions?.[namespace], ...marker };
|
|
211
|
+
}
|
|
212
|
+
return { ...message, providerOptions: merged };
|
|
213
|
+
}
|
|
214
|
+
/** The same message with every marker removed, and each namespace dropped when it held nothing else. */
|
|
215
|
+
function withoutBreakpoint(message) {
|
|
216
|
+
if (!carriesBreakpoint(message))
|
|
217
|
+
return message;
|
|
218
|
+
const remaining = { ...message.providerOptions };
|
|
219
|
+
for (const [namespace, key] of BREAKPOINT_SITES) {
|
|
220
|
+
const bag = remaining[namespace];
|
|
221
|
+
if (bag?.[key] === undefined)
|
|
222
|
+
continue;
|
|
223
|
+
const { [key]: _dropped, ...rest } = bag;
|
|
224
|
+
if (Object.keys(rest).length === 0)
|
|
225
|
+
delete remaining[namespace];
|
|
226
|
+
else
|
|
227
|
+
remaining[namespace] = rest;
|
|
228
|
+
}
|
|
229
|
+
return { ...message, providerOptions: remaining };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Place the request's ONE cache breakpoint, on the last message that can host it.
|
|
233
|
+
*
|
|
234
|
+
* Returns a copy — the caller's array is the transcript the loop keeps pushing
|
|
235
|
+
* onto and the host later persists, and a marker written into it would ride into
|
|
236
|
+
* the stored thread and come back on every later turn, one more breakpoint per
|
|
237
|
+
* turn until the request is refused. Only the request-shaped copy carries one.
|
|
238
|
+
*
|
|
239
|
+
* The placement is deliberately ROLLING, not pinned to the end of the stable
|
|
240
|
+
* prefix: within one run the loop appends an assistant reply and its tool results
|
|
241
|
+
* on every iteration, and a breakpoint that advances with them lets iteration N+1
|
|
242
|
+
* read back everything iteration N wrote. A pinned breakpoint would re-process
|
|
243
|
+
* the whole tool transcript uncached on every iteration, which is the opposite of
|
|
244
|
+
* what an agent loop needs.
|
|
245
|
+
*
|
|
246
|
+
* A marker already on any other message is REMOVED — the invariant is exactly one
|
|
247
|
+
* breakpoint per request, and a stored message that carries one from an older
|
|
248
|
+
* build (`memory/ai-sdk-message-storage.ts` reads `cache_control` back off a
|
|
249
|
+
* stored block) would otherwise spend a slot that the harness never budgeted.
|
|
250
|
+
* `"off"` strips and places nothing, so turning caching off really does send no
|
|
251
|
+
* directive at all.
|
|
252
|
+
*/
|
|
253
|
+
export function withPromptCacheBreakpoint(messages, policy) {
|
|
254
|
+
const options = promptCacheProviderOptions(policy);
|
|
255
|
+
const target = options === undefined ? -1 : lastHostIndex(messages);
|
|
256
|
+
if (target < 0 && !messages.some(carriesBreakpoint))
|
|
257
|
+
return messages;
|
|
258
|
+
return messages.map((message, index) => (index === target && options !== undefined ? withBreakpoint(message, options) : withoutBreakpoint(message)));
|
|
77
259
|
}
|
package/dist/sandbox/client.d.ts
CHANGED
|
@@ -25,8 +25,17 @@
|
|
|
25
25
|
* `tasks/derive-table-exec.ts` are the two worked examples. No type carries this
|
|
26
26
|
* rule yet, thus a new caller must read this paragraph.
|
|
27
27
|
*/
|
|
28
|
-
import type { CreateSandboxMeta, ExecEmit, ExecResult, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SubmitExecBody } from "./types.js";
|
|
28
|
+
import type { CreateSandboxMeta, ExecEmit, ExecResult, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SubmitExecBody, ToolchainSource } from "./types.js";
|
|
29
29
|
export interface SandboxClient {
|
|
30
|
+
/**
|
|
31
|
+
* The declared owner of the sandbox toolchain, as the client was composed
|
|
32
|
+
* with it: `"image"` when the config declared it, `"store"` when the config
|
|
33
|
+
* declared nothing. The mount plan and the orient-core prompt both key on
|
|
34
|
+
* this one value, thus the environment a sandbox gets and the text that
|
|
35
|
+
* describes it cannot disagree. Required rather than optional, because an
|
|
36
|
+
* optional field would carry two meanings, undeclared and unknown.
|
|
37
|
+
*/
|
|
38
|
+
readonly toolchainSource: ToolchainSource;
|
|
30
39
|
/**
|
|
31
40
|
* DBOS step (`sandbox.create`) — the spawn half of the two-step create
|
|
32
41
|
* (see the harness-sandbox-exec spec). Launches the sandbox-base container/Job under the
|
|
@@ -80,6 +80,18 @@ export interface CreateSandboxClientConfig {
|
|
|
80
80
|
* this field existed.
|
|
81
81
|
*/
|
|
82
82
|
toolchainSource?: ToolchainSource;
|
|
83
|
+
/**
|
|
84
|
+
* The deployment cannot run an analysis without the package store. Under
|
|
85
|
+
* the fact, a farm that fails the `inflexa.lock` gate refuses the create
|
|
86
|
+
* with `farm_unusable`, and neither backend makes a machine. Absent keeps
|
|
87
|
+
* the degrade: the store mounts drop, a warning is logged, and the
|
|
88
|
+
* sandbox reports the store as unavailable. The field names the
|
|
89
|
+
* deployment fact rather than the remediation, so how the harness answers
|
|
90
|
+
* it can evolve without changing the embedder surface; a single-valued
|
|
91
|
+
* union (not a boolean) so future store classes extend it without a
|
|
92
|
+
* breaking change.
|
|
93
|
+
*/
|
|
94
|
+
packageStore?: "required";
|
|
83
95
|
/** Docker: host dir bind-mounted read-only at `/mnt/refs`. */
|
|
84
96
|
refStorePath?: string;
|
|
85
97
|
/** Docker: force the container platform (e.g. `linux/amd64`) so the sandbox matches the mounted lib store's arch. */
|
|
@@ -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>;
|