@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.
Files changed (44) hide show
  1. package/dist/citations/types.d.ts +2 -2
  2. package/dist/config/environment-stores.d.ts +10 -0
  3. package/dist/contracts/schemas/chat-parts.d.ts +11 -11
  4. package/dist/execution/run-canceler.js +20 -4
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.js +5 -0
  7. package/dist/lib/metrics.d.ts +63 -5
  8. package/dist/lib/metrics.js +104 -5
  9. package/dist/lib/otel-spans.d.ts +77 -0
  10. package/dist/lib/otel-spans.js +123 -0
  11. package/dist/lib/otel.d.ts +12 -3
  12. package/dist/lib/otel.js +15 -4
  13. package/dist/loop/run-agent.js +13 -2
  14. package/dist/prompts/planner.js +6 -0
  15. package/dist/prompts/sandbox-standards.d.ts +1 -1
  16. package/dist/prompts/sandbox-standards.js +8 -3
  17. package/dist/runtime/dbos.d.ts +8 -7
  18. package/dist/sandbox/await-exec.js +16 -9
  19. package/dist/sandbox/notification-sweep.js +5 -2
  20. package/dist/sandbox/package-identity.d.ts +208 -0
  21. package/dist/sandbox/package-identity.js +244 -0
  22. package/dist/sandbox/reaper.js +5 -2
  23. package/dist/sandbox/submit-exec.js +6 -3
  24. package/dist/sandbox/types.d.ts +20 -25
  25. package/dist/sandbox/watchdog.js +8 -3
  26. package/dist/schemas/plan-schemas.d.ts +3 -3
  27. package/dist/schemas/validate-plan.js +32 -15
  28. package/dist/schemas/workflow-state.js +3 -1
  29. package/dist/state/data-profile.d.ts +20 -6
  30. package/dist/state/data-profile.js +16 -8
  31. package/dist/state/index.d.ts +1 -1
  32. package/dist/state/schema.d.ts +2 -2
  33. package/dist/tasks/data-profile.js +26 -12
  34. package/dist/tools/execute-analysis.js +37 -20
  35. package/dist/tools/research/inspect-run.d.ts +1 -1
  36. package/dist/tools/sandbox/link-packages.d.ts +6 -6
  37. package/dist/tools/sandbox/link-packages.js +45 -14
  38. package/dist/tools/sandbox/list-available-packages.d.ts +23 -1
  39. package/dist/tools/sandbox/list-available-packages.js +185 -43
  40. package/dist/workflows/execute-analysis.d.ts +4 -1
  41. package/dist/workflows/execute-analysis.js +40 -4
  42. package/dist/workflows/execute-target-assessment.js +68 -31
  43. package/dist/workflows/sandbox-step.js +7 -1
  44. 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
+ }
@@ -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: "sandbox-reaper" });
157
+ }, { name: REAPER_WORKFLOW });
158
+ untracedWorkflow(REAPER_WORKFLOW);
156
159
  DBOS.registerScheduled(reaper, {
157
- name: "sandbox-reaper",
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
- await runStep(() => postExec(fetchImpl, ref, capped), {
75
- name: `sandbox.submit-exec.${body.execId}`,
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
  }
@@ -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 package ask of the farm-extension seam. `version` pins one exact
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
- * `name` echoes the REQUESTED spelling verbatim, never a normalized form. A
98
- * caller quotes it into a remedy (`inflexa store add <name>`), and an R name
99
- * is case-sensitive with dots, thus a normalized echo would name a package
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 request resolves to two store directories: two
110
- * versions of one distribution, or one name that both tracks hold. The
111
- * outcome is terminal for the request. `detail`, when the realization
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 name: string;
115
+ readonly spelling: string;
124
116
  readonly version: string;
125
117
  } | {
126
118
  readonly kind: "present";
127
- readonly name: string;
119
+ readonly spelling: string;
128
120
  readonly version: string;
129
121
  } | {
130
122
  readonly kind: "absent";
131
- readonly name: string;
123
+ readonly spelling: string;
132
124
  readonly acquisitionPossible: boolean;
133
125
  readonly detail?: string;
134
126
  } | {
135
127
  readonly kind: "collision";
136
- readonly name: string;
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 name: string;
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, requests: readonly PackageRequest[]) => Promise<readonly PackageRequestOutcome[]>;
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
@@ -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: "sandbox-watchdog-shard" });
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: "sandbox-watchdog-parent" });
155
+ }, { name: WATCHDOG_PARENT_WORKFLOW });
156
+ untracedWorkflow(WATCHDOG_PARENT_WORKFLOW);
152
157
  DBOS.registerScheduled(watchdogParent, {
153
- name: "sandbox-watchdog-parent",
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. Package entries are requirements, never locations. A path, a URL, or
113
- // a store directory in a plan would publish an installer detail as an
114
- // interface — the link pass resolves names against the pool. An absent
115
- // array passes, because stored plans from before the field carry none.
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
- if (/^[.~]|[/\\]|:\/\//.test(entry)) {
122
- errors.push(`Step "${step.id}" names a package location "${entry}" — name each package as a requirement ` +
123
- `(a bare name, or name==version), never a path, a URL, or a store directory`);
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 requirement: a bare name ("scanpy") or a name with one exact version ("numpy==1.26.4"). ' +
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
- * `ok(true)` when this call stamped the row; `ok(false)` when the guard refused it
115
- * (the row was cleared/expired/replayed away) — a no-op the caller logs, not an error.
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<boolean, DbError>;
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
- * `ok(true)` when this call stamped the row; `ok(false)` when the guard refused it —
127
- * a logged no-op, not an error.
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<boolean, DbError>;
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
- * `ok(true)` when this call stamped the row; `ok(false)` when the guard refused it
161
- * (the row was cleared/expired/replayed away) — a no-op the caller logs, not an error.
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.rowCount ?? 0) > 0;
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
- * `ok(true)` when this call stamped the row; `ok(false)` when the guard refused it —
185
- * a logged no-op, not an error.
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.rowCount ?? 0) > 0;
205
+ return terminalWriteOf(res.rows);
198
206
  });
199
207
  }
200
208
  export function expireStaleDataProfile(pool, analysisId, timeoutMs) {