@inflexa-ai/harness 0.34.0 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `image-packages.json` — the self-description of the sandbox image.
3
+ *
4
+ * The image bakes two package tracks that the content-addressed store cannot
5
+ * hold: the conda tools at `/opt/conda` and the Node packages at `/opt/node`.
6
+ * The image writes one record of them at `/opt/inflexa/image-packages.json`,
7
+ * and the catalog build copies that record verbatim into the root of the
8
+ * package store it packed beside it. Thus every reader of a store learns the
9
+ * image inventory from the store alone — with no container engine, no
10
+ * registry client, and no path outside the store.
11
+ *
12
+ * The record is JSON with a `schema` number, the same convention as
13
+ * {@link FarmLockSchema}: an additive field passes through untouched, and a
14
+ * breaking change to the shape moves the number instead. A structured record
15
+ * is what lets a row carry its version, and what lets a later change compare
16
+ * the recorded image against the configured one.
17
+ *
18
+ * This schema is the ONE definition of the shape. The image writes the bytes
19
+ * and the harness validates them, thus a producer that drifts is refused here
20
+ * rather than half-read downstream.
21
+ */
22
+ import { readFileSync } from "node:fs";
23
+ import { err, ok } from "neverthrow";
24
+ import { z } from "zod";
25
+ /**
26
+ * The file name of the record, at the root of the package store. Exported,
27
+ * as `FARM_LOCK_FILE` is, thus an embedder that joins the path onto its own
28
+ * store root never spells the name by hand.
29
+ */
30
+ export const IMAGE_PACKAGES_FILE = "image-packages.json";
31
+ /** The image the record describes. The pair of tags shares one version string. */
32
+ export const ImageIdentitySchema = z
33
+ .object({
34
+ repository: z.string(),
35
+ version: z.string(),
36
+ arch: z.enum(["amd64", "arm64"]),
37
+ })
38
+ .passthrough();
39
+ /** The interpreter versions of the image, so a reader can compare them against the lock. */
40
+ export const ImageRuntimesSchema = z
41
+ .object({
42
+ python: z.string(),
43
+ r: z.string(),
44
+ node: z.string(),
45
+ })
46
+ .passthrough();
47
+ /**
48
+ * One conda tool of the image. `executable` carries the binary name when it
49
+ * differs from the package name (the manifest `binaries:` map holds those
50
+ * exceptions), because an agent invokes the binary and not the package.
51
+ */
52
+ export const ImageSystemToolSchema = z
53
+ .object({
54
+ name: z.string(),
55
+ version: z.string(),
56
+ executable: z.string().optional(),
57
+ })
58
+ .passthrough();
59
+ /** One Node package of the image. */
60
+ export const ImageNodePackageSchema = z
61
+ .object({
62
+ name: z.string(),
63
+ version: z.string(),
64
+ })
65
+ .passthrough();
66
+ /**
67
+ * `image-packages.json` at schema version 1. The keys `system_tools` and
68
+ * `node` are the keys of the image manifest, thus one name means one track
69
+ * across the build and the reader.
70
+ */
71
+ export const ImagePackagesSchema = z
72
+ .object({
73
+ schema: z.literal(1),
74
+ image: ImageIdentitySchema,
75
+ runtimes: ImageRuntimesSchema,
76
+ system_tools: z.array(ImageSystemToolSchema),
77
+ node: z.array(ImageNodePackageSchema),
78
+ })
79
+ .passthrough();
80
+ /**
81
+ * Read and validate one `image-packages.json` by its file path. The read is
82
+ * synchronous, the same as {@link readFarmLockFile}, because the caller reads
83
+ * the record on each call and a store download can land while the process
84
+ * runs. Both errors are normal states of a host that reads a store from
85
+ * before this record existed.
86
+ */
87
+ export function readImagePackagesFile(recordPath) {
88
+ let raw;
89
+ try {
90
+ raw = readFileSync(recordPath, "utf8");
91
+ }
92
+ catch (cause) {
93
+ return err({ type: "record_unreadable", recordPath, cause });
94
+ }
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(raw);
98
+ }
99
+ catch (cause) {
100
+ return err({ type: "record_invalid", recordPath, cause });
101
+ }
102
+ const record = ImagePackagesSchema.safeParse(parsed);
103
+ if (!record.success) {
104
+ return err({ type: "record_invalid", recordPath, cause: record.error });
105
+ }
106
+ return ok(record.data);
107
+ }
@@ -71,6 +71,17 @@ export interface K8sClientConfig {
71
71
  farmSource: FarmSource;
72
72
  /** The declared toolchain owner. Absent means `"store"`, the legacy environment. */
73
73
  toolchainSource?: ToolchainSource;
74
+ /**
75
+ * The deployment cannot run an analysis without the package store. Under
76
+ * the fact, a farm that fails the `inflexa.lock` gate refuses the create
77
+ * with `farm_unusable` and no Job is made. Absent keeps the degrade: the
78
+ * store mounts drop, a warning is logged, and the Job is still made. The
79
+ * field names the deployment fact rather than the remediation, so how the
80
+ * harness answers it can evolve without changing the embedder surface; a
81
+ * single-valued union (not a boolean) so future store classes extend it
82
+ * without a breaking change.
83
+ */
84
+ packageStore?: "required";
74
85
  /** PVC claim mounted read-only at `/mnt/refs` when set. */
75
86
  refStorePvc?: string;
76
87
  /** Node selector pinning sandbox pods to the dedicated agent pool. Omit for default scheduling. */
@@ -465,14 +465,30 @@ export function createK8sSandboxOps(config) {
465
465
  // The `inflexa.lock` gate, for a host that mounts the libs
466
466
  // volume itself. The resolver owes the proof either way,
467
467
  // but a farm half-written between the resolution and this
468
- // create still mounts without a second look. A gate failure
469
- // degrades exactly as the Docker backend does: no store
470
- // mount, the Job is still made, and the sandbox reports the
471
- // store as unavailable.
468
+ // create still mounts without a second look. The failure
469
+ // has the two outcomes of the Docker backend. Under the
470
+ // declared fact the call refuses with `farm_unusable`
471
+ // before any cluster work, and it logs nothing: the
472
+ // returned value carries the reason, and the consumer logs
473
+ // the throw, thus a warning here would record the one fault
474
+ // twice. Without the fact it degrades — no store mount, the
475
+ // Job is still made, and the sandbox reports the store as
476
+ // unavailable.
472
477
  let libsMounted = !!config.libStorePvc;
473
478
  if (config.libStorePvc && config.libStorePvcRoot && farm) {
474
479
  const lock = readFarmLock(join(config.libStorePvcRoot, farm.farmPath));
475
480
  libsMounted = lock.isOk();
481
+ if (lock.isErr() && config.packageStore === "required") {
482
+ return err({
483
+ type: "farm_unusable",
484
+ op: "k8s.createSandbox",
485
+ analysisId: meta.analysisId,
486
+ farmPath: farm.farmPath,
487
+ lockPath: lock.error.lockPath,
488
+ lockError: lock.error.type,
489
+ cause: lock.error.cause,
490
+ });
491
+ }
476
492
  if (lock.isErr()) {
477
493
  (config.logger ?? createNoopLogger())
478
494
  .named("k8s-client")
@@ -26,10 +26,12 @@
26
26
  * ever wraps the single SDK/HTTP call handed to it.
27
27
  *
28
28
  * The public `SandboxClient` interface keeps `Promise<T>` method signatures.
29
- * `create-sandbox.ts` is the composition seam: it builds the backend ops, then
30
- * `unwrapOrThrow`s each op's `ResultAsync` so the seam re-throws a genuine
31
- * `SandboxError`. (`submitExec` is the exception — it is already its own DBOS
32
- * step and throws directly, so the seam forwards its `Promise<void>` as-is.)
29
+ * `create-sandbox.ts` is the composition seam: it builds the backend ops, maps
30
+ * each `err` of an op to a `SandboxFailure`, and `unwrapOrThrow`s it, so the
31
+ * seam throws the described failure with the variant on `.cause` and on
32
+ * `.error`. A `catch` never sees the raw variant. (`submitExec` is the
33
+ * exception — it is already its own DBOS step and throws directly, so the seam
34
+ * forwards its `Promise<void>` as-is.)
33
35
  * Workflow consumers (`sandbox-step.ts`) already wrap each seam call in
34
36
  * `DBOS.runStep`, so the re-thrown error records the step as failed;
35
37
  * non-workflow consumers (`reaper`, `watchdog`,
@@ -37,6 +39,7 @@
37
39
  * untouched by this wave.
38
40
  */
39
41
  import { ResultAsync } from "neverthrow";
42
+ import type { FarmLockError } from "./farm.js";
40
43
  /**
41
44
  * A sandbox backend failure. Absence / idempotent "already gone" is NOT
42
45
  * modelled — those ride the OK channel (`ok(false)` for a dead/missing machine,
@@ -91,6 +94,22 @@ export type SandboxError = {
91
94
  readonly analysisId: string;
92
95
  readonly reason: string;
93
96
  readonly cause?: unknown;
97
+ } | {
98
+ /**
99
+ * The gate proved the `inflexa.lock` at the resolved farm and the
100
+ * farm is not usable. Raised only when the config declares
101
+ * `packageStore: "required"`; without the fact the same gate
102
+ * failure degrades to no store mount. The sibling
103
+ * `farm_unavailable` is the resolver refusing to name a farm at
104
+ * all — here a farm was named, and its lock does not hold up.
105
+ */
106
+ readonly type: "farm_unusable";
107
+ readonly op: string;
108
+ readonly analysisId: string;
109
+ readonly farmPath: string;
110
+ readonly lockPath: string;
111
+ readonly lockError: FarmLockError["type"];
112
+ readonly cause: unknown;
94
113
  };
95
114
  /**
96
115
  * The HTTP/driver status a backend SDK error carries. dockerode exposes it on
@@ -100,8 +119,24 @@ export type SandboxError = {
100
119
  * `typeof === "number"` to avoid returning it as a status.
101
120
  */
102
121
  export declare function sandboxStatusOf(cause: unknown): number | undefined;
103
- /** A one-line, user-facing description of a `SandboxError` for logs/error bodies. */
122
+ /**
123
+ * A one-line, user-facing description of a `SandboxError` for logs/error
124
+ * bodies. The variant gives the head. Where the cause names a reason, that
125
+ * bounded line closes the description, thus the engine or the parser says why
126
+ * on the operator surfaces.
127
+ */
104
128
  export declare function describeSandboxError(e: SandboxError): string;
129
+ /**
130
+ * The throw of the `SandboxClient` seam. The message is
131
+ * `describeSandboxError`, thus a surface that renders a message alone still
132
+ * names the fault and its reason. The variant rides two ways: on `cause`, so
133
+ * a cause-chain reader keeps working, and on `error`, so a consumer that
134
+ * matches the class reads the typed fields without a walk.
135
+ */
136
+ export declare class SandboxFailure extends Error {
137
+ readonly error: SandboxError;
138
+ constructor(error: SandboxError);
139
+ }
105
140
  /**
106
141
  * Wrap a single sandbox backend call (Docker SDK op, K8s API op, or the
107
142
  * `/exec` POST). A throw becomes `err(toError(status, cause))`; the caller
@@ -26,10 +26,12 @@
26
26
  * ever wraps the single SDK/HTTP call handed to it.
27
27
  *
28
28
  * The public `SandboxClient` interface keeps `Promise<T>` method signatures.
29
- * `create-sandbox.ts` is the composition seam: it builds the backend ops, then
30
- * `unwrapOrThrow`s each op's `ResultAsync` so the seam re-throws a genuine
31
- * `SandboxError`. (`submitExec` is the exception — it is already its own DBOS
32
- * step and throws directly, so the seam forwards its `Promise<void>` as-is.)
29
+ * `create-sandbox.ts` is the composition seam: it builds the backend ops, maps
30
+ * each `err` of an op to a `SandboxFailure`, and `unwrapOrThrow`s it, so the
31
+ * seam throws the described failure with the variant on `.cause` and on
32
+ * `.error`. A `catch` never sees the raw variant. (`submitExec` is the
33
+ * exception — it is already its own DBOS step and throws directly, so the seam
34
+ * forwards its `Promise<void>` as-is.)
33
35
  * Workflow consumers (`sandbox-step.ts`) already wrap each seam call in
34
36
  * `DBOS.runStep`, so the re-thrown error records the step as failed;
35
37
  * non-workflow consumers (`reaper`, `watchdog`,
@@ -37,6 +39,7 @@
37
39
  * untouched by this wave.
38
40
  */
39
41
  import { ResultAsync, err, ok } from "neverthrow";
42
+ import { ZodError } from "zod";
40
43
  const _assertDomainError = true;
41
44
  /**
42
45
  * The HTTP/driver status a backend SDK error carries. dockerode exposes it on
@@ -51,8 +54,62 @@ export function sandboxStatusOf(cause) {
51
54
  return e.code;
52
55
  return e.statusCode ?? e.response?.statusCode;
53
56
  }
54
- /** A one-line, user-facing description of a `SandboxError` for logs/error bodies. */
57
+ /**
58
+ * The bound on the appended cause line — the same 200 characters the profile
59
+ * ledger keeps (`tasks/data-profile.ts`), because a description reaches that
60
+ * ledger and the run row. A stack trace and an engine response body must stay
61
+ * out of a row, so the line is cut and marked rather than carried whole.
62
+ */
63
+ const CAUSE_LINE_MAX = 200;
64
+ /**
65
+ * The one bounded line that names the reason of a cause, or `undefined` when
66
+ * the cause carries no reason to name.
67
+ *
68
+ * A `ZodError` is special-cased: its `message` is multi-line JSON whose first
69
+ * line is `[`, which names nothing. Thus a zod cause renders as its first
70
+ * issue — the path and the message — and the reader sees which field broke.
71
+ * Any other cause gives the first line of its message.
72
+ */
73
+ function causeLine(cause) {
74
+ let text;
75
+ if (cause instanceof ZodError) {
76
+ const issue = cause.issues[0];
77
+ if (!issue)
78
+ return undefined;
79
+ const path = issue.path.join(".");
80
+ text = path.length > 0 ? `${path}: ${issue.message}` : issue.message;
81
+ }
82
+ else if (cause instanceof Error) {
83
+ text = cause.message;
84
+ }
85
+ else if (typeof cause === "string") {
86
+ text = cause;
87
+ }
88
+ else {
89
+ return undefined;
90
+ }
91
+ const first = text.split("\n", 1)[0].trim();
92
+ if (first.length === 0)
93
+ return undefined;
94
+ return first.length > CAUSE_LINE_MAX ? `${first.slice(0, CAUSE_LINE_MAX - 1)}…` : first;
95
+ }
96
+ /**
97
+ * A one-line, user-facing description of a `SandboxError` for logs/error
98
+ * bodies. The variant gives the head. Where the cause names a reason, that
99
+ * bounded line closes the description, thus the engine or the parser says why
100
+ * on the operator surfaces.
101
+ */
55
102
  export function describeSandboxError(e) {
103
+ // `farm_unavailable` is the one variant whose head already carries its
104
+ // reason, the text of the resolver. A cause line after it would put two
105
+ // dashes on one row, and the throw of a resolver is in the log with its
106
+ // stack. Every other variant names its reason only through the cause.
107
+ const line = e.type === "farm_unavailable" ? undefined : causeLine(e.cause);
108
+ const head = describeVariant(e);
109
+ return line === undefined ? head : `${head} — ${line}`;
110
+ }
111
+ /** The head of a description: what failed, at which op, on which subject. */
112
+ function describeVariant(e) {
56
113
  switch (e.type) {
57
114
  case "container_create_failed":
58
115
  return `sandbox create failed (${e.op}${e.sandboxId ? `: ${e.sandboxId}` : ""})`;
@@ -68,6 +125,23 @@ export function describeSandboxError(e) {
68
125
  return `sandbox liveness probe failed (${e.op}${e.sandboxId ? `: ${e.sandboxId}` : ""})`;
69
126
  case "farm_unavailable":
70
127
  return `farm unavailable (${e.op}: ${e.analysisId}) — ${e.reason}`;
128
+ case "farm_unusable":
129
+ return `farm unusable (${e.op}: ${e.analysisId}, ${e.lockError} at ${e.lockPath})`;
130
+ }
131
+ }
132
+ /**
133
+ * The throw of the `SandboxClient` seam. The message is
134
+ * `describeSandboxError`, thus a surface that renders a message alone still
135
+ * names the fault and its reason. The variant rides two ways: on `cause`, so
136
+ * a cause-chain reader keeps working, and on `error`, so a consumer that
137
+ * matches the class reads the typed fields without a walk.
138
+ */
139
+ export class SandboxFailure extends Error {
140
+ error;
141
+ constructor(error) {
142
+ super(describeSandboxError(error), { cause: error });
143
+ this.error = error;
144
+ this.name = "SandboxFailure";
71
145
  }
72
146
  }
73
147
  /**
@@ -9,12 +9,10 @@
9
9
  * planning surface reads the pool-scope inventory (`readPoolInventory`) —
10
10
  * everything the store holds, whether or not a farm links it yet — because
11
11
  * the ask flow marks the packages the POOL does not hold, and the farm of a
12
- * new analysis is empty. The baked image inventory fragment
13
- * (`image-packages.txt`, at `/opt/inflexa` in the image) merges into either
14
- * report when the host can read one. The fragment keeps the section format:
15
- *
16
- * ## System tools (CLI)
17
- * samtools, bcftools, ...
12
+ * new analysis is empty. The image inventory record (`image-packages.json`,
13
+ * at the root of the package store) merges into either report when the host
14
+ * can read one: it carries the conda tools and the Node packages the image
15
+ * owns, which the content-addressed store cannot hold.
18
16
  *
19
17
  * Each entry renders as `name==version` where the source pins a version, and
20
18
  * a targeted `names` lookup also carries the store directory and the full
@@ -23,6 +21,7 @@
23
21
  */
24
22
  import type { EnvironmentStorePaths, PoolInventoryPackage, PoolInventorySection } from "../../config/environment-stores.js";
25
23
  import { type FarmLock } from "../../sandbox/farm.js";
24
+ import { type ImagePackages } from "../../sandbox/image-packages.js";
26
25
  /**
27
26
  * Group the lock packages into sections, one per track, in a stable order:
28
27
  * the known tracks first, then an unknown track at its first occurrence.
@@ -30,9 +29,19 @@ import { type FarmLock } from "../../sandbox/farm.js";
30
29
  * targeted `names` path can report the exact store identity.
31
30
  */
32
31
  export declare function lockSections(lock: FarmLock): Section[];
32
+ /**
33
+ * Group the image record into its two sections, in the order of the record.
34
+ * An empty track yields no section, so the report never carries a heading
35
+ * with nothing under it.
36
+ *
37
+ * A `system_tools` row renders its `executable` name where the record gives
38
+ * one, because an agent invokes the binary rather than the conda package
39
+ * (the manifest `binaries:` map holds the pairs that differ).
40
+ */
41
+ export declare function imageSections(record: ImagePackages): Section[];
33
42
  /** One package entry of a section, with the store identity where the source records it. */
34
43
  export type SectionPackage = PoolInventoryPackage;
35
- /** One language track of the store, in `packages.txt` section order. The seam shape and the render shape are one. */
44
+ /** One language track of the store, in the section order of its source. The seam shape and the render shape are one. */
36
45
  export type Section = PoolInventorySection;
37
46
  /** One `names` lookup: present + canonical spelling + track + store identity, or absent. */
38
47
  export type CheckedPackage = {
@@ -65,13 +74,6 @@ export type PackagesResult = {
65
74
  readonly hasMore: boolean;
66
75
  readonly content: string;
67
76
  };
68
- /**
69
- * Parse `packages.txt` into its sections. `#` lines are the advisory header,
70
- * `## X` opens a section, and every other non-empty line contributes
71
- * comma-separated package names to the open section. Unknown section headings
72
- * are preserved as-is — a downstream image may add its own track.
73
- */
74
- export declare function parsePackagesFile(content: string): Section[];
75
77
  /** The tool's input, as parsed from `inputSchema`. */
76
78
  export interface PackagesQuery {
77
79
  readonly names?: readonly string[];
@@ -83,7 +85,7 @@ export interface PackagesQuery {
83
85
  * Answer a packages query against a parsed catalog. Pure — the tool's `execute`
84
86
  * is only the file read plus this call.
85
87
  */
86
- export declare function queryPackages(sections: readonly Section[], { names, query, language, limit }: PackagesQuery): PackagesResult;
88
+ export declare function queryPackages(rawSections: readonly Section[], { names, query, language, limit }: PackagesQuery): PackagesResult;
87
89
  export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "farmLockFile" | "imagePackagesFile" | "readPoolInventory"> & {
88
90
  /**
89
91
  * True when the composition also attaches the `link_packages` tool. The
@@ -96,7 +98,7 @@ export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "farmLockFil
96
98
  * Create the package inventory over the bound source: the pool-scope reader
97
99
  * when the embedder binds one (a conversation or planning surface), the
98
100
  * host-readable farm `inflexa.lock` otherwise (a sandbox agent). The image
99
- * fragment merges into either report.
101
+ * record merges into either report.
100
102
  */
101
103
  export declare function createListAvailablePackagesTool(deps?: ListAvailablePackagesDeps): import("../define-tool.js").Tool<{
102
104
  names?: string[] | undefined;
@@ -9,25 +9,23 @@
9
9
  * planning surface reads the pool-scope inventory (`readPoolInventory`) —
10
10
  * everything the store holds, whether or not a farm links it yet — because
11
11
  * the ask flow marks the packages the POOL does not hold, and the farm of a
12
- * new analysis is empty. The baked image inventory fragment
13
- * (`image-packages.txt`, at `/opt/inflexa` in the image) merges into either
14
- * report when the host can read one. The fragment keeps the section format:
15
- *
16
- * ## System tools (CLI)
17
- * samtools, bcftools, ...
12
+ * new analysis is empty. The image inventory record (`image-packages.json`,
13
+ * at the root of the package store) merges into either report when the host
14
+ * can read one: it carries the conda tools and the Node packages the image
15
+ * owns, which the content-addressed store cannot hold.
18
16
  *
19
17
  * Each entry renders as `name==version` where the source pins a version, and
20
18
  * a targeted `names` lookup also carries the store directory and the full
21
19
  * content hash where the source records them. A full listing carries no
22
20
  * hashes — a thousand rows of sha256 would bury the signal.
23
21
  */
24
- import { readFile } from "node:fs/promises";
25
22
  import { ok } from "neverthrow";
26
23
  import { z } from "zod";
27
24
  import { defineTool } from "../define-tool.js";
28
25
  import { capCodePoints, DETAIL_NEEDLE_MAX_LENGTH } from "../../loop/tool-detail.js";
29
26
  import { LIBS_CONTAINER_PATH } from "../../sandbox/mount-plan.js";
30
27
  import { readFarmLockFile } from "../../sandbox/farm.js";
28
+ import { IMAGE_PACKAGES_FILE, readImagePackagesFile } from "../../sandbox/image-packages.js";
31
29
  /**
32
30
  * Where the lock lives when the host mounts the farm at the same path the
33
31
  * sandbox does. Both farm container paths are tried, because the path keys on
@@ -36,15 +34,30 @@ import { readFarmLockFile } from "../../sandbox/farm.js";
36
34
  * reads the farm somewhere else injects its own path instead.
37
35
  */
38
36
  const DEFAULT_FARM_LOCK_FILES = [`${LIBS_CONTAINER_PATH}/farm/inflexa.lock`, `${LIBS_CONTAINER_PATH}/current/inflexa.lock`];
39
- /** Where the baked image inventory fragment lives inside the image. */
40
- const DEFAULT_IMAGE_PACKAGES_FILE = "/opt/inflexa/image-packages.txt";
37
+ /**
38
+ * Where the image inventory record lives: at the root of the package store,
39
+ * which the catalog build copied it into. The default names the container
40
+ * mountpoint of that store, the same rule as {@link DEFAULT_FARM_LOCK_FILES}
41
+ * — a host whose own process sees the store the way a sandbox does needs no
42
+ * configuration. A host that reads the store somewhere else injects
43
+ * `imagePackagesFile`.
44
+ */
45
+ const DEFAULT_IMAGE_PACKAGES_FILE = `${LIBS_CONTAINER_PATH}/${IMAGE_PACKAGES_FILE}`;
46
+ /**
47
+ * The two headings that a lock track and the image record can both carry.
48
+ * Declared once, because {@link LANGUAGE_MATCHERS} keys the `language` filter
49
+ * on the heading text, and {@link foldSections} merges two sources by it: a
50
+ * second spelling of either would split one track into two headings.
51
+ */
52
+ const CLI_TITLE = "System tools (CLI)";
53
+ const NODE_TITLE = "Node (npm)";
41
54
  /** The section title of each known lock track. An unknown track titles itself. */
42
55
  const TRACK_TITLES = {
43
56
  python: "Python (pip)",
44
57
  cran: "R (CRAN)",
45
58
  bioconductor: "R (Bioconductor)",
46
59
  github: "R (GitHub)",
47
- node: "Node (npm)",
60
+ node: NODE_TITLE,
48
61
  };
49
62
  /**
50
63
  * Group the lock packages into sections, one per track, in a stable order:
@@ -66,6 +79,56 @@ export function lockSections(lock) {
66
79
  }
67
80
  return [...byTrack.entries()].filter(([, packages]) => packages.length > 0).map(([track, packages]) => ({ title: TRACK_TITLES[track] ?? track, packages }));
68
81
  }
82
+ /**
83
+ * Group the image record into its two sections, in the order of the record.
84
+ * An empty track yields no section, so the report never carries a heading
85
+ * with nothing under it.
86
+ *
87
+ * A `system_tools` row renders its `executable` name where the record gives
88
+ * one, because an agent invokes the binary rather than the conda package
89
+ * (the manifest `binaries:` map holds the pairs that differ).
90
+ */
91
+ export function imageSections(record) {
92
+ const sections = [];
93
+ if (record.system_tools.length > 0) {
94
+ sections.push({
95
+ title: CLI_TITLE,
96
+ packages: record.system_tools.map((tool) => ({ name: tool.executable ?? tool.name, version: tool.version })),
97
+ });
98
+ }
99
+ if (record.node.length > 0) {
100
+ sections.push({ title: NODE_TITLE, packages: record.node.map((pkg) => ({ name: pkg.name, version: pkg.version })) });
101
+ }
102
+ return sections;
103
+ }
104
+ /**
105
+ * Merge the sections that share a title, in first-occurrence order, and keep
106
+ * the first entry of each name inside a merged section.
107
+ *
108
+ * Two sources can carry one track: the `node` track of a farm lock and the
109
+ * `node` track of the image record. Rendered as they arrive, a listing would
110
+ * print one heading twice and read as two tracks. The fold keeps the first
111
+ * writer, which is the same rule the `names` index applies, thus the listing
112
+ * and the presence check agree on which entry a colliding name resolves to.
113
+ */
114
+ function foldSections(sections) {
115
+ const byTitle = new Map();
116
+ for (const section of sections) {
117
+ let open = byTitle.get(section.title);
118
+ if (!open) {
119
+ open = { title: section.title, packages: [], seen: new Set() };
120
+ byTitle.set(section.title, open);
121
+ }
122
+ for (const pkg of section.packages) {
123
+ const key = pkg.name.toLowerCase();
124
+ if (open.seen.has(key))
125
+ continue;
126
+ open.seen.add(key);
127
+ open.packages.push(pkg);
128
+ }
129
+ }
130
+ return [...byTitle.values()].map(({ title, packages }) => ({ title, packages }));
131
+ }
69
132
  /**
70
133
  * Default cap on a listing — high enough that the real store is never truncated.
71
134
  * The shipped catalog is ~270 packages (~3 KB, well under a thousand tokens), so
@@ -98,39 +161,10 @@ const LANGUAGE_MATCHERS = {
98
161
  // The R triple: `R (CRAN)`, `R (Bioconductor)`, `R (GitHub)`.
99
162
  r: (t) => /^r\b/i.test(t),
100
163
  python: (t) => /^python\b/i.test(t),
101
- // `System tools (CLI)` — the conda-installed bioinformatics executables.
164
+ // `CLI_TITLE` — the conda-installed bioinformatics executables.
102
165
  cli: (t) => /system tools|\bcli\b/i.test(t),
103
166
  node: (t) => /^node\b/i.test(t),
104
167
  };
105
- /**
106
- * Parse `packages.txt` into its sections. `#` lines are the advisory header,
107
- * `## X` opens a section, and every other non-empty line contributes
108
- * comma-separated package names to the open section. Unknown section headings
109
- * are preserved as-is — a downstream image may add its own track.
110
- */
111
- export function parsePackagesFile(content) {
112
- const sections = [];
113
- for (const line of content.split("\n")) {
114
- const trimmed = line.trim();
115
- if (!trimmed)
116
- continue;
117
- if (trimmed.startsWith("##")) {
118
- sections.push({ title: trimmed.slice(2).trim(), packages: [] });
119
- continue;
120
- }
121
- if (trimmed.startsWith("#"))
122
- continue;
123
- const open = sections.at(-1);
124
- if (!open)
125
- continue;
126
- for (const name of trimmed.split(",")) {
127
- const pkg = name.trim();
128
- if (pkg)
129
- open.packages.push({ name: pkg });
130
- }
131
- }
132
- return sections;
133
- }
134
168
  /** One entry as the listing renders it: `name==version` where the source pins a version, the bare name otherwise. */
135
169
  function renderPackage(entry) {
136
170
  return entry.version === undefined ? entry.name : `${entry.name}==${entry.version}`;
@@ -160,15 +194,16 @@ function renderListing(sections, limit) {
160
194
  * Answer a packages query against a parsed catalog. Pure — the tool's `execute`
161
195
  * is only the file read plus this call.
162
196
  */
163
- export function queryPackages(sections, { names, query, language, limit }) {
197
+ export function queryPackages(rawSections, { names, query, language, limit }) {
198
+ const sections = foldSections(rawSections);
164
199
  // Presence check — the cheap, targeted path. Answers "is X available, and in
165
200
  // which track" without returning the catalog.
166
201
  if (names && names.length > 0) {
167
202
  const index = new Map();
168
203
  for (const section of sections) {
169
204
  for (const pkg of section.packages) {
170
- // First writer wins: `packages.txt` sections are ordered, so a name
171
- // colliding across tracks resolves to the earliest one.
205
+ // First writer wins: the sections arrive in a fixed order, so a
206
+ // name colliding across tracks resolves to the earliest one.
172
207
  if (!index.has(pkg.name.toLowerCase()))
173
208
  index.set(pkg.name.toLowerCase(), { entry: pkg, section: section.title });
174
209
  }
@@ -219,7 +254,7 @@ export function queryPackages(sections, { names, query, language, limit }) {
219
254
  * Create the package inventory over the bound source: the pool-scope reader
220
255
  * when the embedder binds one (a conversation or planning surface), the
221
256
  * host-readable farm `inflexa.lock` otherwise (a sandbox agent). The image
222
- * fragment merges into either report.
257
+ * record merges into either report.
223
258
  */
224
259
  export function createListAvailablePackagesTool(deps = {}) {
225
260
  const lockCandidates = deps.farmLockFile ? [deps.farmLockFile] : DEFAULT_FARM_LOCK_FILES;
@@ -284,12 +319,15 @@ export function createListAvailablePackagesTool(deps = {}) {
284
319
  return "full package list";
285
320
  },
286
321
  execute: async (input) => {
287
- // The baked image fragment merges in when the host can read one; a
288
- // missing fragment merges nothing, and that is the normal state for
289
- // a host that runs outside the image.
290
- const fragmentSections = await readFile(imagePackagesFile, "utf-8")
291
- .then(parsePackagesFile)
292
- .catch(() => []);
322
+ // The image record merges in when the host can read a valid one.
323
+ // An absent record and an invalid record both merge nothing, and
324
+ // neither is an error: the record is an enrichment, and the farm
325
+ // or pool inventory stays whole without it. A store packed before
326
+ // the record existed carries none. The tool holds no logger, thus
327
+ // an invalid record cannot be reported here — the schema of the
328
+ // harness refuses it, and the report degrades to the tracks it
329
+ // could read.
330
+ const recordSections = readImagePackagesFile(imagePackagesFile).map(imageSections).unwrapOr([]);
293
331
  // An unreadable inventory is an expected environment state — model it as an
294
332
  // `available: false` data variant telling the caller the set is UNKNOWN,
295
333
  // WITH the reason: without it, a structural fault (a damaged dependency
@@ -301,7 +339,7 @@ export function createListAvailablePackagesTool(deps = {}) {
301
339
  }));
302
340
  if (pool.kind === "unavailable")
303
341
  return ok({ available: false, content: `${POOL_UNAVAILABLE_NOTE} The reason: ${pool.reason}.` });
304
- return ok(queryPackages([...pool.sections, ...fragmentSections], input));
342
+ return ok(queryPackages([...pool.sections, ...recordSections], input));
305
343
  }
306
344
  let lock = null;
307
345
  for (const candidate of lockCandidates) {
@@ -312,7 +350,7 @@ export function createListAvailablePackagesTool(deps = {}) {
312
350
  if (lock === null) {
313
351
  return ok({ available: false, content: UNAVAILABLE_NOTE });
314
352
  }
315
- return ok(queryPackages([...lockSections(lock), ...fragmentSections], input));
353
+ return ok(queryPackages([...lockSections(lock), ...recordSections], input));
316
354
  },
317
355
  });
318
356
  }