@inflexa-ai/harness 0.35.3 → 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 (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
@@ -48,9 +48,9 @@ export declare const CitationVerdictSchema: z.ZodEnum<{
48
48
  }>;
49
49
  export type CitationVerdict = z.infer<typeof CitationVerdictSchema>;
50
50
  export declare const CitationCoverageSchema: z.ZodEnum<{
51
+ partial: "partial";
51
52
  none: "none";
52
53
  complete: "complete";
53
- partial: "partial";
54
54
  }>;
55
55
  export type CitationCoverage = z.infer<typeof CitationCoverageSchema>;
56
56
  export declare const ComparisonStatusSchema: z.ZodEnum<{
@@ -496,9 +496,9 @@ export declare const CitationResolutionResultSchema: z.ZodObject<{
496
496
  inconclusive: "inconclusive";
497
497
  }>;
498
498
  coverage: z.ZodEnum<{
499
+ partial: "partial";
499
500
  none: "none";
500
501
  complete: "complete";
501
- partial: "partial";
502
502
  }>;
503
503
  }, z.core.$strip>;
504
504
  export type CitationResolutionResult = z.infer<typeof CitationResolutionResultSchema>;
@@ -8,6 +8,7 @@
8
8
  * "the profiler", "the planner") while the contract stayed identical. Extending
9
9
  * this interface keeps one description of a field that has one meaning.
10
10
  */
11
+ import type { Track } from "../sandbox/package-identity.js";
11
12
  /**
12
13
  * Host paths of the environment's two read-only stores.
13
14
  *
@@ -94,5 +95,14 @@ export interface PoolInventoryPackage {
94
95
  /** One language-track section of an inventory, as `list_available_packages` renders it. */
95
96
  export interface PoolInventorySection {
96
97
  readonly title: string;
98
+ /**
99
+ * The ecosystem that holds the packages of this section. It carries the
100
+ * track as DATA, thus no reader derives one from the title: a title is
101
+ * display text, and a heading that changes wording would silently change
102
+ * which packages a `language` filter answers. A section of system tools or
103
+ * of node packages carries none, because neither is a package track of the
104
+ * pool.
105
+ */
106
+ readonly track?: Track;
97
107
  readonly packages: readonly PoolInventoryPackage[];
98
108
  }
@@ -104,8 +104,8 @@ export declare const FileReferencePartSchema: z.ZodObject<{
104
104
  }, z.core.$strip>>;
105
105
  }, z.core.$strip>;
106
106
  export declare const AskStatusSchema: z.ZodEnum<{
107
- aborted: "aborted";
108
107
  pending: "pending";
108
+ aborted: "aborted";
109
109
  resolved: "resolved";
110
110
  rejected: "rejected";
111
111
  expired: "expired";
@@ -117,8 +117,8 @@ export declare const AskPartSchema: z.ZodObject<{
117
117
  command: z.ZodString;
118
118
  detail: z.ZodOptional<z.ZodString>;
119
119
  status: z.ZodEnum<{
120
- aborted: "aborted";
121
120
  pending: "pending";
121
+ aborted: "aborted";
122
122
  resolved: "resolved";
123
123
  rejected: "rejected";
124
124
  expired: "expired";
@@ -182,8 +182,8 @@ export declare const DagStatePartSchema: z.ZodObject<{
182
182
  }, z.core.$strict>>;
183
183
  }, z.core.$strict>;
184
184
  export declare const StepPhaseSchema: z.ZodEnum<{
185
- complete: "complete";
186
185
  failed: "failed";
186
+ complete: "complete";
187
187
  warning: "warning";
188
188
  "sandbox-init": "sandbox-init";
189
189
  executing: "executing";
@@ -199,8 +199,8 @@ export declare const StepActivityPartSchema: z.ZodObject<{
199
199
  runId: z.ZodString;
200
200
  stepId: z.ZodString;
201
201
  phase: z.ZodEnum<{
202
- complete: "complete";
203
202
  failed: "failed";
203
+ complete: "complete";
204
204
  warning: "warning";
205
205
  "sandbox-init": "sandbox-init";
206
206
  executing: "executing";
@@ -314,8 +314,8 @@ export declare const RunCompletedPartSchema: z.ZodObject<{
314
314
  type: z.ZodLiteral<"data-run-completed">;
315
315
  runId: z.ZodString;
316
316
  status: z.ZodEnum<{
317
- partial: "partial";
318
317
  completed: "completed";
318
+ partial: "partial";
319
319
  }>;
320
320
  completedSteps: z.ZodNumber;
321
321
  totalSteps: z.ZodNumber;
@@ -403,9 +403,9 @@ export declare const RunSynthesisPartSchema: z.ZodObject<{
403
403
  }, z.core.$strip>>;
404
404
  }, z.core.$strip>;
405
405
  export declare const SynthesisPhaseSchema: z.ZodEnum<{
406
- complete: "complete";
407
406
  failed: "failed";
408
407
  skipped: "skipped";
408
+ complete: "complete";
409
409
  indexing: "indexing";
410
410
  persisting: "persisting";
411
411
  starting: "starting";
@@ -419,9 +419,9 @@ export declare const SynthesisProgressPartSchema: z.ZodObject<{
419
419
  id: z.ZodString;
420
420
  runId: z.ZodString;
421
421
  phase: z.ZodEnum<{
422
- complete: "complete";
423
422
  failed: "failed";
424
423
  skipped: "skipped";
424
+ complete: "complete";
425
425
  indexing: "indexing";
426
426
  persisting: "persisting";
427
427
  starting: "starting";
@@ -520,8 +520,8 @@ export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
520
520
  command: z.ZodString;
521
521
  detail: z.ZodOptional<z.ZodString>;
522
522
  status: z.ZodEnum<{
523
- aborted: "aborted";
524
523
  pending: "pending";
524
+ aborted: "aborted";
525
525
  resolved: "resolved";
526
526
  rejected: "rejected";
527
527
  expired: "expired";
@@ -560,8 +560,8 @@ export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
560
560
  runId: z.ZodString;
561
561
  stepId: z.ZodString;
562
562
  phase: z.ZodEnum<{
563
- complete: "complete";
564
563
  failed: "failed";
564
+ complete: "complete";
565
565
  warning: "warning";
566
566
  "sandbox-init": "sandbox-init";
567
567
  executing: "executing";
@@ -679,9 +679,9 @@ export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
679
679
  id: z.ZodString;
680
680
  runId: z.ZodString;
681
681
  phase: z.ZodEnum<{
682
- complete: "complete";
683
682
  failed: "failed";
684
683
  skipped: "skipped";
684
+ complete: "complete";
685
685
  indexing: "indexing";
686
686
  persisting: "persisting";
687
687
  starting: "starting";
@@ -698,8 +698,8 @@ export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
698
698
  type: z.ZodLiteral<"data-run-completed">;
699
699
  runId: z.ZodString;
700
700
  status: z.ZodEnum<{
701
- partial: "partial";
702
701
  completed: "completed";
702
+ partial: "partial";
703
703
  }>;
704
704
  completedSteps: z.ZodNumber;
705
705
  totalSteps: z.ZodNumber;
@@ -18,6 +18,7 @@
18
18
  * terminal frame read-side.
19
19
  */
20
20
  import { createNoopLogger } from "../lib/console-logger.js";
21
+ import { elapsedSinceIso, recordRunCompleted, recordStepCompleted } from "../lib/metrics.js";
21
22
  import { unwrapOrThrow } from "../lib/result.js";
22
23
  import { markRunCanceledIfActive, queryRun } from "../state/runs.js";
23
24
  import { queryStepsByRun, sweepPendingStepExecutions } from "../state/step-executions.js";
@@ -49,8 +50,9 @@ export function createRunCanceler(deps) {
49
50
  })();
50
51
  await engineCancel(workflowIds);
51
52
  };
52
- /** Persisted child workflow ids of steps not yet completed. */
53
- const incompleteChildIds = async (runId) => unwrapOrThrow(await queryStepsByRun(pool, runId)).flatMap((step) => step.childWorkflowId !== null && step.completedAt === null ? [step.childWorkflowId] : []);
53
+ /** Steps whose child started (a persisted child workflow id) and has not written its terminal row. */
54
+ const incompleteSteps = async (runId) => unwrapOrThrow(await queryStepsByRun(pool, runId)).filter((step) => step.childWorkflowId !== null && step.completedAt === null);
55
+ const childIds = (steps) => steps.flatMap((step) => (step.childWorkflowId !== null ? [step.childWorkflowId] : []));
54
56
  return {
55
57
  async cancel(runId, session) {
56
58
  const row = unwrapOrThrow(await queryRun(pool, runId));
@@ -71,10 +73,14 @@ export function createRunCanceler(deps) {
71
73
  // mark-running step has not yet committed its child_workflow_id. The
72
74
  // persisted ids and the post-cancel re-query are belt-and-braces over
73
75
  // both ledgers' lag.
74
- const knownChildren = await incompleteChildIds(runId);
76
+ const knownChildren = childIds(await incompleteSteps(runId));
75
77
  await cancelWorkflows([workflowId, ...knownChildren]);
78
+ // The steps the cancel cut: read after the cancel landed, so a child that
79
+ // wrote its terminal row in the meantime is not among them.
80
+ let cutSteps;
76
81
  try {
77
- const late = (await incompleteChildIds(runId)).filter((id) => !knownChildren.includes(id));
82
+ cutSteps = await incompleteSteps(runId);
83
+ const late = childIds(cutSteps).filter((id) => !knownChildren.includes(id));
78
84
  if (late.length > 0)
79
85
  await cancelWorkflows(late);
80
86
  }
@@ -89,6 +95,16 @@ export function createRunCanceler(deps) {
89
95
  catch (err) {
90
96
  logger.error("markRunCanceledIfActive failed", { runId, ...logger.errorFields(err) });
91
97
  }
98
+ // The workflow's own terminal step never runs after a cancel, so this
99
+ // conditional write is the one terminal write of an external cancel, and
100
+ // the outcome metrics ride behind it: a refused write means the run
101
+ // settled on its own and its own terminal step recorded them. A cut
102
+ // child never reached its terminal row, so its step records here too.
103
+ if (transitioned) {
104
+ recordRunCompleted({ workflow: "analysis", status: "canceled", durationMs: elapsedSinceIso(row.startedAt) });
105
+ for (const step of cutSteps ?? [])
106
+ recordStepCompleted({ agentId: step.agentId, status: "canceled" });
107
+ }
92
108
  let steps = false;
93
109
  try {
94
110
  unwrapOrThrow(await sweepPendingStepExecutions(pool, runId));
package/dist/index.d.ts CHANGED
@@ -136,7 +136,9 @@ export type { CreateSandboxClientConfig, SandboxBackendConfig } from "./sandbox/
136
136
  export type { SandboxClient } from "./sandbox/client.js";
137
137
  export { describeSandboxError, SandboxFailure } from "./sandbox/sandbox-error.js";
138
138
  export type { SandboxError } from "./sandbox/sandbox-error.js";
139
- export type { ExtendAnalysisFarm, FarmLocation, FarmResolution, FarmSource, PackageRequest, PackageRequestOutcome, ResolveAnalysisFarm, ToolchainSource, } from "./sandbox/types.js";
139
+ export type { ExtendAnalysisFarm, FarmLocation, FarmResolution, FarmSource, PackageRequestOutcome, ResolveAnalysisFarm, ToolchainSource, } from "./sandbox/types.js";
140
+ export { formatQuery, identityAddress, identityKey, identityOf, parseIdentityKey, parseQuery, pythonIdentity, resolveQuery, rIdentity, } from "./sandbox/package-identity.js";
141
+ export type { PackageIdentity, PackageQuery, ParseQueryError, PoolIndex, QueryResolution, Track } from "./sandbox/package-identity.js";
140
142
  export { FARM_LOCK_FILE, FarmLockSchema, readFarmLock, readFarmLockFile } from "./sandbox/farm.js";
141
143
  export type { FarmLock, FarmLockError } from "./sandbox/farm.js";
142
144
  export { IMAGE_PACKAGES_FILE } from "./sandbox/image-packages.js";
package/dist/index.js CHANGED
@@ -246,6 +246,11 @@ export { createSandboxClient } from "./sandbox/create-sandbox.js";
246
246
  // The throw of the client seam. An embedder matches the class and reads the
247
247
  // typed variant on `error`, instead of a string match on the message.
248
248
  export { describeSandboxError, SandboxFailure } from "./sandbox/sandbox-error.js";
249
+ // The identity of a package, the query that asks for one, and the resolution
250
+ // between the two. An embedder reads `by_name` of the dependency graph with
251
+ // these, thus the host lookup, the census, and the graph key never disagree on
252
+ // the identity of a name.
253
+ export { formatQuery, identityAddress, identityKey, identityOf, parseIdentityKey, parseQuery, pythonIdentity, resolveQuery, rIdentity, } from "./sandbox/package-identity.js";
249
254
  // The farm contract: one `inflexa.lock` per farm. An embedder that composes
250
255
  // farms writes this shape, and the mount gate reads it — one schema, no copy.
251
256
  export { FARM_LOCK_FILE, FarmLockSchema, readFarmLock, readFarmLockFile } from "./sandbox/farm.js";
@@ -2,17 +2,75 @@
2
2
  * Custom OTel metrics for Cortex.
3
3
  *
4
4
  * Instruments:
5
- * - cortex.artifact.reconcile.dropped — counter for missing manifest entries
6
- * - cortex.artifact.reconcile.input_dropped — counter for lineage input drops
5
+ * - cortex.run.completed{status, workflow} — counter, one per run that reached a terminal status
6
+ * - cortex.run.duration_ms{status, workflow} — histogram, start to terminal status
7
+ * - cortex.step.completed{status, agent_id} — counter, one per executed step that settled
8
+ * - cortex.step.duration_ms{status, agent_id} — histogram, step start to its terminal ledger write
9
+ * - cortex.artifact.reconcile.dropped{agent_id} — counter for missing manifest entries
10
+ * - cortex.artifact.reconcile.input_dropped{agent_id, reason} — counter for lineage input drops
7
11
  *
8
- * Both carry `agent_id` (about 15 values) and never a per-step or per-run
9
- * identifier: a label with unbounded values multiplies the series count.
12
+ * Every label is a bounded vocabulary: `status` is the terminal enum, `workflow`
13
+ * names the workflow family, `agent_id` has about 15 values. No instrument
14
+ * carries a run, step, exec, user, or organization id — a label with unbounded
15
+ * values multiplies the series count.
16
+ *
17
+ * A run or step outcome is recorded INSIDE the DBOS step that persists the same
18
+ * outcome to the ledger (or behind the ledger's own compare-and-set where the
19
+ * body has no such step). DBOS caches a completed step and does not re-run its
20
+ * body on recovery, so a replayed workflow records nothing twice.
10
21
  *
11
22
  * Instruments are lazy so the record site binds to whichever `MeterProvider`
12
23
  * is globally registered at first use. The metrics API has no late-binding
13
24
  * proxy: a meter taken at module load, before `initOtel` runs, would stay
14
25
  * on the no-op provider for the life of the process.
15
26
  */
27
+ import type { StepExecutionStatus } from "../state/schema.js";
28
+ /** Terminal status of a run. A budget pause records as `canceled`, the same as the run ledger. */
29
+ export type RunOutcome = "completed" | "partial" | "failed" | "canceled";
30
+ /** The workflow family a run belongs to. */
31
+ export type RunWorkflow = "analysis" | "target_assessment" | "data_profile";
32
+ /** Terminal status of an executed step. A blocker is a failure to deliver, so `blocked` maps here to `failed`. */
33
+ export type StepOutcome = "completed" | "failed" | "canceled";
34
+ /**
35
+ * Bucket bounds for durations that run from seconds to hours: 30 s, then
36
+ * 1, 2, 5, 10, 30, 60, 120, 240 min. Ten buckets with the overflow, so one
37
+ * histogram costs about ten series per label set.
38
+ */
39
+ export declare const RUN_DURATION_BOUNDS_MS: readonly number[];
40
+ /**
41
+ * Record one run that reached a terminal status. Call it inside the DBOS step
42
+ * that persists that status, or behind the ledger compare-and-set that accepted
43
+ * it. `durationMs` is omitted when the start instant is unknown: the run is
44
+ * then counted, not timed.
45
+ */
46
+ export declare function recordRunCompleted(args: {
47
+ readonly workflow: RunWorkflow;
48
+ readonly status: RunOutcome;
49
+ readonly durationMs?: number;
50
+ }): void;
51
+ /**
52
+ * Record one executed step that settled. Call it inside the DBOS step that
53
+ * persists the terminal row, or inside the terminal step of the run for a
54
+ * step whose own body never reached one. `durationMs` is omitted when the
55
+ * step settled without a durable duration: it is then counted, not timed.
56
+ */
57
+ export declare function recordStepCompleted(args: {
58
+ readonly agentId: string;
59
+ readonly status: StepOutcome;
60
+ readonly durationMs?: number;
61
+ }): void;
62
+ /**
63
+ * The step outcome that a terminal ledger status records. A status that is not
64
+ * terminal (`pending`, `running`) or that names a step that never executed
65
+ * (`skipped`) records no outcome.
66
+ */
67
+ export declare function stepOutcomeOf(status: StepExecutionStatus): StepOutcome | undefined;
68
+ /**
69
+ * Milliseconds from a persisted ISO-8601 start instant to `nowMs`. `undefined`
70
+ * when the ledger holds no start or an unparsable one, so the caller counts
71
+ * without a duration instead of recording a bogus one.
72
+ */
73
+ export declare function elapsedSinceIso(startedAt: string | null | undefined, nowMs?: number): number | undefined;
16
74
  /** Record one manifest entry dropped at reconcile. */
17
75
  export declare function recordArtifactReconcileDropped(args: {
18
76
  readonly agentId: string;
@@ -24,4 +82,4 @@ export declare function recordLineageInputDropped(args: {
24
82
  readonly reason: LineageInputDropReason;
25
83
  }): void;
26
84
  /** Test hook — drop memoised instruments to rebind a fresh MeterProvider. */
27
- export declare function __resetReconcileMetricsForTest(): void;
85
+ export declare function __resetMetricsForTest(): void;
@@ -2,11 +2,22 @@
2
2
  * Custom OTel metrics for Cortex.
3
3
  *
4
4
  * Instruments:
5
- * - cortex.artifact.reconcile.dropped — counter for missing manifest entries
6
- * - cortex.artifact.reconcile.input_dropped — counter for lineage input drops
5
+ * - cortex.run.completed{status, workflow} — counter, one per run that reached a terminal status
6
+ * - cortex.run.duration_ms{status, workflow} — histogram, start to terminal status
7
+ * - cortex.step.completed{status, agent_id} — counter, one per executed step that settled
8
+ * - cortex.step.duration_ms{status, agent_id} — histogram, step start to its terminal ledger write
9
+ * - cortex.artifact.reconcile.dropped{agent_id} — counter for missing manifest entries
10
+ * - cortex.artifact.reconcile.input_dropped{agent_id, reason} — counter for lineage input drops
7
11
  *
8
- * Both carry `agent_id` (about 15 values) and never a per-step or per-run
9
- * identifier: a label with unbounded values multiplies the series count.
12
+ * Every label is a bounded vocabulary: `status` is the terminal enum, `workflow`
13
+ * names the workflow family, `agent_id` has about 15 values. No instrument
14
+ * carries a run, step, exec, user, or organization id — a label with unbounded
15
+ * values multiplies the series count.
16
+ *
17
+ * A run or step outcome is recorded INSIDE the DBOS step that persists the same
18
+ * outcome to the ledger (or behind the ledger's own compare-and-set where the
19
+ * body has no such step). DBOS caches a completed step and does not re-run its
20
+ * body on recovery, so a replayed workflow records nothing twice.
10
21
  *
11
22
  * Instruments are lazy so the record site binds to whichever `MeterProvider`
12
23
  * is globally registered at first use. The metrics API has no late-binding
@@ -14,11 +25,36 @@
14
25
  * on the no-op provider for the life of the process.
15
26
  */
16
27
  import { metrics } from "@opentelemetry/api";
28
+ /**
29
+ * Bucket bounds for durations that run from seconds to hours: 30 s, then
30
+ * 1, 2, 5, 10, 30, 60, 120, 240 min. Ten buckets with the overflow, so one
31
+ * histogram costs about ten series per label set.
32
+ */
33
+ export const RUN_DURATION_BOUNDS_MS = [30_000, 60_000, 120_000, 300_000, 600_000, 1_800_000, 3_600_000, 7_200_000, 14_400_000];
17
34
  let instruments;
18
35
  function getInstruments() {
19
36
  if (instruments === undefined) {
20
37
  const meter = metrics.getMeter("cortex");
38
+ const advice = { explicitBucketBoundaries: [...RUN_DURATION_BOUNDS_MS] };
21
39
  instruments = {
40
+ runCompleted: meter.createCounter("cortex.run.completed", {
41
+ description: "Runs that reached a terminal status. Tagged by status, workflow.",
42
+ unit: "{run}",
43
+ }),
44
+ runDuration: meter.createHistogram("cortex.run.duration_ms", {
45
+ description: "Run duration from its start to its terminal status. Tagged by status, workflow.",
46
+ unit: "ms",
47
+ advice,
48
+ }),
49
+ stepCompleted: meter.createCounter("cortex.step.completed", {
50
+ description: "Executed steps that settled. Tagged by status, agent_id.",
51
+ unit: "{step}",
52
+ }),
53
+ stepDuration: meter.createHistogram("cortex.step.duration_ms", {
54
+ description: "Step duration from its start to its terminal ledger write. Tagged by status, agent_id.",
55
+ unit: "ms",
56
+ advice,
57
+ }),
22
58
  artifactReconcileDropped: meter.createCounter("cortex.artifact.reconcile.dropped", {
23
59
  description: "Manifest entries dropped because their on-disk file was missing at " +
24
60
  "registration time (writes-then-deletes, renames). Tagged by agent_id.",
@@ -33,6 +69,69 @@ function getInstruments() {
33
69
  }
34
70
  return instruments;
35
71
  }
72
+ /**
73
+ * Record one run that reached a terminal status. Call it inside the DBOS step
74
+ * that persists that status, or behind the ledger compare-and-set that accepted
75
+ * it. `durationMs` is omitted when the start instant is unknown: the run is
76
+ * then counted, not timed.
77
+ */
78
+ export function recordRunCompleted(args) {
79
+ const attributes = { status: args.status, workflow: args.workflow };
80
+ const { runCompleted, runDuration } = getInstruments();
81
+ runCompleted.add(1, attributes);
82
+ if (args.durationMs !== undefined)
83
+ runDuration.record(Math.max(0, args.durationMs), attributes);
84
+ }
85
+ /**
86
+ * Record one executed step that settled. Call it inside the DBOS step that
87
+ * persists the terminal row, or inside the terminal step of the run for a
88
+ * step whose own body never reached one. `durationMs` is omitted when the
89
+ * step settled without a durable duration: it is then counted, not timed.
90
+ */
91
+ export function recordStepCompleted(args) {
92
+ const attributes = { status: args.status, agent_id: args.agentId };
93
+ const { stepCompleted, stepDuration } = getInstruments();
94
+ stepCompleted.add(1, attributes);
95
+ if (args.durationMs !== undefined)
96
+ stepDuration.record(Math.max(0, args.durationMs), attributes);
97
+ }
98
+ /**
99
+ * The step outcome that a terminal ledger status records. A status that is not
100
+ * terminal (`pending`, `running`) or that names a step that never executed
101
+ * (`skipped`) records no outcome.
102
+ */
103
+ export function stepOutcomeOf(status) {
104
+ switch (status) {
105
+ case "completed":
106
+ return "completed";
107
+ case "failed":
108
+ case "blocked":
109
+ return "failed";
110
+ case "canceled":
111
+ return "canceled";
112
+ case "pending":
113
+ case "running":
114
+ case "skipped":
115
+ return undefined;
116
+ default: {
117
+ const _exhaustive = status;
118
+ return _exhaustive;
119
+ }
120
+ }
121
+ }
122
+ /**
123
+ * Milliseconds from a persisted ISO-8601 start instant to `nowMs`. `undefined`
124
+ * when the ledger holds no start or an unparsable one, so the caller counts
125
+ * without a duration instead of recording a bogus one.
126
+ */
127
+ export function elapsedSinceIso(startedAt, nowMs = Date.now()) {
128
+ if (startedAt === null || startedAt === undefined)
129
+ return undefined;
130
+ const startedMs = Date.parse(startedAt);
131
+ if (!Number.isFinite(startedMs))
132
+ return undefined;
133
+ return Math.max(0, nowMs - startedMs);
134
+ }
36
135
  /** Record one manifest entry dropped at reconcile. */
37
136
  export function recordArtifactReconcileDropped(args) {
38
137
  getInstruments().artifactReconcileDropped.add(1, { agent_id: args.agentId });
@@ -42,6 +141,6 @@ export function recordLineageInputDropped(args) {
42
141
  getInstruments().lineageInputDropped.add(1, { agent_id: args.agentId, reason: args.reason });
43
142
  }
44
143
  /** Test hook — drop memoised instruments to rebind a fresh MeterProvider. */
45
- export function __resetReconcileMetricsForTest() {
144
+ export function __resetMetricsForTest() {
46
145
  instruments = undefined;
47
146
  }
@@ -14,8 +14,12 @@
14
14
  *
15
15
  * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
16
16
  * set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
17
- * OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. Custom Cortex
18
- * metrics are defined in metrics.ts.
17
+ * OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. The run and step
18
+ * outcome instruments (`cortex.run.*`, `cortex.step.*`) and the
19
+ * reconcile counters are defined in metrics.ts; the agent loop, the
20
+ * thread memory, and the workflows hold their own instruments next
21
+ * to their record sites. Every instrument binds lazily to the
22
+ * provider registered here.
19
23
  *
20
24
  * Resource: the host's `serviceName` / `serviceVersion` merged with the SDK
21
25
  * env detector (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`);
package/dist/lib/otel.js CHANGED
@@ -14,8 +14,12 @@
14
14
  *
15
15
  * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
16
16
  * set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
17
- * OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. Custom Cortex
18
- * metrics are defined in metrics.ts.
17
+ * OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. The run and step
18
+ * outcome instruments (`cortex.run.*`, `cortex.step.*`) and the
19
+ * reconcile counters are defined in metrics.ts; the agent loop, the
20
+ * thread memory, and the workflows hold their own instruments next
21
+ * to their record sites. Every instrument binds lazily to the
22
+ * provider registered here.
19
23
  *
20
24
  * Resource: the host's `serviceName` / `serviceVersion` merged with the SDK
21
25
  * env detector (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`);
@@ -167,6 +167,12 @@ Every step carries a \`packages\` array — the packages its scripts import.
167
167
  one exact version (\`"numpy==1.26.4"\`). Never a path, a URL, or a store
168
168
  directory — a location is an installer detail, and validation refuses it.
169
169
  - An empty array is valid: the step needs nothing beyond the baked toolchain.
170
+ - When the package census shows one name under a Python section AND under an R
171
+ section, write the prefixed form the census shows: \`"python:igraph"\` or
172
+ \`"r:igraph"\`. A bare name that both tracks hold refuses the launch, because
173
+ the pool cannot tell which package you mean.
174
+ - Write a bare name for every other package. The prefix is for a both-track
175
+ name only.
170
176
  - The set is not a promise of completeness. The execution agent can still link a
171
177
  missing package mid-step. Name what you know the step imports; do not pad the
172
178
  list defensively.
@@ -42,5 +42,5 @@ export declare function sandboxOrientCorePromptFor(toolchainSource: ToolchainSou
42
42
  * constant, thus the prompt stays byte-identical across the steps of one
43
43
  * composition.
44
44
  */
45
- export declare const sandboxPackageLinkPrompt = "# Linking Packages\n\nThe host keeps a pool of staged packages beside the farm of this analysis \u2014 the\npool is part of what the host staged before this container started. The\n`link_packages` tool links a staged package into your farm \u2014 live, with no\nrestart. A link adds no new bytes and downloads nothing; it connects your farm\nto a directory that is already on the mount. So a package your farm lacks is\nnot missing until the pool has answered:\n\n- After a failed import, and when `list_available_packages` reports a package\n absent, call `link_packages` with the module name verbatim.\n- `linked` and `present` mean the import works now \u2014 run it again.\n- `absent` is a real answer: the pool does not hold the package. When\n `acquisitionPossible` is true, report the package as missing so the host can\n acquire it. Do not retry the link, and do not attempt an install.\n- `collision` is terminal for that package: the request resolves to two store\n directories. Report it and continue without the package.\n- Report a package as missing only after `link_packages` answered `absent` or\n `unavailable` for it.\n";
45
+ export declare const sandboxPackageLinkPrompt = "# Linking Packages\n\nThe host keeps a pool of staged packages beside the farm of this analysis \u2014 the\npool is part of what the host staged before this container started. The\n`link_packages` tool links a staged package into your farm \u2014 live, with no\nrestart. A link adds no new bytes and downloads nothing; it connects your farm\nto a directory that is already on the mount. So a package your farm lacks is\nnot missing until the pool has answered:\n\n- After a failed import, and when `list_available_packages` reports a package\n absent, call `link_packages` with the module name verbatim. Each entry is\n `[python:|r:]<name>[==<version>]`, and a bare name searches both tracks.\n- `linked` and `present` mean the import works now \u2014 run it again.\n- `absent` is a real answer: the pool does not hold the package. When\n `acquisitionPossible` is true, report the package as missing so the host can\n acquire it. Do not retry the link, and do not attempt an install.\n- `collision` means the request resolved to two store directories. When one\n name is held by both tracks, call `link_packages` again for that package\n with the prefixed form, `python:<name>` or `r:<name>`.\n- Drop the package only after that second call also refuses. A collision of two\n versions of one distribution is terminal at once: report it and continue\n without the package.\n- Report a package as missing only after `link_packages` answered `absent` or\n `unavailable` for it.\n";
46
46
  export declare const sandboxAnalysisStepStandardsPrompt = "# Sandbox Analysis-Step Conventions\n\nThese apply to plannable analysis steps \u2014 agents that produce reproducible\nscript + output + figure artifacts. They do NOT apply to data profiling or\nreport building (those agents opt out via\n`appendAnalysisStepStandards: false`).\n\n## Language Policy\n\nPython is the DEFAULT language. Use R when:\n(a) No adequate Python equivalent (e.g. minfi, ANCOM-BC2, ChAMP).\n(b) The R implementation is significantly more mature.\n(c) The task explicitly requires an R-only package.\n\nChoose native R or rpy2 based on scope:\n- **Isolated R calls** in a Python pipeline \u2192 rpy2 bridge.\n Example: DESeq2 for one DE step, fgsea for enrichment.\n- **R-dominant pipeline** where most steps use R packages \u2192 write\n **native R scripts**. Do not wrap an entire R pipeline in rpy2.\n Examples: microbiome (DADA2 \u2192 phyloseq \u2192 vegan \u2192 ANCOM-BC2),\n DNA methylation (minfi \u2192 ChAMP \u2192 DMRcate), untargeted metabolomics\n (XCMS \u2192 CAMERA).\n\nNative R scripts follow the same standards as Python: structured logging\n(`message()`), named constants, documented functions. Save final outputs\nas CSV (and optionally AnnData) for cross-agent consumption.\n\n## Data Format Standards\n\n- **AnnData (.h5ad)** is the UNIVERSAL container for sample-by-feature\n data across ALL modalities \u2014 bulk RNA-seq, proteomics, metabolomics,\n microarray, methylation, single-cell. Samples in `.obs`, features in\n `.var`, values in `.X`, layers in `.layers`, embeddings in `.obsm`,\n unstructured results in `.uns`.\n- **MuData (.h5mu)** for multi-modal data (CITE-seq, Multiome,\n multi-omics integration).\n- Store sample metadata in `.obs`, feature annotations in `.var`. Do\n NOT write separate metadata CSV files unless explicitly needed.\n- **Intermediate analysis objects**: save as `.h5ad` or `.h5mu`. Do\n NOT save as `.rds` or `.pkl`. When R packages produce R objects\n (DESeqDataSet, phyloseq, SingleCellExperiment), convert key results\n back to AnnData or pandas.\n- **Tabular results**: CSV with human-readable column names\n (`log2_fold_change`, `adjusted_pvalue`, not `lfc`/`padj`).\n- **Genomic data**: VCF/BED/BAM are acceptable primary containers.\n Derived summary statistics go in AnnData or CSV.\n- **Dual-format output**: when producing modality-specific results,\n also output a generic CSV for downstream cross-cutting agents.\n\n## Figure Standards\n\n- **PNG at 300 DPI** for preview AND **PDF (vector)** for publication,\n same base filename with different extensions.\n- **Colorblind-safe palettes** \u2014 viridis family as default. Never use\n red-green as the sole distinguishing feature.\n- **Clean minimal themes**: matplotlib `seaborn-v0_8-whitegrid` or\n equivalent; ggplot2 `theme_classic()` or `theme_minimal()`.\n- **Complete labels**: title, axis labels with units, legend.\n- **Statistical annotations** where applicable (p-values, significance\n brackets, effect sizes).\n- No default matplotlib/ggplot styling, no cut-off labels, no\n overlapping text.\n\n## Directory Structure\n\nYour working directory has five subdirectories \u2014 write each kind of artifact\nto its own (paths are relative to your working directory):\n\n```\nscripts/ Analysis scripts (.py, .R)\noutput/ Results: CSV, AnnData (.h5ad), MuData (.h5mu), JSON\nfigures/ Visualizations: PNG (300 DPI) + PDF (vector), same base name\nlogs/ Execution logs from long-running commands\nnotebooks/ Reserved (currently unused)\n```\n\nPut data results in `output/`, not at the working-directory root. For\nlong-running commands, redirect stdout/stderr to a file in `logs/`:\n```python\nsubprocess.run(cmd, stdout=open(\"logs/deseq2_run.log\", \"w\"), stderr=subprocess.STDOUT)\n```\n\n## Code Quality\n\n**Functions** for any non-trivial logic. Each logical operation\n(load, QC, fit, plot) gets its own function with a docstring. Top-level\nscript flow should read like an outline \u2014 call functions, not inline\nlogic.\n\n```python\n# %% [markdown]\n# ## Differential Expression\n\n# %%\ndef run_deseq2(counts: pd.DataFrame, metadata: pd.DataFrame, design: str) -> pd.DataFrame:\n \"\"\"Run DESeq2 via rpy2 and return results as a DataFrame.\"\"\"\n ...\n\nde_results = run_deseq2(counts, metadata, \"~ condition + batch\")\n```\n\n**Structured logging**, not `print()`:\n\n- Python \u2014 `logging.getLogger()`:\n `logger.info(\"Loaded %d samples, %d genes\", n_samples, n_genes)`\n- R \u2014 `message()` / `warning()`:\n `message(sprintf(\"Loaded %d samples\", n_samples))`\n\nReserve `print()` / `cat()` for intentional data display in notebook\ncells only.\n\n**Validate at function boundaries**. Fail fast with clear messages.\nUse `try/except` only when recovery is meaningful.\n\n**Type hints** on Python function signatures. Add them \u2014 they document\nintent and catch mistakes early.\n\n**Named constants** for thresholds, cutoffs, parameters in the first\ncode cell:\n```python\n# %% Parameters\nRANDOM_SEED = 42\nFDR_THRESHOLD = 0.05\nMIN_LOG2FC = 1.0\n```\n\n**R specifics**: load packages at the top (never inside functions).\nUse `<-` for assignment. Use `message()` / `warning()` not `cat()` /\n`print()`.\n\n## Scripts\n\n- Start with a header comment (title + description).\n- Parameters go in the first code block.\n- Set random seeds for reproducibility.\n- Scripts must be independently re-runnable.\n- **Lint Python scripts with `ruff check <script.py>`** via\n `execute_command` before executing. `ruff` is pre-installed. This\n catches import errors, undefined names, and common bugs early.\n\n## Cleanup\n\nWhen a script fails, delete the failed script from `scripts/` and any\npartial output files before writing a corrected version. Only final\nworking scripts should remain. Use `list_files` to verify.\n\n## Interpretation & Literature Grounding\n\nGround findings in the research literature as you work \u2014 not as a\nfinal step:\n\n1. When you identify a significant finding (DE genes, enriched\n pathways, hub genes, quality issues), search PubMed with\n `pubmed({action:\"search\"})`.\n2. For relevant hits, call `pubmed({action:\"details\"})` for abstracts and\n PMIDs. Note short citations (e.g. \"Smith et al., Nature 2023\").\n3. Assess novelty per finding: **novel** (searched, nothing found),\n **confirmed** (directly supported), **partially confirmed**,\n **contradicted**, **expected** (domain knowledge, no citation\n needed).\n4. Document the search even when no references are found. \"Novel\"\n means you looked and found nothing \u2014 not that you didn't look.\n\nYour structured output captures this interpretation. It is your\nscientific assessment of what the results mean.\n\n## Analysis-Step Anti-Patterns\n\n- **Ignoring script errors.** Read the error, diagnose the root\n cause, fix the script. Do not move on.\n- **Placeholder or dummy output.** If an analysis cannot produce\n meaningful results, report the reason clearly instead of faking\n results.\n- **Monolithic scripts.** Keep scripts under 300 lines. Split into\n logical sections with markdown annotations.\n- **`print()` for logging.** Use `logging` (Python) or `message()`\n (R). `print()` is for intentional data display only.\n- **Inline logic without functions.** Wrap non-trivial operations in\n functions with docstrings and typed parameters.\n- **Magic numbers.** Define thresholds and cutoffs as named constants.\n";
@@ -312,13 +312,18 @@ to a directory that is already on the mount. So a package your farm lacks is
312
312
  not missing until the pool has answered:
313
313
 
314
314
  - After a failed import, and when \`list_available_packages\` reports a package
315
- absent, call \`link_packages\` with the module name verbatim.
315
+ absent, call \`link_packages\` with the module name verbatim. Each entry is
316
+ \`[python:|r:]<name>[==<version>]\`, and a bare name searches both tracks.
316
317
  - \`linked\` and \`present\` mean the import works now — run it again.
317
318
  - \`absent\` is a real answer: the pool does not hold the package. When
318
319
  \`acquisitionPossible\` is true, report the package as missing so the host can
319
320
  acquire it. Do not retry the link, and do not attempt an install.
320
- - \`collision\` is terminal for that package: the request resolves to two store
321
- directories. Report it and continue without the package.
321
+ - \`collision\` means the request resolved to two store directories. When one
322
+ name is held by both tracks, call \`link_packages\` again for that package
323
+ with the prefixed form, \`python:<name>\` or \`r:<name>\`.
324
+ - Drop the package only after that second call also refuses. A collision of two
325
+ versions of one distribution is terminal at once: report it and continue
326
+ without the package.
322
327
  - Report a package as missing only after \`link_packages\` answered \`absent\` or
323
328
  \`unavailable\` for it.
324
329
  `;