@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
@@ -21,7 +21,7 @@ export type { ActiveSandboxRow } from "./active-sandboxes.js";
21
21
  export { insertPlan, upsertPlan, loadPlan } from "./plans.js";
22
22
  export type { InsertPlanInput, UpsertPlanInput } from "./plans.js";
23
23
  export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, completeEmptyDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
24
- export type { DataProfileStatus, DataProfileResult, DataProfileChecked, DataProfileCompanionCompleteness, DataProfileDimension, DataProfileGroup, DataProfileGroupSlot, DataProfileFile, DataProfileMemberAnnotation, DataProfileObservation, DataProfilePartition, DataProfileProbeReport, DataProfileQualityAssessment, DataProfileQuarantine, DataProfileRecipeStep, DataProfileKind, DataProfileAxis, DataProfileInputSignature, DataProfileCoverage, } from "./data-profile.js";
24
+ export type { DataProfileStatus, DataProfileTerminalWrite, DataProfileResult, DataProfileChecked, DataProfileCompanionCompleteness, DataProfileDimension, DataProfileGroup, DataProfileGroupSlot, DataProfileFile, DataProfileMemberAnnotation, DataProfileObservation, DataProfilePartition, DataProfileProbeReport, DataProfileQualityAssessment, DataProfileQuarantine, DataProfileRecipeStep, DataProfileKind, DataProfileAxis, DataProfileInputSignature, DataProfileCoverage, } from "./data-profile.js";
25
25
  export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses } from "./analyses-metrics.js";
26
26
  export { createAnalysisPurge } from "./purge-analysis.js";
27
27
  export type { AnalysisPurge, AnalysisPurgeDeps, AnalysisPurgeOutcome } from "./purge-analysis.js";
@@ -46,10 +46,10 @@ export declare const ArtifactRowSchema: z.ZodObject<{
46
46
  }, z.core.$strip>;
47
47
  export type ArtifactRow = z.infer<typeof ArtifactRowSchema>;
48
48
  export declare const RunStatus: z.ZodEnum<{
49
- partial: "partial";
50
49
  running: "running";
51
50
  completed: "completed";
52
51
  failed: "failed";
52
+ partial: "partial";
53
53
  canceled: "canceled";
54
54
  suspended_insufficient_funds: "suspended_insufficient_funds";
55
55
  }>;
@@ -77,10 +77,10 @@ export declare const CortexRunRowSchema: z.ZodObject<{
77
77
  threadId: z.ZodNullable<z.ZodString>;
78
78
  workflowName: z.ZodString;
79
79
  status: z.ZodEnum<{
80
- partial: "partial";
81
80
  running: "running";
82
81
  completed: "completed";
83
82
  failed: "failed";
83
+ partial: "partial";
84
84
  canceled: "canceled";
85
85
  suspended_insufficient_funds: "suspended_insufficient_funds";
86
86
  }>;
@@ -45,6 +45,7 @@ import { PROFILE_INDEX_TYPES, buildProfileIndexEntries } from "./data-profile-in
45
45
  import { absorbRecipe, renderAbsorbDelta } from "./data-profile-absorb.js";
46
46
  import { formatResolutionErrors, resolveProfileSubmission } from "./data-profile-resolve.js";
47
47
  import { completeDataProfile, completeEmptyDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
48
+ import { elapsedSinceIso, recordRunCompleted } from "../lib/metrics.js";
48
49
  import { createProfileActivityEmitter } from "./data-profile-activity.js";
49
50
  import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.js";
50
51
  // The run literal is declared in `contracts/` — a consumer reads it back off recorded usage, and this
@@ -316,8 +317,7 @@ export async function runDataProfileBody(input, deps) {
316
317
  // catalogue's fit is measured, and a completion that emitted none would make the
317
318
  // empty-manifest path invisible to the same monitoring that watches every other.
318
319
  logProfileMonitoring(logger, { dimensions: [], probes: [], repairRounds: 0, absorb: "none" });
319
- if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId)))
320
- logTerminalNoop(logger, analysisId, "completion");
320
+ settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId)), "completed");
321
321
  await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
322
322
  // This is a terminal completion like any other, so it reports one — a consumer watching
323
323
  // an empty-manifest profile sees it settle rather than seeing the stream simply stop.
@@ -370,8 +370,7 @@ export async function runDataProfileBody(input, deps) {
370
370
  await activity.indexing();
371
371
  await indexProfile({ deps, analysisId, session: runSession, record, scan, logger });
372
372
  logProfileMonitoring(logger, { resolution, dimensions: [], probes: [], repairRounds: 0, absorb: "none" });
373
- if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, record)))
374
- logTerminalNoop(logger, analysisId, "completion");
373
+ settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, record)), "completed");
375
374
  await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
376
375
  await activity.complete();
377
376
  return;
@@ -397,8 +396,7 @@ export async function runDataProfileBody(input, deps) {
397
396
  repairRounds: 0,
398
397
  absorb: absorb.kind,
399
398
  });
400
- if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, absorbed)))
401
- logTerminalNoop(logger, analysisId, "completion");
399
+ settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, absorbed)), "completed");
402
400
  await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
403
401
  await activity.complete();
404
402
  return;
@@ -575,9 +573,7 @@ export async function runDataProfileBody(input, deps) {
575
573
  });
576
574
  // 6. Complete — store the FULL profiler finding plus the input signature for
577
575
  // staleness detection. The scratch tree is gone; this row is all that survives.
578
- if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, profileRecord))) {
579
- logTerminalNoop(logger, analysisId, "completion");
580
- }
576
+ settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, profileRecord)), "completed");
581
577
  await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
582
578
  // LAST statement of the success path, and that placement is the guarantee that exactly
583
579
  // one terminal activity is ever emitted. Everything that could still throw — the ledger
@@ -599,8 +595,7 @@ export async function runDataProfileBody(input, deps) {
599
595
  catch (err) {
600
596
  logger.error("profile failed", logger.errorFields(err));
601
597
  const reason = profileFailureReason(err);
602
- if (!unwrapOrThrow(await failDataProfile(deps.pool, analysisId, reason)))
603
- logTerminalNoop(logger, analysisId, "failure");
598
+ settleTerminalWrite(logger, analysisId, unwrapOrThrow(await failDataProfile(deps.pool, analysisId, reason)), "failed");
604
599
  await deps.runAuthorizer.revoke(authorization, "data-profile-failed");
605
600
  // The same bounded, user-safe line the ledger receives — never the raw error, whose paths and
606
601
  // stack frames stay in the log record above. This is the only other terminal emission, so
@@ -617,6 +612,21 @@ export async function runDataProfileBody(input, deps) {
617
612
  function logTerminalNoop(logger, analysisId, write) {
618
613
  logger.warn("terminal write skipped: ledger row not running (cleared or expired concurrently)", { analysisId, write });
619
614
  }
615
+ /**
616
+ * Settle one terminal write of the profile ledger. A refused stamp is logged
617
+ * (see `logTerminalNoop`). An accepted stamp records the profile's run outcome:
618
+ * the CAS admits one write per `running` claim, so this is the exactly-once
619
+ * point of the transition — a recovery replay of the body finds the row no
620
+ * longer `running`, and records nothing again. The duration runs from the
621
+ * `data_profile_started_at` the claim stamped, never from a body-local clock.
622
+ */
623
+ function settleTerminalWrite(logger, analysisId, write, status) {
624
+ if (!write.stamped) {
625
+ logTerminalNoop(logger, analysisId, status === "completed" ? "completion" : "failure");
626
+ return;
627
+ }
628
+ recordRunCompleted({ workflow: "data_profile", status, durationMs: elapsedSinceIso(write.startedAt) });
629
+ }
620
630
  /**
621
631
  * Resolve the `RunSession` the workflow runs under. Whether the authorization
622
632
  * is freshly minted (Cortex-owned) or reused (caller-owned) is decided by the
@@ -762,13 +772,17 @@ async function compensateStartFailure(deps, analysisId, phase, err) {
762
772
  if (failed.isErr()) {
763
773
  logger.error("failed to mark failed after a start error", { phase, err: failed.error });
764
774
  }
765
- else if (!failed.value) {
775
+ else if (!failed.value.stamped) {
766
776
  // The row this compensation was written to fail is no longer `running` — a
767
777
  // concurrent clear/expire already moved it on, so the running-CAS refused the
768
778
  // stamp. Nothing wedged at `running`, which is the outcome compensation exists
769
779
  // to guarantee; just record the no-op.
770
780
  logTerminalNoop(logger, analysisId, "compensation");
771
781
  }
782
+ else {
783
+ // A profile that failed at start is a failed profile: its claim opened a run the ledger now closes.
784
+ recordRunCompleted({ workflow: "data_profile", status: "failed", durationMs: elapsedSinceIso(failed.value.startedAt) });
785
+ }
772
786
  }
773
787
  /**
774
788
  * Start the data-profile workflow for an already-claimed analysis (the retry
@@ -10,6 +10,7 @@ import { DEFAULT_SANDBOX_MAX_STEPS } from "../config/resource-limits.js";
10
10
  import { createNoopLogger } from "../lib/console-logger.js";
11
11
  import { unwrapOrThrow } from "../lib/result.js";
12
12
  import { buildRunCardData } from "../memory/card-builders.js";
13
+ import { formatQuery, parseQuery } from "../sandbox/package-identity.js";
13
14
  import { AnalysisPlanSchema } from "../schemas/workflow-state.js";
14
15
  import { validatePlan } from "../schemas/validate-plan.js";
15
16
  import { RunDedupCollisionError, insertRun, loadPlan, queryActiveRun, reserveRunById, updateRunStatus, upsertPlan } from "../state/index.js";
@@ -80,39 +81,41 @@ export class PlanPackagesMissingError extends Error {
80
81
  this.name = "PlanPackagesMissingError";
81
82
  }
82
83
  }
83
- /** One plan package entry, in requirement form: a bare name, or a name with one exact version. */
84
- function parseRequirement(entry) {
85
- const split = entry.indexOf("==");
86
- if (split < 0)
87
- return { name: entry.trim() };
88
- return { name: entry.slice(0, split).trim(), version: entry.slice(split + 2).trim() };
89
- }
90
84
  /**
91
85
  * Link the packages of the plan into the farm of the analysis, before the
92
- * run reserves anything. The linked set is the union of the packages of each
93
- * step. A pool miss and a collision refuse the launch with the names. Without
94
- * a bound seam, the pass returns at once.
86
+ * run reserves anything. The linked set is the union of the queries of each
87
+ * step. A pool miss and a collision refuse the launch with the spellings.
88
+ * Without a bound seam, the pass returns at once.
95
89
  */
96
90
  async function linkPlanPackages(extendAnalysisFarm, analysisId, plan) {
97
91
  if (!extendAnalysisFarm)
98
92
  return;
93
+ // The union dedupes EQUAL queries only: two entries are one ask when their
94
+ // spelling, their track, and their version are equal. A bare entry beside a
95
+ // qualified entry of one spelling stays two asks, because the bare one
96
+ // carries its own refusal — an absorption would drop the pin of one entry
97
+ // and silence a wrong track.
99
98
  const union = new Map();
100
99
  for (const step of plan.steps) {
101
100
  for (const entry of step.packages ?? []) {
102
- // The FIRST spelling of the plan rides the request. An outcome
103
- // echoes the requested spelling, and a last-writer fold could
104
- // echo `seurat` where the plan said `Seurat` — a remedy in that
105
- // case names a package that no repository holds.
106
- const key = entry.trim().toLowerCase();
101
+ const parsed = parseQuery(entry);
102
+ if (parsed.isErr()) {
103
+ // `validatePlan` parsed every entry of this plan already, thus a
104
+ // refusal here means that the two readers disagree.
105
+ throw new Error(`Step "${step.id}" carries the package entry "${entry}", which the plan validation should have refused`);
106
+ }
107
+ const query = parsed.value;
108
+ const key = formatQuery(query);
107
109
  if (!union.has(key))
108
- union.set(key, parseRequirement(entry));
110
+ union.set(key, query);
109
111
  }
110
112
  }
111
- if (union.size === 0)
113
+ const queries = [...union.values()];
114
+ if (queries.length === 0)
112
115
  return;
113
116
  let outcomes;
114
117
  try {
115
- outcomes = await extendAnalysisFarm(analysisId, [...union.values()]);
118
+ outcomes = await extendAnalysisFarm(analysisId, queries);
116
119
  }
117
120
  catch (cause) {
118
121
  // A realization throw is an `unavailable` answer, not a driver error.
@@ -132,8 +135,22 @@ async function linkPlanPackages(extendAnalysisFarm, analysisId, plan) {
132
135
  // so the agent replans from the true state instead of re-asking. A
133
136
  // collision's detail names the two pins and what needs each side — the
134
137
  // remedy is to drop or re-pin a DEPENDENT, so the refusal must name it.
135
- const missing = outcomes.filter((o) => o.kind === "absent").map((o) => (o.detail === undefined ? o.name : `${o.name} — ${o.detail}`));
136
- const collisions = outcomes.filter((o) => o.kind === "collision").map((o) => (o.detail === undefined ? o.name : `${o.name} — ${o.detail}`));
138
+ const missing = outcomes.filter((o) => o.kind === "absent").map((o) => (o.detail === undefined ? o.spelling : `${o.spelling} — ${o.detail}`));
139
+ // A collision names the two store directories AND the two prefixed forms.
140
+ // One spelling that both tracks hold is the common cause, and the prefix is
141
+ // the remedy that a plan can write. `formatQuery` writes the two forms,
142
+ // thus the refusal quotes the grammar that the plan reads.
143
+ //
144
+ // The prefix carries its CONDITION, because the outcome shape cannot tell
145
+ // the two collisions apart: two versions of one distribution collide too,
146
+ // and a prefix there resolves nothing. An unconditional instruction would
147
+ // send the agent through a second launch that refuses identically.
148
+ const collisions = outcomes
149
+ .filter((o) => o.kind === "collision")
150
+ .map((o) => `${o.spelling} — two store directories claim it, ${o.storeDirs[0]} and ${o.storeDirs[1]}` +
151
+ `${o.detail === undefined ? "" : ` (${o.detail})`}` +
152
+ `; when the two directories are one spelling in two tracks, name the track in the plan, as ` +
153
+ `${formatQuery({ spelling: o.spelling, track: "python" })} or ${formatQuery({ spelling: o.spelling, track: "r" })}`);
137
154
  if (missing.length > 0 || collisions.length > 0) {
138
155
  throw new PlanPackagesMissingError(missing, collisions);
139
156
  }
@@ -53,7 +53,7 @@ declare function formatRun(r: CortexRunRow, verbose: boolean, exposePaths?: bool
53
53
  synthesisReason?: string | undefined;
54
54
  runId: string;
55
55
  workflowName: string;
56
- status: "partial" | "running" | "completed" | "failed" | "canceled" | "suspended_insufficient_funds";
56
+ status: "running" | "completed" | "failed" | "partial" | "canceled" | "suspended_insufficient_funds";
57
57
  startedAt: string;
58
58
  completedAt: string | null;
59
59
  planId: string | null;
@@ -8,20 +8,20 @@
8
8
  * staged — the tool never installs, downloads, or acquires anything. A link
9
9
  * is live in a container that already runs, because the farm rides a bind
10
10
  * mount.
11
+ *
12
+ * Each entry is a query in the one grammar of the `package-identity`
13
+ * capability, thus the agent learns one grammar for the plan, the tool, and
14
+ * the prompt layer.
11
15
  */
12
16
  import type { ExtendAnalysisFarm, PackageRequestOutcome } from "../../sandbox/types.js";
13
17
  export interface LinkPackagesDeps {
14
18
  readonly extendAnalysisFarm: ExtendAnalysisFarm;
15
19
  readonly analysisId: string;
16
20
  }
17
- /** The tool result: one outcome per requested package, index-aligned. */
21
+ /** The tool result: one outcome per requested query, index-aligned. */
18
22
  export interface LinkPackagesResult {
19
23
  readonly outcomes: readonly PackageRequestOutcome[];
20
24
  }
21
25
  export declare function createLinkPackagesTool(deps: LinkPackagesDeps): import("../define-tool.js").Tool<{
22
- packages: {
23
- name: string;
24
- version?: string | undefined;
25
- ecosystem?: "r" | "python" | undefined;
26
- }[];
26
+ packages: string[];
27
27
  }, LinkPackagesResult>;
@@ -8,44 +8,75 @@
8
8
  * staged — the tool never installs, downloads, or acquires anything. A link
9
9
  * is live in a container that already runs, because the farm rides a bind
10
10
  * mount.
11
+ *
12
+ * Each entry is a query in the one grammar of the `package-identity`
13
+ * capability, thus the agent learns one grammar for the plan, the tool, and
14
+ * the prompt layer.
11
15
  */
12
- import { ok } from "neverthrow";
16
+ import { err, ok } from "neverthrow";
13
17
  import { z } from "zod";
14
18
  import { defineTool } from "../define-tool.js";
19
+ import { parseQuery } from "../../sandbox/package-identity.js";
20
+ /**
21
+ * The refusal of one entry that is not a query. The text names the entry and
22
+ * the issue, thus the agent corrects the entry it wrote instead of guessing
23
+ * which of its packages the tool refused.
24
+ */
25
+ function describeParseError(entry, error) {
26
+ switch (error.type) {
27
+ case "empty":
28
+ return `"${entry}" names no package — pass a bare name, or name==version`;
29
+ case "location":
30
+ return `"${entry}" is a location — pass a package name, never a path, a URL, or a store directory`;
31
+ case "unknown_prefix":
32
+ return `"${entry}" carries the prefix "${error.prefix}:", which this tool cannot read — the permitted prefixes are "python:" and "r:", and a bare name searches both tracks`;
33
+ case "unsupported_specifier":
34
+ return `"${entry}" pins with the specifier "${error.specifier}", which this tool cannot honor — pass a bare name, or name==version with one exact version`;
35
+ }
36
+ }
15
37
  export function createLinkPackagesTool(deps) {
16
38
  return defineTool({
17
39
  id: "link_packages",
18
40
  description: "Link host-staged packages into the farm of this analysis, so an import that failed works without a restart. " +
19
41
  "This tool links what the host already staged — it NEVER installs, downloads, or acquires anything. " +
20
- "Pass each module name verbatim, exactly as the import names it. " +
42
+ "Pass each module name verbatim, exactly as the import names it, optionally behind the track prefix `python:` or `r:` and optionally pinned as `name==version`. " +
21
43
  "One outcome comes back per request: `linked` (the pool held it, and it is importable now), " +
22
44
  "`present` (the farm held it already), " +
23
45
  "`absent` (the pool does not hold it — a real answer, and `acquisitionPossible` states whether the host can acquire that ecosystem; report the package as missing, do not retry), " +
24
- "`collision` (the request resolves to two store directories — terminal for that package; the `detail` names the two pins and the packages that need each; report it and continue without it), " +
25
- "or `unavailable` (the link pass itself could not answer — the `reason` says why; it says nothing about the package's presence, so report the reason and do not re-request packages).",
46
+ "`collision` (the request resolves to two store directories; the `detail` names the two pins and the packages that need each), " +
47
+ "or `unavailable` (the link pass itself could not answer — the `reason` says why; it says nothing about the package's presence, so report the reason and do not re-request packages). " +
48
+ "After a `collision` of one name that BOTH tracks hold, call this tool again for that package with the prefixed form, `python:<name>` or `r:<name>`. " +
49
+ "A collision is terminal only after that second call also refuses, or when it names two versions of one distribution — then report it and continue without the package.",
26
50
  inputSchema: z.object({
27
51
  packages: z
28
- .array(z.object({
29
- name: z.string().min(1).describe("The module or package name, verbatim."),
30
- version: z.string().optional().describe("One exact version, when the requirement pins one."),
31
- ecosystem: z.enum(["python", "r"]).optional().describe("The ecosystem, when the bare name is ambiguous."),
32
- }))
52
+ .array(z.string().min(1))
33
53
  .min(1)
34
54
  .max(50)
35
- .describe("The packages to link into the farm."),
55
+ .describe('The packages to link into the farm, each as `[python:|r:]<name>[==<version>]`, for example ["scanpy", "r:Seurat", "numpy==1.26.4"]. A bare name searches both tracks.'),
36
56
  }),
37
- describeCall: ({ packages }) => packages.map((p) => p.name).join(", "),
57
+ describeCall: ({ packages }) => packages.join(", "),
38
58
  execute: async (input) => {
39
- // A realization throw reads as `unavailable` per request. The loop
59
+ // Every entry parses before any link lands. A partial link over a
60
+ // batch whose tail is malformed leaves the farm in a state that the
61
+ // agent did not ask for and cannot see.
62
+ const queries = [];
63
+ for (const entry of input.packages) {
64
+ const parsed = parseQuery(entry);
65
+ if (parsed.isErr()) {
66
+ return err({ error: `link_packages refused the call: ${describeParseError(entry, parsed.error)}`, retryable: true });
67
+ }
68
+ queries.push(parsed.value);
69
+ }
70
+ // A realization throw reads as `unavailable` per query. The loop
40
71
  // would render the throw as a raw tool error, and a raw driver
41
72
  // message teaches the agent nothing that `reason` does not.
42
73
  try {
43
- const outcomes = await deps.extendAnalysisFarm(deps.analysisId, input.packages);
74
+ const outcomes = await deps.extendAnalysisFarm(deps.analysisId, queries);
44
75
  return ok({ outcomes });
45
76
  }
46
77
  catch (cause) {
47
78
  const reason = cause instanceof Error ? cause.message : String(cause);
48
- return ok({ outcomes: input.packages.map((request) => ({ kind: "unavailable", name: request.name, reason })) });
79
+ return ok({ outcomes: queries.map((query) => ({ kind: "unavailable", spelling: query.spelling, reason })) });
49
80
  }
50
81
  },
51
82
  });
@@ -20,8 +20,19 @@
20
20
  * hashes — a thousand rows of sha256 would bury the signal.
21
21
  */
22
22
  import type { EnvironmentStorePaths, PoolInventoryPackage, PoolInventorySection } from "../../config/environment-stores.js";
23
+ import { type Track } from "../../sandbox/package-identity.js";
23
24
  import { type FarmLock } from "../../sandbox/farm.js";
24
25
  import { type ImagePackages } from "../../sandbox/image-packages.js";
26
+ /**
27
+ * The ecosystem of one lock subtree. `python` gives the Python track, and
28
+ * `cran`, `bioconductor`, and `github` give the R track — three subtrees of
29
+ * one ecosystem, because a repository is a source and not an identity rule.
30
+ *
31
+ * A subtree that names neither, such as `node`, carries no track: its packages
32
+ * are not in the pool of the two ecosystems, and a `names` lookup matches them
33
+ * by their rendered name.
34
+ */
35
+ export declare function trackOfLockSubtree(subtree: string): Track | undefined;
25
36
  /**
26
37
  * Group the lock packages into sections, one per track, in a stable order:
27
38
  * the known tracks first, then an unknown track at its first occurrence.
@@ -43,7 +54,12 @@ export declare function imageSections(record: ImagePackages): Section[];
43
54
  export type SectionPackage = PoolInventoryPackage;
44
55
  /** One language track of the store, in the section order of its source. The seam shape and the render shape are one. */
45
56
  export type Section = PoolInventorySection;
46
- /** One `names` lookup: present + canonical spelling + track + store identity, or absent. */
57
+ /**
58
+ * One hit of a `names` lookup: present + exact spelling + track + store
59
+ * identity, or absent. A name that two sections hold answers with one entry
60
+ * for each of them, thus the array is longer than the `names` array and it is
61
+ * not aligned with it.
62
+ */
47
63
  export type CheckedPackage = {
48
64
  readonly requested: string;
49
65
  readonly present: true;
@@ -55,6 +71,12 @@ export type CheckedPackage = {
55
71
  } | {
56
72
  readonly requested: string;
57
73
  readonly present: false;
74
+ /**
75
+ * The spelling that the pool does hold, when the resolution finds
76
+ * one. `seurat` misses and `Seurat` is present, thus the answer
77
+ * carries the spelling that a plan and a link both accept.
78
+ */
79
+ readonly suggestion?: string;
58
80
  };
59
81
  /**
60
82
  * Three shapes, one per call path: the store is unmounted; a `names` presence