@inflexa-ai/harness 0.35.2 → 0.35.4
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/citations/types.d.ts +2 -2
- package/dist/config/environment-stores.d.ts +10 -0
- package/dist/contracts/schemas/chat-parts.d.ts +11 -11
- package/dist/execution/run-canceler.js +20 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -0
- package/dist/lib/metrics.d.ts +63 -5
- package/dist/lib/metrics.js +104 -5
- package/dist/lib/otel-spans.d.ts +77 -0
- package/dist/lib/otel-spans.js +123 -0
- package/dist/lib/otel.d.ts +12 -3
- package/dist/lib/otel.js +15 -4
- package/dist/loop/run-agent.js +13 -2
- package/dist/prompts/planner.js +6 -0
- package/dist/prompts/sandbox-standards.d.ts +1 -1
- package/dist/prompts/sandbox-standards.js +8 -3
- package/dist/runtime/dbos.d.ts +8 -7
- package/dist/sandbox/await-exec.js +16 -9
- package/dist/sandbox/notification-sweep.js +5 -2
- package/dist/sandbox/package-identity.d.ts +208 -0
- package/dist/sandbox/package-identity.js +244 -0
- package/dist/sandbox/reaper.js +5 -2
- package/dist/sandbox/submit-exec.js +6 -3
- package/dist/sandbox/types.d.ts +20 -25
- package/dist/sandbox/watchdog.js +8 -3
- package/dist/schemas/plan-schemas.d.ts +3 -3
- package/dist/schemas/validate-plan.js +32 -15
- package/dist/schemas/workflow-state.js +3 -1
- package/dist/state/data-profile.d.ts +20 -6
- package/dist/state/data-profile.js +16 -8
- package/dist/state/index.d.ts +1 -1
- package/dist/state/schema.d.ts +2 -2
- package/dist/tasks/data-profile.js +26 -12
- package/dist/tools/execute-analysis.js +37 -20
- package/dist/tools/research/inspect-run.d.ts +1 -1
- package/dist/tools/sandbox/link-packages.d.ts +6 -6
- package/dist/tools/sandbox/link-packages.js +45 -14
- package/dist/tools/sandbox/list-available-packages.d.ts +23 -1
- package/dist/tools/sandbox/list-available-packages.js +185 -43
- package/dist/workflows/execute-analysis.d.ts +4 -1
- package/dist/workflows/execute-analysis.js +40 -4
- package/dist/workflows/execute-target-assessment.js +68 -31
- package/dist/workflows/sandbox-step.js +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity of a package, the query that asks for one, and the resolution
|
|
3
|
+
* between the two.
|
|
4
|
+
*
|
|
5
|
+
* A package name crosses the harness, the embedder, and the provisioner. As a
|
|
6
|
+
* bare `string` each site applies the rule that it believes, and two sites then
|
|
7
|
+
* disagree on whether `decoupleR` and `decoupler` are one package. This module
|
|
8
|
+
* is the one place that knows the rule.
|
|
9
|
+
*
|
|
10
|
+
* Two types carry the two ideas. A {@link PackageQuery} is what a person or an
|
|
11
|
+
* agent asked: a spelling, an optional track, an optional version. A
|
|
12
|
+
* {@link PackageIdentity} is the name that an ecosystem recognizes. The
|
|
13
|
+
* identity is opaque, thus a caller cannot invent one, and every identity comes
|
|
14
|
+
* from {@link pythonIdentity}, {@link rIdentity}, or {@link identityOf}.
|
|
15
|
+
*
|
|
16
|
+
* The PEP 503 fold lives in exactly one function of this file. The provisioner
|
|
17
|
+
* holds the Python twin, `images/sandbox-provisioner/package_identity.py`, and
|
|
18
|
+
* one fixture binds the two.
|
|
19
|
+
*/
|
|
20
|
+
import { err, ok } from "neverthrow";
|
|
21
|
+
/**
|
|
22
|
+
* The PEP 503 fold: each run of `-`, `_`, and `.` becomes one `-`, and the
|
|
23
|
+
* case lowers. This is the ONE TypeScript home of the rule. The fold is
|
|
24
|
+
* idempotent, thus a fold of a folded name gives that name again.
|
|
25
|
+
*/
|
|
26
|
+
function fold(name) {
|
|
27
|
+
return name.replace(/[-_.]+/g, "-").toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Brand a name that a constructor of this module already ruled on.
|
|
31
|
+
*
|
|
32
|
+
* The cast is the whole point of the brand: the brand has no runtime value,
|
|
33
|
+
* thus only a cast can produce one, and this function is the one place that
|
|
34
|
+
* casts. A caller outside the module reaches a name through a constructor.
|
|
35
|
+
*/
|
|
36
|
+
function brand(name) {
|
|
37
|
+
// The brand is a compile-time marker with no runtime value, thus a cast is
|
|
38
|
+
// the only way to mint one, and this function is the only site that casts.
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The Python identity of a spelling. The name folds, because PEP 503 makes
|
|
43
|
+
* `PyYAML`, `pyyaml`, and `py_yaml` one distribution.
|
|
44
|
+
*
|
|
45
|
+
* @param spelling The distribution name, as the caller spells it.
|
|
46
|
+
*/
|
|
47
|
+
export function pythonIdentity(spelling) {
|
|
48
|
+
return { track: "python", name: brand(fold(spelling)) };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The R identity of a name. The name keeps its spelling, because `library()`
|
|
52
|
+
* is case-sensitive and `GO.db` loads under that exact name.
|
|
53
|
+
*
|
|
54
|
+
* @param name The DESCRIPTION `Package` value.
|
|
55
|
+
*/
|
|
56
|
+
export function rIdentity(name) {
|
|
57
|
+
return { track: "r", name: brand(name) };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The identity of a name that an emitter already minted — a graph node, a lock
|
|
61
|
+
* row. The dispatch is safe over such a name, because the fold is idempotent.
|
|
62
|
+
*
|
|
63
|
+
* @param track The ecosystem that holds the name.
|
|
64
|
+
* @param name The name as the emitter wrote it.
|
|
65
|
+
*/
|
|
66
|
+
export function identityOf(track, name) {
|
|
67
|
+
return track === "python" ? pythonIdentity(name) : rIdentity(name);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The key of an identity: `<track>:<name>`. Two identities are equal when
|
|
71
|
+
* their keys are equal, thus a map of identities keys on this string.
|
|
72
|
+
*/
|
|
73
|
+
export function identityKey(identity) {
|
|
74
|
+
return `${identity.track}:${identity.name}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The identity that a key names, or `undefined` when the string is not a key.
|
|
78
|
+
*
|
|
79
|
+
* The FIRST colon splits the two halves. A track name holds no colon, and
|
|
80
|
+
* neither a PEP 503 name nor an R name can hold one, thus `r:GO.db` gives the
|
|
81
|
+
* R identity `GO.db`, dot and all. The name rides through {@link identityOf},
|
|
82
|
+
* and the fold is idempotent, thus a key that {@link identityKey} wrote comes
|
|
83
|
+
* back as the identity that wrote it.
|
|
84
|
+
*
|
|
85
|
+
* The key format is minted here, thus it is read here: a reader that splits
|
|
86
|
+
* the string itself owns a second copy of the format.
|
|
87
|
+
*
|
|
88
|
+
* @param key A `<track>:<name>` string, as {@link identityKey} writes it.
|
|
89
|
+
*/
|
|
90
|
+
export function parseIdentityKey(key) {
|
|
91
|
+
const at = key.indexOf(":");
|
|
92
|
+
if (at < 0)
|
|
93
|
+
return undefined;
|
|
94
|
+
const track = key.slice(0, at);
|
|
95
|
+
const name = key.slice(at + 1);
|
|
96
|
+
if (name === "")
|
|
97
|
+
return undefined;
|
|
98
|
+
if (track !== "python" && track !== "r")
|
|
99
|
+
return undefined;
|
|
100
|
+
return identityOf(track, name);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The store address of an identity: the PEP 503 fold of its name, for both
|
|
104
|
+
* tracks. A store directory is an address and not an identity, thus two
|
|
105
|
+
* identities can share one address — `r:decoupleR` and `python:decoupler`
|
|
106
|
+
* both address as `decoupler`. The pin marker inside the directory carries
|
|
107
|
+
* the identity.
|
|
108
|
+
*/
|
|
109
|
+
export function identityAddress(identity) {
|
|
110
|
+
return fold(identity.name);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A path, a URL, or a store directory. The entry is trimmed already, thus a
|
|
114
|
+
* leading space cannot defeat the guard.
|
|
115
|
+
*/
|
|
116
|
+
const LOCATION = /^[.~]|[/\\]|:\/\//;
|
|
117
|
+
/** The `<word>:` prefix of an entry. The group holds the word before the colon. */
|
|
118
|
+
const PREFIX = /^([A-Za-z][A-Za-z0-9_.-]*):/;
|
|
119
|
+
/** A run of specifier characters. Only the exact run `==` names a version. */
|
|
120
|
+
const SPECIFIER = /[<>!~=]+/;
|
|
121
|
+
/**
|
|
122
|
+
* A run of characters that an exact version cannot hold: a specifier
|
|
123
|
+
* character, a comma, or whitespace. `SPECIFIER` reads the FIRST run only,
|
|
124
|
+
* thus `numpy==1.26,<2` gives the version `1.26,<2` without this guard, and
|
|
125
|
+
* the compound range then rides into the pool as a version string.
|
|
126
|
+
*/
|
|
127
|
+
const VERSION_INTRUDER = /[<>!~=,\s]+/;
|
|
128
|
+
/**
|
|
129
|
+
* Read one entry of the grammar `[python:|r:]<spelling>[==<version>]`.
|
|
130
|
+
*
|
|
131
|
+
* The entry trims once, and the parts keep what the trim left. Every reader of
|
|
132
|
+
* the grammar in the harness calls this function: a second parser would
|
|
133
|
+
* disagree with this one, and the plan validation that passed would then meet
|
|
134
|
+
* a link pass that refuses.
|
|
135
|
+
*
|
|
136
|
+
* @param entry One package entry, as a plan, a tool call, or a host wrote it.
|
|
137
|
+
*/
|
|
138
|
+
export function parseQuery(entry) {
|
|
139
|
+
const trimmed = entry.trim();
|
|
140
|
+
if (trimmed === "")
|
|
141
|
+
return err({ type: "empty" });
|
|
142
|
+
if (LOCATION.test(trimmed))
|
|
143
|
+
return err({ type: "location", entry: trimmed });
|
|
144
|
+
const prefix = PREFIX.exec(trimmed);
|
|
145
|
+
const word = prefix?.[1];
|
|
146
|
+
if (word !== undefined && word !== "python" && word !== "r") {
|
|
147
|
+
return err({ type: "unknown_prefix", prefix: word, entry: trimmed });
|
|
148
|
+
}
|
|
149
|
+
const track = word === "python" ? "python" : word === "r" ? "r" : undefined;
|
|
150
|
+
const rest = track === undefined ? trimmed : trimmed.slice(track.length + 1);
|
|
151
|
+
if (rest === "")
|
|
152
|
+
return err({ type: "empty" });
|
|
153
|
+
const specifier = SPECIFIER.exec(rest);
|
|
154
|
+
if (specifier === null)
|
|
155
|
+
return ok({ spelling: rest, ...(track === undefined ? {} : { track }) });
|
|
156
|
+
if (specifier[0] !== "==") {
|
|
157
|
+
return err({ type: "unsupported_specifier", specifier: specifier[0], entry: trimmed });
|
|
158
|
+
}
|
|
159
|
+
// Both halves trim: `numpy == 1.26.4` is one ask, and an untrimmed half
|
|
160
|
+
// makes the spelling `numpy ` and the version ` 1.26.4`, neither of which
|
|
161
|
+
// any index holds.
|
|
162
|
+
const spelling = rest.slice(0, specifier.index).trim();
|
|
163
|
+
if (spelling === "")
|
|
164
|
+
return err({ type: "empty" });
|
|
165
|
+
// `name==` names no version, thus the query pins none and the pool answers
|
|
166
|
+
// the newest. The round-trip law holds, because `formatQuery` writes the
|
|
167
|
+
// specifier only beside a version.
|
|
168
|
+
const version = rest.slice(specifier.index + 2).trim();
|
|
169
|
+
const intruder = VERSION_INTRUDER.exec(version);
|
|
170
|
+
if (intruder !== null) {
|
|
171
|
+
return err({ type: "unsupported_specifier", specifier: intruder[0], entry: trimmed });
|
|
172
|
+
}
|
|
173
|
+
return ok({
|
|
174
|
+
spelling,
|
|
175
|
+
...(track === undefined ? {} : { track }),
|
|
176
|
+
...(version === "" ? {} : { version }),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Write a query in the grammar. The prefix rides only beside a track, and
|
|
181
|
+
* `==<version>` only beside a version. For every query,
|
|
182
|
+
* `parseQuery(formatQuery(query))` gives that query again.
|
|
183
|
+
*/
|
|
184
|
+
export function formatQuery(query) {
|
|
185
|
+
const prefix = query.track === undefined ? "" : `${query.track}:`;
|
|
186
|
+
const version = query.version === undefined ? "" : `==${query.version}`;
|
|
187
|
+
return `${prefix}${query.spelling}${version}`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The sole R identity of the pool that folds to this fold, or none. Two such
|
|
191
|
+
* identities give no suggestion, because a guess between them is a coin flip.
|
|
192
|
+
*/
|
|
193
|
+
function soleRSuggestion(pool, spellingFold) {
|
|
194
|
+
const candidates = pool.rIdentitiesFoldingTo(spellingFold);
|
|
195
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Resolve one query against a pool.
|
|
199
|
+
*
|
|
200
|
+
* The version takes no part, because the caller holds the versions: the graph
|
|
201
|
+
* orders them, and this function answers which package the query names.
|
|
202
|
+
*
|
|
203
|
+
* The ladder, in order:
|
|
204
|
+
*
|
|
205
|
+
* 1. The query names a track: the identity of that track, when the pool holds
|
|
206
|
+
* it. Otherwise unknown, with the R suggestion when the track is `r`.
|
|
207
|
+
* 2. The pool holds the R identity of the spelling and the Python identity of
|
|
208
|
+
* its fold: the R identity when the spelling differs from its fold, because
|
|
209
|
+
* an uppercase letter or a dot is evidence of an R spelling. Otherwise
|
|
210
|
+
* ambiguous.
|
|
211
|
+
* 3. The pool holds the R identity: the R identity.
|
|
212
|
+
* 4. The pool holds the Python identity: the Python identity.
|
|
213
|
+
* 5. Exactly one R identity of the pool folds to the fold of the spelling:
|
|
214
|
+
* unknown, with that identity as the suggestion.
|
|
215
|
+
* 6. Otherwise: unknown.
|
|
216
|
+
*
|
|
217
|
+
* A silent Python-first pick is a fault: it sends `decoupleR` to the Python
|
|
218
|
+
* `decoupler`, and the R package never links.
|
|
219
|
+
*/
|
|
220
|
+
export function resolveQuery(query, pool) {
|
|
221
|
+
const spellingFold = fold(query.spelling);
|
|
222
|
+
if (query.track !== undefined) {
|
|
223
|
+
const identity = identityOf(query.track, query.spelling);
|
|
224
|
+
if (pool.has(identity))
|
|
225
|
+
return { kind: "resolved", identity };
|
|
226
|
+
if (query.track !== "r")
|
|
227
|
+
return { kind: "unknown" };
|
|
228
|
+
const suggestion = soleRSuggestion(pool, spellingFold);
|
|
229
|
+
return suggestion === undefined ? { kind: "unknown" } : { kind: "unknown", suggestion };
|
|
230
|
+
}
|
|
231
|
+
const r = rIdentity(query.spelling);
|
|
232
|
+
const python = pythonIdentity(query.spelling);
|
|
233
|
+
const holdsR = pool.has(r);
|
|
234
|
+
const holdsPython = pool.has(python);
|
|
235
|
+
if (holdsR && holdsPython) {
|
|
236
|
+
return query.spelling === spellingFold ? { kind: "ambiguous", python, r } : { kind: "resolved", identity: r };
|
|
237
|
+
}
|
|
238
|
+
if (holdsR)
|
|
239
|
+
return { kind: "resolved", identity: r };
|
|
240
|
+
if (holdsPython)
|
|
241
|
+
return { kind: "resolved", identity: python };
|
|
242
|
+
const suggestion = soleRSuggestion(pool, spellingFold);
|
|
243
|
+
return suggestion === undefined ? { kind: "unknown" } : { kind: "unknown", suggestion };
|
|
244
|
+
}
|
package/dist/sandbox/reaper.js
CHANGED
|
@@ -21,9 +21,11 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
23
23
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
24
|
+
import { untracedWorkflow } from "../lib/otel-spans.js";
|
|
24
25
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
25
26
|
import { reconcileReapedSandbox } from "../state/active-sandboxes.js";
|
|
26
27
|
const REAPER_CRON = "0 */5 * * * *";
|
|
28
|
+
const REAPER_WORKFLOW = "sandbox-reaper";
|
|
27
29
|
const DEFAULT_GRACE_MS = 10 * 60_000;
|
|
28
30
|
/**
|
|
29
31
|
* Workflow statuses that mean "still owns its sandbox — never reap". Must
|
|
@@ -152,9 +154,10 @@ export function registerSandboxReaper(deps) {
|
|
|
152
154
|
graceMs: deps.graceMs,
|
|
153
155
|
logger: deps.logger,
|
|
154
156
|
}), { name: "sandbox-reaper-sweep" });
|
|
155
|
-
}, { name:
|
|
157
|
+
}, { name: REAPER_WORKFLOW });
|
|
158
|
+
untracedWorkflow(REAPER_WORKFLOW);
|
|
156
159
|
DBOS.registerScheduled(reaper, {
|
|
157
|
-
name:
|
|
160
|
+
name: REAPER_WORKFLOW,
|
|
158
161
|
crontab: REAPER_CRON,
|
|
159
162
|
});
|
|
160
163
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* untouched.
|
|
18
18
|
*/
|
|
19
19
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
20
|
+
import { ATTR_INFLEXA_EXEC_ID, stableSpan } from "../lib/otel-spans.js";
|
|
20
21
|
import { EXEC_STREAM_BYTE_CAP } from "../tools/workspace/result-bounds.js";
|
|
21
22
|
import { signCallback } from "./hmac.js";
|
|
22
23
|
const SUBMIT_HTTP_TIMEOUT_MS = 30_000;
|
|
@@ -71,7 +72,9 @@ export async function submitExec(ref, body, deps = {}) {
|
|
|
71
72
|
stdoutByteCap: body.stdoutByteCap ?? deps.execStreamByteCap ?? EXEC_STREAM_BYTE_CAP,
|
|
72
73
|
stderrByteCap: body.stderrByteCap ?? deps.execStreamByteCap ?? EXEC_STREAM_BYTE_CAP,
|
|
73
74
|
};
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
const stepName = `sandbox.submit-exec.${body.execId}`;
|
|
76
|
+
await runStep(() => {
|
|
77
|
+
stableSpan(stepName, "sandbox.submit-exec", { [ATTR_INFLEXA_EXEC_ID]: body.execId });
|
|
78
|
+
return postExec(fetchImpl, ref, capped);
|
|
79
|
+
}, { name: stepName });
|
|
77
80
|
}
|
package/dist/sandbox/types.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import type { ResourceSpec } from "../config/resource-limits.js";
|
|
13
|
+
import type { PackageQuery } from "./package-identity.js";
|
|
13
14
|
export declare const SandboxBackend: z.ZodEnum<{
|
|
14
15
|
docker: "docker";
|
|
15
16
|
k8s: "k8s";
|
|
@@ -82,22 +83,12 @@ export type FarmSource = {
|
|
|
82
83
|
readonly resolve: ResolveAnalysisFarm;
|
|
83
84
|
};
|
|
84
85
|
/**
|
|
85
|
-
* One
|
|
86
|
-
* version. `ecosystem` qualifies a name that both tracks can hold. Without
|
|
87
|
-
* it, a name that both tracks hold comes back as a `collision` outcome.
|
|
88
|
-
*/
|
|
89
|
-
export interface PackageRequest {
|
|
90
|
-
readonly name: string;
|
|
91
|
-
readonly version?: string;
|
|
92
|
-
readonly ecosystem?: "python" | "r";
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* One outcome per request, index-aligned with the request array.
|
|
86
|
+
* One outcome per query, index-aligned with the query array.
|
|
96
87
|
*
|
|
97
|
-
* `
|
|
98
|
-
* caller quotes it into a remedy (`inflexa store add <
|
|
99
|
-
* is case-sensitive with dots, thus a normalized echo would name a
|
|
100
|
-
* that no repository holds.
|
|
88
|
+
* `spelling` echoes the spelling of its query verbatim, never a normalized
|
|
89
|
+
* form. A caller quotes it into a remedy (`inflexa store add <spelling>`), and
|
|
90
|
+
* an R name is case-sensitive with dots, thus a normalized echo would name a
|
|
91
|
+
* package that no repository holds.
|
|
101
92
|
*
|
|
102
93
|
* - `linked` — the pool held the package, and this call linked it.
|
|
103
94
|
* - `present` — the farm linked it already.
|
|
@@ -106,12 +97,13 @@ export interface PackageRequest {
|
|
|
106
97
|
* the realization gives one, classifies the miss in host terms — in
|
|
107
98
|
* flight, failed with a recorded reason, or never requested — and the
|
|
108
99
|
* launch refusal renders it beside the name.
|
|
109
|
-
* - `collision` — the
|
|
110
|
-
* versions of one distribution, or one
|
|
111
|
-
* outcome is terminal for the
|
|
100
|
+
* - `collision` — the query resolves to two store directories: two
|
|
101
|
+
* versions of one distribution, or one spelling that both tracks hold. The
|
|
102
|
+
* outcome is terminal for the query. `detail`, when the realization
|
|
112
103
|
* gives one, names the two pins and the packages that need each side —
|
|
113
104
|
* without it, a caller must guess which package pulls each pin, and a
|
|
114
|
-
* wrong guess sends it into store surgery.
|
|
105
|
+
* wrong guess sends it into store surgery. For one spelling in two tracks
|
|
106
|
+
* the detail names the two identity keys instead.
|
|
115
107
|
* - `unavailable` — the link pass itself could not answer: an unreadable
|
|
116
108
|
* dependency graph, a locked farm. The reason says why. It says NOTHING
|
|
117
109
|
* about the presence of the package, and it must never render as an
|
|
@@ -120,25 +112,25 @@ export interface PackageRequest {
|
|
|
120
112
|
*/
|
|
121
113
|
export type PackageRequestOutcome = {
|
|
122
114
|
readonly kind: "linked";
|
|
123
|
-
readonly
|
|
115
|
+
readonly spelling: string;
|
|
124
116
|
readonly version: string;
|
|
125
117
|
} | {
|
|
126
118
|
readonly kind: "present";
|
|
127
|
-
readonly
|
|
119
|
+
readonly spelling: string;
|
|
128
120
|
readonly version: string;
|
|
129
121
|
} | {
|
|
130
122
|
readonly kind: "absent";
|
|
131
|
-
readonly
|
|
123
|
+
readonly spelling: string;
|
|
132
124
|
readonly acquisitionPossible: boolean;
|
|
133
125
|
readonly detail?: string;
|
|
134
126
|
} | {
|
|
135
127
|
readonly kind: "collision";
|
|
136
|
-
readonly
|
|
128
|
+
readonly spelling: string;
|
|
137
129
|
readonly storeDirs: readonly [string, string];
|
|
138
130
|
readonly detail?: string;
|
|
139
131
|
} | {
|
|
140
132
|
readonly kind: "unavailable";
|
|
141
|
-
readonly
|
|
133
|
+
readonly spelling: string;
|
|
142
134
|
readonly reason: string;
|
|
143
135
|
};
|
|
144
136
|
/**
|
|
@@ -146,8 +138,11 @@ export type PackageRequestOutcome = {
|
|
|
146
138
|
* packages into the farm of the analysis. It never installs, downloads, or
|
|
147
139
|
* acquires. A link is live in a sandbox that already runs, because the farm
|
|
148
140
|
* rides a bind mount.
|
|
141
|
+
*
|
|
142
|
+
* The seam speaks the query of the `package-identity` capability, thus the
|
|
143
|
+
* host resolves the identity and the harness never folds a name.
|
|
149
144
|
*/
|
|
150
|
-
export type ExtendAnalysisFarm = (analysisId: string,
|
|
145
|
+
export type ExtendAnalysisFarm = (analysisId: string, queries: readonly PackageQuery[]) => Promise<readonly PackageRequestOutcome[]>;
|
|
151
146
|
/**
|
|
152
147
|
* Per-sandbox-machine liveness verdict. `oomKilled` is meaningful only when
|
|
153
148
|
* `alive` is false: true when the backend reports the machine was killed for
|
package/dist/sandbox/watchdog.js
CHANGED
|
@@ -21,11 +21,14 @@
|
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
23
23
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
24
|
+
import { untracedWorkflow } from "../lib/otel-spans.js";
|
|
24
25
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
25
26
|
import { workflowIdFromExec } from "./exec-id.js";
|
|
26
27
|
import { syntheticFailureReason, syntheticFailureResult } from "./liveness.js";
|
|
27
28
|
export const SHARD_COUNT = 8;
|
|
28
29
|
const WATCHDOG_CRON = "*/60 * * * * *";
|
|
30
|
+
const WATCHDOG_PARENT_WORKFLOW = "sandbox-watchdog-parent";
|
|
31
|
+
const WATCHDOG_SHARD_WORKFLOW = "sandbox-watchdog-shard";
|
|
29
32
|
/** Stable hash → shard mapping, exported for tests. */
|
|
30
33
|
export function shardIndex(sandboxId, shardCount = SHARD_COUNT) {
|
|
31
34
|
const digest = createHash("sha1").update(sandboxId).digest();
|
|
@@ -131,7 +134,8 @@ export function registerWatchdog(deps) {
|
|
|
131
134
|
},
|
|
132
135
|
logger: deps.logger,
|
|
133
136
|
});
|
|
134
|
-
}, { name:
|
|
137
|
+
}, { name: WATCHDOG_SHARD_WORKFLOW });
|
|
138
|
+
untracedWorkflow(WATCHDOG_SHARD_WORKFLOW);
|
|
135
139
|
// The scheduled target must itself be a registered workflow — DBOS's
|
|
136
140
|
// scheduler loop skips (and errors on) any `@scheduled` function that lacks
|
|
137
141
|
// a workflow registration. Register, then schedule the same callable.
|
|
@@ -148,9 +152,10 @@ export function registerWatchdog(deps) {
|
|
|
148
152
|
continue;
|
|
149
153
|
await DBOS.startWorkflow(childCheckShard)(shard);
|
|
150
154
|
}
|
|
151
|
-
}, { name:
|
|
155
|
+
}, { name: WATCHDOG_PARENT_WORKFLOW });
|
|
156
|
+
untracedWorkflow(WATCHDOG_PARENT_WORKFLOW);
|
|
152
157
|
DBOS.registerScheduled(watchdogParent, {
|
|
153
|
-
name:
|
|
158
|
+
name: WATCHDOG_PARENT_WORKFLOW,
|
|
154
159
|
crontab: WATCHDOG_CRON,
|
|
155
160
|
});
|
|
156
161
|
}
|
|
@@ -8,9 +8,9 @@ import type { AnalysisPlan } from "./workflow-state.js";
|
|
|
8
8
|
* and the hardcoded sandbox turn budget. */
|
|
9
9
|
export declare const PlanStepSchema: z.ZodObject<{
|
|
10
10
|
name: z.ZodString;
|
|
11
|
-
id: z.ZodString;
|
|
12
11
|
context: z.ZodOptional<z.ZodString>;
|
|
13
12
|
description: z.ZodOptional<z.ZodString>;
|
|
13
|
+
id: z.ZodString;
|
|
14
14
|
track: z.ZodString;
|
|
15
15
|
step_type: z.ZodString;
|
|
16
16
|
question: z.ZodString;
|
|
@@ -41,9 +41,9 @@ export declare const PlannerPlanSchema: z.ZodObject<{
|
|
|
41
41
|
title: z.ZodString;
|
|
42
42
|
steps: z.ZodArray<z.ZodObject<{
|
|
43
43
|
name: z.ZodString;
|
|
44
|
-
id: z.ZodString;
|
|
45
44
|
context: z.ZodOptional<z.ZodString>;
|
|
46
45
|
description: z.ZodOptional<z.ZodString>;
|
|
46
|
+
id: z.ZodString;
|
|
47
47
|
track: z.ZodString;
|
|
48
48
|
step_type: z.ZodString;
|
|
49
49
|
question: z.ZodString;
|
|
@@ -83,9 +83,9 @@ export declare const PlanningAgentOutputSchema: z.ZodObject<{
|
|
|
83
83
|
title: z.ZodString;
|
|
84
84
|
steps: z.ZodArray<z.ZodObject<{
|
|
85
85
|
name: z.ZodString;
|
|
86
|
-
id: z.ZodString;
|
|
87
86
|
context: z.ZodOptional<z.ZodString>;
|
|
88
87
|
description: z.ZodOptional<z.ZodString>;
|
|
88
|
+
id: z.ZodString;
|
|
89
89
|
track: z.ZodString;
|
|
90
90
|
step_type: z.ZodString;
|
|
91
91
|
question: z.ZodString;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { KNOWN_AGENT_IDS } from "../agents/sandbox-catalog.js";
|
|
15
15
|
import { CycleError, DependencyError, topoSortIntoWaves } from "../execution/topo-sort.js";
|
|
16
|
+
import { parseQuery } from "../sandbox/package-identity.js";
|
|
16
17
|
import { isSafeId, STEP_SUBDIRS, SYNTHESIS_STEP_ID } from "../workspace/paths.js";
|
|
17
18
|
const KNOWN_AGENTS = new Set(KNOWN_AGENT_IDS);
|
|
18
19
|
/**
|
|
@@ -24,6 +25,30 @@ const KNOWN_AGENTS = new Set(KNOWN_AGENT_IDS);
|
|
|
24
25
|
* the row's `(run_id, step_id)` primary key (see the harness-workspace-tools spec).
|
|
25
26
|
*/
|
|
26
27
|
const RESERVED_STEP_IDS = new Set([...STEP_SUBDIRS, SYNTHESIS_STEP_ID]);
|
|
28
|
+
/**
|
|
29
|
+
* One issue for one package entry that does not parse. Each message names the
|
|
30
|
+
* step and the entry, because a planner corrects the entry it wrote.
|
|
31
|
+
*
|
|
32
|
+
* The wording carries the reason of each refusal. A location publishes an
|
|
33
|
+
* installer detail as an interface. An unknown `<word>:` prefix rides into the
|
|
34
|
+
* pool as part of the name. A specifier that is not `==` turns a range such as
|
|
35
|
+
* `numpy>=1.26` into a package name, and the pool then refuses a package that
|
|
36
|
+
* it holds.
|
|
37
|
+
*/
|
|
38
|
+
function describeParseError(stepId, entry, error) {
|
|
39
|
+
switch (error.type) {
|
|
40
|
+
case "empty":
|
|
41
|
+
return `Step "${stepId}" names an empty package entry — name each package as a requirement (a bare name, or name==version)`;
|
|
42
|
+
case "location":
|
|
43
|
+
return (`Step "${stepId}" names a package location "${entry}" — name each package as a requirement ` +
|
|
44
|
+
`(a bare name, or name==version), never a path, a URL, or a store directory`);
|
|
45
|
+
case "unknown_prefix":
|
|
46
|
+
return (`Step "${stepId}" names the ecosystem of "${entry}" with a prefix the link pass cannot read — ` +
|
|
47
|
+
`the permitted prefixes are "python:" and "r:", and a bare name searches both tracks`);
|
|
48
|
+
case "unsupported_specifier":
|
|
49
|
+
return (`Step "${stepId}" pins "${entry}" with a specifier the link pass cannot honor — ` + `use a bare name, or name==version with one exact version`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
27
52
|
/** Derive a filesystem-safe output prefix from a step ID. */
|
|
28
53
|
export function deriveOutputPrefix(stepId) {
|
|
29
54
|
return stepId
|
|
@@ -109,23 +134,15 @@ export function validatePlan(plan, options) {
|
|
|
109
134
|
`step-directory convention; "${SYNTHESIS_STEP_ID}" is the run-level synthesis phase)`);
|
|
110
135
|
}
|
|
111
136
|
}
|
|
112
|
-
// 6.
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// The specifier check exists because the link pass splits on `==` only:
|
|
117
|
-
// an unrefused `numpy>=1.26` becomes a package NAME, and the pool then
|
|
118
|
-
// refuses a package it holds.
|
|
137
|
+
// 6. Each package entry is a query of the one grammar. The validation IS
|
|
138
|
+
// the parse, thus no second reader of the grammar can disagree with the
|
|
139
|
+
// link pass. An absent array passes, because stored plans from before
|
|
140
|
+
// the field carry none.
|
|
119
141
|
for (const step of plan.steps) {
|
|
120
142
|
for (const entry of step.packages ?? []) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
else if (/[<>!~]/.test(entry) || (entry.includes("=") && !entry.includes("=="))) {
|
|
126
|
-
errors.push(`Step "${step.id}" pins "${entry}" with a specifier the link pass cannot honor — ` +
|
|
127
|
-
`use a bare name, or name==version with one exact version`);
|
|
128
|
-
}
|
|
143
|
+
const parsed = parseQuery(entry);
|
|
144
|
+
if (parsed.isErr())
|
|
145
|
+
errors.push(describeParseError(step.id, entry, parsed.error));
|
|
129
146
|
}
|
|
130
147
|
}
|
|
131
148
|
// 7. Step-id path safety. The id becomes the `runs/{runId}/{stepId}` directory
|
|
@@ -31,7 +31,9 @@ export const AnalysisStepSchema = z.object({
|
|
|
31
31
|
packages: z
|
|
32
32
|
.array(z.string())
|
|
33
33
|
.optional()
|
|
34
|
-
.describe('The packages this step imports, each as a
|
|
34
|
+
.describe('The packages this step imports, each as a query in the grammar [python:|r:]<name>[==<version>]: a bare name ("scanpy"), a name with one exact version ("numpy==1.26.4"), or either behind a track prefix ("python:igraph", "r:decoupleR==2.17.0"). ' +
|
|
35
|
+
"A bare name searches both tracks. " +
|
|
36
|
+
"When the package census shows one name under the Python section and under the R section, write the prefixed form — a bare both-track name refuses the launch. " +
|
|
35
37
|
"Never a path, a URL, or a store directory. An empty array means the step needs nothing beyond the baked toolchain. " +
|
|
36
38
|
"The set is not a promise of completeness — the execution agent can still link a missing package."),
|
|
37
39
|
depends_on: z
|
|
@@ -96,6 +96,18 @@ export declare function tryRerunDataProfile(pool: Querier, analysisId: string):
|
|
|
96
96
|
* normal in-band outcome, not an error.
|
|
97
97
|
*/
|
|
98
98
|
export declare function recordDataProfileWorkflowId(pool: Querier, analysisId: string, workflowId: string): ResultAsync<boolean, DbError>;
|
|
99
|
+
/**
|
|
100
|
+
* Outcome of a terminal compare-and-set on the profile row. The CAS accepts one
|
|
101
|
+
* write per `running` claim, so `stamped: true` is the exactly-once point of a
|
|
102
|
+
* profile's terminal transition. It carries the `data_profile_started_at` of the
|
|
103
|
+
* claim it closed, so the caller measures the profile from the ledger's own start.
|
|
104
|
+
*/
|
|
105
|
+
export type DataProfileTerminalWrite = {
|
|
106
|
+
readonly stamped: false;
|
|
107
|
+
} | {
|
|
108
|
+
readonly stamped: true;
|
|
109
|
+
readonly startedAt: string | null;
|
|
110
|
+
};
|
|
99
111
|
/**
|
|
100
112
|
* Stamp a claimed-`running` row `completed` and record its result — a terminal
|
|
101
113
|
* write CAS'd on `data_profile_status = 'running'`.
|
|
@@ -111,10 +123,11 @@ export declare function recordDataProfileWorkflowId(pool: Querier, analysisId: s
|
|
|
111
123
|
* Every legitimate caller reaches here from a row it claimed into `running`, so the
|
|
112
124
|
* guard only ever refuses a row another writer already moved on from.
|
|
113
125
|
*
|
|
114
|
-
* `
|
|
115
|
-
*
|
|
126
|
+
* `stamped: true` when this call stamped the row, with the `data_profile_started_at`
|
|
127
|
+
* of the claim it closed; `stamped: false` when the guard refused it (the row was
|
|
128
|
+
* cleared/expired/replayed away) — a no-op the caller logs, not an error.
|
|
116
129
|
*/
|
|
117
|
-
export declare function completeDataProfile(pool: Querier, analysisId: string, result?: DataProfileResult): ResultAsync<
|
|
130
|
+
export declare function completeDataProfile(pool: Querier, analysisId: string, result?: DataProfileResult): ResultAsync<DataProfileTerminalWrite, DbError>;
|
|
118
131
|
/**
|
|
119
132
|
* Stamp a claimed-`running` row `failed` with a reason — the terminal-failure
|
|
120
133
|
* counterpart to {@link completeDataProfile}, CAS'd on `data_profile_status = 'running'`
|
|
@@ -123,10 +136,11 @@ export declare function completeDataProfile(pool: Querier, analysisId: string, r
|
|
|
123
136
|
* `failed` status over a cleared row. `data_profile_result` is left untouched so a
|
|
124
137
|
* prior profile survives the failure (the re-profile/retry route can keep serving it).
|
|
125
138
|
*
|
|
126
|
-
* `
|
|
127
|
-
*
|
|
139
|
+
* `stamped: true` when this call stamped the row, with the `data_profile_started_at`
|
|
140
|
+
* of the claim it closed; `stamped: false` when the guard refused it — a logged
|
|
141
|
+
* no-op, not an error.
|
|
128
142
|
*/
|
|
129
|
-
export declare function failDataProfile(pool: Querier, analysisId: string, error: string): ResultAsync<
|
|
143
|
+
export declare function failDataProfile(pool: Querier, analysisId: string, error: string): ResultAsync<DataProfileTerminalWrite, DbError>;
|
|
130
144
|
export declare function expireStaleDataProfile(pool: Querier, analysisId: string, timeoutMs: number): ResultAsync<boolean, DbError>;
|
|
131
145
|
/**
|
|
132
146
|
* Reset a ledger row wedged at `running` with no workflow behind it back to
|
|
@@ -142,6 +142,10 @@ export function recordDataProfileWorkflowId(pool, analysisId, workflowId) {
|
|
|
142
142
|
return (result.rowCount ?? 0) > 0;
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
+
function terminalWriteOf(rows) {
|
|
146
|
+
const row = rows[0];
|
|
147
|
+
return row === undefined ? { stamped: false } : { stamped: true, startedAt: row.data_profile_started_at ?? null };
|
|
148
|
+
}
|
|
145
149
|
/**
|
|
146
150
|
* Stamp a claimed-`running` row `completed` and record its result — a terminal
|
|
147
151
|
* write CAS'd on `data_profile_status = 'running'`.
|
|
@@ -157,8 +161,9 @@ export function recordDataProfileWorkflowId(pool, analysisId, workflowId) {
|
|
|
157
161
|
* Every legitimate caller reaches here from a row it claimed into `running`, so the
|
|
158
162
|
* guard only ever refuses a row another writer already moved on from.
|
|
159
163
|
*
|
|
160
|
-
* `
|
|
161
|
-
*
|
|
164
|
+
* `stamped: true` when this call stamped the row, with the `data_profile_started_at`
|
|
165
|
+
* of the claim it closed; `stamped: false` when the guard refused it (the row was
|
|
166
|
+
* cleared/expired/replayed away) — a no-op the caller logs, not an error.
|
|
162
167
|
*/
|
|
163
168
|
export function completeDataProfile(pool, analysisId, result) {
|
|
164
169
|
const now = new Date().toISOString();
|
|
@@ -167,10 +172,11 @@ export function completeDataProfile(pool, analysisId, result) {
|
|
|
167
172
|
text: `UPDATE cortex_analysis_state
|
|
168
173
|
SET data_profile_status = 'completed', data_profile_completed_at = $1,
|
|
169
174
|
data_profile_result = $2::jsonb
|
|
170
|
-
WHERE analysis_id = $3 AND data_profile_status = 'running'
|
|
175
|
+
WHERE analysis_id = $3 AND data_profile_status = 'running'
|
|
176
|
+
RETURNING data_profile_started_at`,
|
|
171
177
|
values: [now, result ? JSON.stringify(result) : null, analysisId],
|
|
172
178
|
});
|
|
173
|
-
return (res.
|
|
179
|
+
return terminalWriteOf(res.rows);
|
|
174
180
|
});
|
|
175
181
|
}
|
|
176
182
|
/**
|
|
@@ -181,8 +187,9 @@ export function completeDataProfile(pool, analysisId, result) {
|
|
|
181
187
|
* `failed` status over a cleared row. `data_profile_result` is left untouched so a
|
|
182
188
|
* prior profile survives the failure (the re-profile/retry route can keep serving it).
|
|
183
189
|
*
|
|
184
|
-
* `
|
|
185
|
-
*
|
|
190
|
+
* `stamped: true` when this call stamped the row, with the `data_profile_started_at`
|
|
191
|
+
* of the claim it closed; `stamped: false` when the guard refused it — a logged
|
|
192
|
+
* no-op, not an error.
|
|
186
193
|
*/
|
|
187
194
|
export function failDataProfile(pool, analysisId, error) {
|
|
188
195
|
const now = new Date().toISOString();
|
|
@@ -191,10 +198,11 @@ export function failDataProfile(pool, analysisId, error) {
|
|
|
191
198
|
text: `UPDATE cortex_analysis_state
|
|
192
199
|
SET data_profile_status = 'failed', data_profile_error = $1,
|
|
193
200
|
data_profile_completed_at = $2
|
|
194
|
-
WHERE analysis_id = $3 AND data_profile_status = 'running'
|
|
201
|
+
WHERE analysis_id = $3 AND data_profile_status = 'running'
|
|
202
|
+
RETURNING data_profile_started_at`,
|
|
195
203
|
values: [error, now, analysisId],
|
|
196
204
|
});
|
|
197
|
-
return (res.
|
|
205
|
+
return terminalWriteOf(res.rows);
|
|
198
206
|
});
|
|
199
207
|
}
|
|
200
208
|
export function expireStaleDataProfile(pool, analysisId, timeoutMs) {
|