@inflexa-ai/harness 0.35.3 → 0.35.5

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 (35) 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.d.ts +6 -2
  10. package/dist/lib/otel.js +6 -2
  11. package/dist/prompts/planner.js +6 -0
  12. package/dist/prompts/sandbox-standards.d.ts +1 -1
  13. package/dist/prompts/sandbox-standards.js +8 -3
  14. package/dist/sandbox/package-identity.d.ts +208 -0
  15. package/dist/sandbox/package-identity.js +244 -0
  16. package/dist/sandbox/types.d.ts +20 -25
  17. package/dist/schemas/plan-schemas.d.ts +3 -3
  18. package/dist/schemas/validate-plan.js +32 -15
  19. package/dist/schemas/workflow-state.js +3 -1
  20. package/dist/state/data-profile.d.ts +20 -6
  21. package/dist/state/data-profile.js +16 -8
  22. package/dist/state/index.d.ts +1 -1
  23. package/dist/state/schema.d.ts +2 -2
  24. package/dist/tasks/data-profile.js +26 -12
  25. package/dist/tools/execute-analysis.js +37 -20
  26. package/dist/tools/research/inspect-run.d.ts +1 -1
  27. package/dist/tools/sandbox/link-packages.d.ts +6 -6
  28. package/dist/tools/sandbox/link-packages.js +45 -14
  29. package/dist/tools/sandbox/list-available-packages.d.ts +23 -1
  30. package/dist/tools/sandbox/list-available-packages.js +185 -43
  31. package/dist/workflows/execute-analysis.d.ts +4 -1
  32. package/dist/workflows/execute-analysis.js +34 -3
  33. package/dist/workflows/execute-target-assessment.js +59 -28
  34. package/dist/workflows/sandbox-step.js +7 -1
  35. package/package.json +1 -1
@@ -0,0 +1,208 @@
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 { type Result } from "neverthrow";
21
+ /** The two ecosystems that the pool holds. */
22
+ export type Track = "python" | "r";
23
+ /**
24
+ * What a person or an agent asked for. `spelling` is the name verbatim, as the
25
+ * caller wrote it — a remedy quotes it, and an R name is case-sensitive at
26
+ * `library()`. `track` names one ecosystem, and its absence searches both.
27
+ * `version` pins one exact version.
28
+ *
29
+ * A query is never a key. Two queries of one fold are two asks, because a
30
+ * caller that wrote `decoupleR` did not ask for `decoupler`.
31
+ */
32
+ export type PackageQuery = {
33
+ readonly spelling: string;
34
+ readonly track?: Track;
35
+ readonly version?: string;
36
+ };
37
+ /**
38
+ * The brand of an identity name. It has no runtime value: it exists so that a
39
+ * literal `{ track, name }` fails the typecheck outside this module, and thus
40
+ * every identity comes from a constructor that applied the rule of its track.
41
+ */
42
+ declare const identityBrand: unique symbol;
43
+ /**
44
+ * The name that one ecosystem recognizes, under the track that holds it.
45
+ *
46
+ * The name of a Python identity is the PEP 503 fold of the spelling, because
47
+ * PEP 503 defines the equivalence of a distribution name. The name of an R
48
+ * identity is the DESCRIPTION spelling, verbatim, because `library()` is
49
+ * case-sensitive. Two identities are equal when their keys are equal.
50
+ */
51
+ export type PackageIdentity = {
52
+ readonly track: Track;
53
+ readonly name: string & {
54
+ readonly [identityBrand]: true;
55
+ };
56
+ };
57
+ /**
58
+ * The Python identity of a spelling. The name folds, because PEP 503 makes
59
+ * `PyYAML`, `pyyaml`, and `py_yaml` one distribution.
60
+ *
61
+ * @param spelling The distribution name, as the caller spells it.
62
+ */
63
+ export declare function pythonIdentity(spelling: string): PackageIdentity;
64
+ /**
65
+ * The R identity of a name. The name keeps its spelling, because `library()`
66
+ * is case-sensitive and `GO.db` loads under that exact name.
67
+ *
68
+ * @param name The DESCRIPTION `Package` value.
69
+ */
70
+ export declare function rIdentity(name: string): PackageIdentity;
71
+ /**
72
+ * The identity of a name that an emitter already minted — a graph node, a lock
73
+ * row. The dispatch is safe over such a name, because the fold is idempotent.
74
+ *
75
+ * @param track The ecosystem that holds the name.
76
+ * @param name The name as the emitter wrote it.
77
+ */
78
+ export declare function identityOf(track: Track, name: string): PackageIdentity;
79
+ /**
80
+ * The key of an identity: `<track>:<name>`. Two identities are equal when
81
+ * their keys are equal, thus a map of identities keys on this string.
82
+ */
83
+ export declare function identityKey(identity: PackageIdentity): string;
84
+ /**
85
+ * The identity that a key names, or `undefined` when the string is not a key.
86
+ *
87
+ * The FIRST colon splits the two halves. A track name holds no colon, and
88
+ * neither a PEP 503 name nor an R name can hold one, thus `r:GO.db` gives the
89
+ * R identity `GO.db`, dot and all. The name rides through {@link identityOf},
90
+ * and the fold is idempotent, thus a key that {@link identityKey} wrote comes
91
+ * back as the identity that wrote it.
92
+ *
93
+ * The key format is minted here, thus it is read here: a reader that splits
94
+ * the string itself owns a second copy of the format.
95
+ *
96
+ * @param key A `<track>:<name>` string, as {@link identityKey} writes it.
97
+ */
98
+ export declare function parseIdentityKey(key: string): PackageIdentity | undefined;
99
+ /**
100
+ * The store address of an identity: the PEP 503 fold of its name, for both
101
+ * tracks. A store directory is an address and not an identity, thus two
102
+ * identities can share one address — `r:decoupleR` and `python:decoupler`
103
+ * both address as `decoupler`. The pin marker inside the directory carries
104
+ * the identity.
105
+ */
106
+ export declare function identityAddress(identity: PackageIdentity): string;
107
+ /**
108
+ * Why an entry is not a query.
109
+ *
110
+ * - `empty` — the entry names no package.
111
+ * - `location` — a path, a URL, or a store directory. A location is an
112
+ * installer detail, and the pool resolves names.
113
+ * - `unknown_prefix` — a `<word>:` prefix that is neither `python:` nor `r:`.
114
+ * Unrefused, such a prefix rides into the pool as part of the name.
115
+ * - `unsupported_specifier` — a specifier that is not `==`. Unrefused, a range
116
+ * such as `numpy>=1.26` becomes a package name.
117
+ */
118
+ export type ParseQueryError = {
119
+ readonly type: "empty";
120
+ } | {
121
+ readonly type: "location";
122
+ readonly entry: string;
123
+ } | {
124
+ readonly type: "unknown_prefix";
125
+ readonly prefix: string;
126
+ readonly entry: string;
127
+ } | {
128
+ readonly type: "unsupported_specifier";
129
+ readonly specifier: string;
130
+ readonly entry: string;
131
+ };
132
+ /**
133
+ * Read one entry of the grammar `[python:|r:]<spelling>[==<version>]`.
134
+ *
135
+ * The entry trims once, and the parts keep what the trim left. Every reader of
136
+ * the grammar in the harness calls this function: a second parser would
137
+ * disagree with this one, and the plan validation that passed would then meet
138
+ * a link pass that refuses.
139
+ *
140
+ * @param entry One package entry, as a plan, a tool call, or a host wrote it.
141
+ */
142
+ export declare function parseQuery(entry: string): Result<PackageQuery, ParseQueryError>;
143
+ /**
144
+ * Write a query in the grammar. The prefix rides only beside a track, and
145
+ * `==<version>` only beside a version. For every query,
146
+ * `parseQuery(formatQuery(query))` gives that query again.
147
+ */
148
+ export declare function formatQuery(query: PackageQuery): string;
149
+ /**
150
+ * What a pool answers about the identities that it holds. Two reads are
151
+ * enough for the ladder, thus a caller builds this over a graph, a lock, or a
152
+ * census without exposing its own shape.
153
+ */
154
+ export type PoolIndex = {
155
+ /** Whether the pool holds this exact identity. */
156
+ has: (identity: PackageIdentity) => boolean;
157
+ /**
158
+ * Each R identity of the pool whose address is this fold. The suggestion
159
+ * of an unknown query comes from here: a person who wrote `seurat` meant
160
+ * the R package `Seurat`.
161
+ */
162
+ rIdentitiesFoldingTo: (fold: string) => readonly PackageIdentity[];
163
+ };
164
+ /**
165
+ * What a query resolves to.
166
+ *
167
+ * - `resolved` — one identity, thus the caller reads the pool under it.
168
+ * - `ambiguous` — one spelling that both tracks hold. The caller refuses, and
169
+ * the remedy is a prefixed query.
170
+ * - `unknown` — the pool holds no identity of this query. `suggestion` names
171
+ * the R identity that the caller probably meant.
172
+ */
173
+ export type QueryResolution = {
174
+ readonly kind: "resolved";
175
+ readonly identity: PackageIdentity;
176
+ } | {
177
+ readonly kind: "ambiguous";
178
+ readonly python: PackageIdentity;
179
+ readonly r: PackageIdentity;
180
+ } | {
181
+ readonly kind: "unknown";
182
+ readonly suggestion?: PackageIdentity;
183
+ };
184
+ /**
185
+ * Resolve one query against a pool.
186
+ *
187
+ * The version takes no part, because the caller holds the versions: the graph
188
+ * orders them, and this function answers which package the query names.
189
+ *
190
+ * The ladder, in order:
191
+ *
192
+ * 1. The query names a track: the identity of that track, when the pool holds
193
+ * it. Otherwise unknown, with the R suggestion when the track is `r`.
194
+ * 2. The pool holds the R identity of the spelling and the Python identity of
195
+ * its fold: the R identity when the spelling differs from its fold, because
196
+ * an uppercase letter or a dot is evidence of an R spelling. Otherwise
197
+ * ambiguous.
198
+ * 3. The pool holds the R identity: the R identity.
199
+ * 4. The pool holds the Python identity: the Python identity.
200
+ * 5. Exactly one R identity of the pool folds to the fold of the spelling:
201
+ * unknown, with that identity as the suggestion.
202
+ * 6. Otherwise: unknown.
203
+ *
204
+ * A silent Python-first pick is a fault: it sends `decoupleR` to the Python
205
+ * `decoupler`, and the R package never links.
206
+ */
207
+ export declare function resolveQuery(query: PackageQuery, pool: PoolIndex): QueryResolution;
208
+ export {};
@@ -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
+ }
@@ -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
@@ -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