@inflexa-ai/harness 0.39.0 → 0.39.2

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 (39) hide show
  1. package/dist/agents/system-prompt.d.ts +3 -4
  2. package/dist/agents/system-prompt.js +1 -1
  3. package/dist/execution/artifact-registration.d.ts +12 -2
  4. package/dist/execution/artifact-registration.js +9 -4
  5. package/dist/execution/post-step-pipeline.d.ts +3 -6
  6. package/dist/execution/post-step-pipeline.js +11 -3
  7. package/dist/execution/step-summary.js +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +1 -1
  10. package/dist/loop/metrics.js +1 -1
  11. package/dist/loop/run-agent.d.ts +2 -2
  12. package/dist/loop/run-agent.js +6 -8
  13. package/dist/loop/types.d.ts +1 -1
  14. package/dist/prompts/SOUL.d.ts +3 -3
  15. package/dist/prompts/SOUL.js +2 -16
  16. package/dist/prompts/conversation.d.ts +1 -1
  17. package/dist/prompts/conversation.js +14 -24
  18. package/dist/prompts/execute-analysis/step-summary.js +3 -4
  19. package/dist/prompts/planner.js +44 -45
  20. package/dist/prompts/sandbox/scientific-executor.d.ts +1 -1
  21. package/dist/prompts/sandbox/scientific-executor.js +5 -6
  22. package/dist/prompts/sandbox-standards.d.ts +1 -1
  23. package/dist/prompts/sandbox-standards.js +6 -7
  24. package/dist/prompts/synthesis-agent.d.ts +1 -1
  25. package/dist/prompts/synthesis-agent.js +9 -13
  26. package/dist/providers/types.d.ts +5 -4
  27. package/dist/report-model/pin-snapshot.js +4 -10
  28. package/dist/state/artifacts.d.ts +11 -9
  29. package/dist/state/artifacts.js +112 -93
  30. package/dist/state/data-profile.d.ts +2 -2
  31. package/dist/state/data-profile.js +2 -2
  32. package/dist/tasks/data-profile.d.ts +28 -10
  33. package/dist/tasks/data-profile.js +114 -95
  34. package/dist/tools/ad-hoc-router.js +2 -1
  35. package/dist/tools/research/generate-analogy-report.js +2 -4
  36. package/dist/tools/research/generate-plan.js +7 -8
  37. package/dist/tools/sandbox/list-available-refs.js +1 -2
  38. package/dist/workflows/execute-analysis.js +5 -3
  39. package/package.json +1 -1
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
21
21
  import { randomUUID } from "node:crypto";
22
- import { err, ok } from "neverthrow";
22
+ import { ResultAsync, err, ok } from "neverthrow";
23
23
  import { forStep, forSubAgent } from "../auth/types.js";
24
24
  import { createDataProfilerAgent } from "../agents/sandbox/data-profiler.js";
25
25
  import { runToTerminal } from "../loop/run-to-terminal.js";
@@ -345,14 +345,14 @@ export async function runDataProfileBody(input, deps) {
345
345
  return;
346
346
  }
347
347
  // 2. Register staged files in cortex_artifacts
348
- await upsertArtifacts(deps.pool, stagedInputs.map((f) => ({
348
+ unwrapOrThrow(await upsertArtifacts(deps.pool, stagedInputs.map((f) => ({
349
349
  resourceId: analysisId,
350
350
  path: inputArtifactPath(f),
351
351
  hash: f.hash,
352
352
  size: f.size,
353
353
  role: "input",
354
354
  fileId: f.fileId,
355
- })));
355
+ }))));
356
356
  const executionId = generateExecutionId(DATA_PROFILE_AGENT_ID);
357
357
  const workflowId = DBOS.workflowID ?? `${DATA_PROFILE_RUN_LITERAL}:${executionId}`;
358
358
  const childSession = forSubAgent(runSession, DATA_PROFILE_AGENT_ID);
@@ -636,6 +636,15 @@ function settleTerminalWrite(logger, analysisId, write, status) {
636
636
  }
637
637
  recordRunCompleted({ workflow: "data_profile", status, durationMs: elapsedSinceIso(write.startedAt) });
638
638
  }
639
+ /** A one-line, user-facing description of a `DataProfileStartError`. */
640
+ export function describeDataProfileStartError(e) {
641
+ switch (e.type) {
642
+ case "refused":
643
+ return `the profile run was not authorized: ${e.reason}`;
644
+ case "start_failed":
645
+ return `the profile workflow did not start: ${profileFailureReason(e.cause)}`;
646
+ }
647
+ }
639
648
  /**
640
649
  * Resolve the `RunSession` the workflow runs under. Whether the authorization
641
650
  * is freshly minted (Cortex-owned) or reused (caller-owned) is decided by the
@@ -667,18 +676,14 @@ export function dataProfileWorkflowId(analysisId, nonce) {
667
676
  return `dataprofile:${analysisId}:${nonce}`;
668
677
  }
669
678
  /**
670
- * Authorize the run via the `RunAuthorizer` and start the data-profile workflow
671
- * under a per-attempt id `dataprofile:{analysisId}:{nonce}` — concurrent
672
- * triggers are already serialized by the ledger CAS. The caller has already
673
- * staged the inputs and supplied the manifest in `params.stagedInputs`; this
674
- * forwards it into the workflow input. Fire-and-forget: the handle result is
675
- * not awaited.
679
+ * Start the data-profile workflow under the authorization and a per-attempt id
680
+ * `dataprofile:{analysisId}:{nonce}` — concurrent triggers are already
681
+ * serialized by the ledger CAS. The caller has already staged the inputs and
682
+ * supplied the manifest in `params.stagedInputs`; this forwards it into the
683
+ * workflow input. The handle result is not awaited.
676
684
  */
677
- async function startDataProfileWorkflow(deps, params) {
678
- const authorized = await authorizeDataProfile(deps, params);
679
- if (authorized.isErr())
680
- return err(authorized.error);
681
- const { runSession, ownsMandate } = authorized.value; // oss-core-managed-ok
685
+ async function startDataProfileWorkflow(deps, params, authorization) {
686
+ const { runSession, ownsMandate } = authorization; // oss-core-managed-ok
682
687
  const attemptNonce = randomUUID();
683
688
  await DBOS.startWorkflow(deps.workflow, {
684
689
  workflowID: dataProfileWorkflowId(params.analysisId, attemptNonce),
@@ -688,20 +693,29 @@ async function startDataProfileWorkflow(deps, params) {
688
693
  ownsMandate, // oss-core-managed-ok
689
694
  stagedInputs: params.stagedInputs,
690
695
  });
691
- return ok(undefined);
692
696
  }
693
697
  /**
694
- * Dispatch a claimed profile, fire-and-forget. The claim already flipped the
695
- * row to `running`, thus a refused authorization and a rejected start each
696
- * settle the row as `failed`, and it never wedges at `running`.
698
+ * Authorize and start a claimed profile. The claim already flipped the row to
699
+ * `running`, thus a refused authorization and a failed start each settle the
700
+ * row as `failed` before the `err`, and the row never wedges at `running`. A
701
+ * rejected `authorize` promise is a defect of the host: the row is settled the
702
+ * same way, and the rejection passes through.
697
703
  */
698
704
  async function dispatchClaimedProfile(deps, params, phase) {
699
- const started = await startDataProfileWorkflow(deps, params).catch(async (cause) => {
700
- await compensateStartFailure(deps, params.analysisId, phase, cause);
701
- return undefined;
705
+ const authorized = await Promise.resolve(authorizeDataProfile(deps, params)).catch(async (defect) => {
706
+ await compensateStartFailure(deps, params.analysisId, phase, defect);
707
+ throw defect;
702
708
  });
703
- if (started?.isErr())
704
- await refuseClaimedProfile(deps, params.analysisId, phase, started.error);
709
+ if (authorized.isErr()) {
710
+ await refuseClaimedProfile(deps, params.analysisId, phase, authorized.error);
711
+ return err({ type: "refused", reason: authorized.error.reason, suspend: authorized.error.kind === "suspended" });
712
+ }
713
+ const started = await ResultAsync.fromPromise(startDataProfileWorkflow(deps, params, authorized.value), (cause) => cause);
714
+ if (started.isErr()) {
715
+ await compensateStartFailure(deps, params.analysisId, phase, started.error);
716
+ return err({ type: "start_failed", cause: started.error });
717
+ }
718
+ return ok(undefined);
705
719
  }
706
720
  /**
707
721
  * Attempt to claim and run data profiling for an analysis, then start the workflow
@@ -720,70 +734,85 @@ async function dispatchClaimedProfile(deps, params, phase) {
720
734
  * `"completed"`. An empty manifest against a seed that names files is a divergence
721
735
  * between the caller and the ledger, and the trigger refuses it.
722
736
  *
723
- * Returns what happened, so the caller can surface it (e.g. in the seed response).
737
+ * Returns what happened, so the caller can surface it (e.g. in the seed response). A
738
+ * failed ledger read or claim is the `err`. The dispatch settles its own row, thus a
739
+ * refusal or a failed start after a claim shows on the ledger only.
724
740
  */
725
- export async function triggerDataProfile(deps, params) {
741
+ export function triggerDataProfile(deps, params) {
742
+ return new ResultAsync(claimAndDispatchProfile(deps, params));
743
+ }
744
+ async function claimAndDispatchProfile(deps, params) {
726
745
  const logger = (deps.logger ?? createNoopLogger()).named("data-profile").with({ analysisId: params.analysisId });
727
746
  const { analysisId } = params;
728
- try {
729
- // ADVISORY pre-check, not the enforcement — the claim CAS carries the same seed
730
- // conjunct (see `SEEDED` in state/data-profile.ts) and is what actually makes a
731
- // seedless `running` row impossible. This read exists only to name the cause: on
732
- // the ordinary non-racing path it turns an opaque "no claim matched" into a
733
- // precise reason. An empty array is not a seed — it names zero files. Reads via
734
- // `loadSeedInputFileIds`, which returns null for BOTH a missing analysis row and
735
- // a NULL-seed row (`loadDataProfileStatus` would hide the seed of a cleared row).
736
- const seeded = unwrapOrThrow(await loadSeedInputFileIds(deps.pool, analysisId));
737
- if (params.stagedInputs.length === 0) {
738
- // The trigger validates the manifest it is about to dispatch against the ledger
739
- // seed. A seeded row profiled against an EMPTY manifest would claim `running` and
740
- // then hit the body's empty-manifest path, and complete with a NULL result while
741
- // the seed still names files. Refuse the divergence before any claim.
742
- if (seeded !== null && seeded.length > 0) {
743
- logger.error("trigger rejected: empty manifest dispatched against a non-empty seed", { seededCount: seeded.length });
744
- return "failed";
745
- }
746
- // The analysis has no input files, so there is nothing to profile. The row is
747
- // `completed` at once, with no workflow. The CAS carries the same seed
748
- // predicate as this pre-read, so a seed upsert that lands in between cannot be
749
- // hidden behind a finished profile: the stamp refuses, and the status read
750
- // below names the cause.
751
- if (unwrapOrThrow(await completeEmptyDataProfile(deps.pool, analysisId)))
752
- return "completed";
753
- const current = unwrapOrThrow(await loadDataProfileStatus(deps.pool, analysisId));
754
- if (current?.status === "running")
755
- return "already_running";
756
- logger.error("trigger rejected: empty-set completion refused (no analysis row, or the seed now names files)");
757
- return "failed";
758
- }
759
- // A non-empty manifest against a seed that names no file: the caller skipped the
760
- // seed, or the analysis row is missing. Name the cause before any claim.
761
- if (seeded === null || seeded.length === 0) {
762
- logger.error("trigger rejected: no seeded input set (missing analysis row or caller skipped seeding)");
763
- return "failed";
764
- }
765
- const started = unwrapOrThrow(await tryStartDataProfile(deps.pool, analysisId));
766
- if (started) {
767
- void dispatchClaimedProfile(deps, params, "Run");
768
- return "started";
747
+ // ADVISORY pre-check, not the enforcement — the claim CAS carries the same seed
748
+ // conjunct (see `SEEDED` in state/data-profile.ts) and is what actually makes a
749
+ // seedless `running` row impossible. This read exists only to name the cause: on
750
+ // the ordinary non-racing path it turns an opaque "no claim matched" into a
751
+ // precise reason. An empty array is not a seed — it names zero files. Reads via
752
+ // `loadSeedInputFileIds`, which returns null for BOTH a missing analysis row and
753
+ // a NULL-seed row (`loadDataProfileStatus` would hide the seed of a cleared row).
754
+ const seedRead = await loadSeedInputFileIds(deps.pool, analysisId);
755
+ if (seedRead.isErr())
756
+ return err(seedRead.error);
757
+ const seeded = seedRead.value;
758
+ if (params.stagedInputs.length === 0) {
759
+ // The trigger validates the manifest it is about to dispatch against the ledger
760
+ // seed. A seeded row profiled against an EMPTY manifest would claim `running` and
761
+ // then hit the body's empty-manifest path, and complete with a NULL result while
762
+ // the seed still names files. Refuse the divergence before any claim.
763
+ if (seeded !== null && seeded.length > 0) {
764
+ logger.error("trigger rejected: empty manifest dispatched against a non-empty seed", { seededCount: seeded.length });
765
+ return ok("failed");
769
766
  }
770
- const restarted = unwrapOrThrow(await tryRerunDataProfile(deps.pool, analysisId));
771
- if (restarted) {
772
- void dispatchClaimedProfile(deps, params, "Re-run");
773
- return "restarted";
774
- }
775
- const status = unwrapOrThrow(await loadDataProfileStatus(deps.pool, analysisId));
776
- if (status?.status === "running")
777
- return "already_running";
778
- return "failed";
767
+ // The analysis has no input files, so there is nothing to profile. The row is
768
+ // `completed` at once, with no workflow. The CAS carries the same seed
769
+ // predicate as this pre-read, so a seed upsert that lands in between cannot be
770
+ // hidden behind a finished profile: the stamp refuses, and the status read
771
+ // below names the cause.
772
+ const completed = await completeEmptyDataProfile(deps.pool, analysisId);
773
+ if (completed.isErr())
774
+ return err(completed.error);
775
+ if (completed.value)
776
+ return ok("completed");
777
+ const current = await loadDataProfileStatus(deps.pool, analysisId);
778
+ if (current.isErr())
779
+ return err(current.error);
780
+ if (current.value?.status === "running")
781
+ return ok("already_running");
782
+ logger.error("trigger rejected: empty-set completion refused (no analysis row, or the seed now names files)");
783
+ return ok("failed");
779
784
  }
780
- catch (err) {
781
- logger.error("trigger error", logger.errorFields(err));
782
- return "failed";
785
+ // A non-empty manifest against a seed that names no file: the caller skipped the
786
+ // seed, or the analysis row is missing. Name the cause before any claim.
787
+ if (seeded === null || seeded.length === 0) {
788
+ logger.error("trigger rejected: no seeded input set (missing analysis row or caller skipped seeding)");
789
+ return ok("failed");
790
+ }
791
+ const started = await tryStartDataProfile(deps.pool, analysisId);
792
+ if (started.isErr())
793
+ return err(started.error);
794
+ if (started.value) {
795
+ dispatchInBackground(deps, params, "Run");
796
+ return ok("started");
797
+ }
798
+ const restarted = await tryRerunDataProfile(deps.pool, analysisId);
799
+ if (restarted.isErr())
800
+ return err(restarted.error);
801
+ if (restarted.value) {
802
+ dispatchInBackground(deps, params, "Re-run");
803
+ return ok("restarted");
783
804
  }
805
+ const status = await loadDataProfileStatus(deps.pool, analysisId);
806
+ if (status.isErr())
807
+ return err(status.error);
808
+ return ok(status.value?.status === "running" ? "already_running" : "failed");
809
+ }
810
+ /** The dispatch settles the row on each arm and logs a defect, thus the trigger keeps no outcome of it. */
811
+ function dispatchInBackground(deps, params, phase) {
812
+ void dispatchClaimedProfile(deps, params, phase).catch(() => undefined);
784
813
  }
785
814
  /**
786
- * Compensate a fire-and-forget start that rejected. The ledger CAS in
815
+ * Compensate a start that failed after the claim. The ledger CAS in
787
816
  * `tryStart`/`tryRerun`/`tryRetry` already flipped the row to `running`; if the
788
817
  * dispatch never landed a workflow, DBOS has nothing to recover, so without
789
818
  * this the row would sit at `running` forever and every later trigger would
@@ -827,23 +856,13 @@ async function failClaimedProfile(deps, analysisId, phase, reason) {
827
856
  /**
828
857
  * Start the data-profile workflow for an already-claimed analysis (the retry
829
858
  * route claims via `tryRetryDataProfile`, then calls this). The caller does not
830
- * await the workflow's completion, but a start that rejects compensates the
831
- * ledger (see {@link compensateStartFailure}) before re-throwing, so a caller's
832
- * own `.catch` still observes the error and the row never wedges at `running`.
833
- * A refused authorization fails the row with the reason of the host, and the
834
- * promise resolves: the row is where a caller reads the outcome.
859
+ * wait for the completion of the workflow. A refused authorization and a failed
860
+ * start each settle the row as `failed` before the `err` (see
861
+ * {@link dispatchClaimedProfile}), thus a caller that minted an authorization
862
+ * can revoke it.
835
863
  */
836
- export async function runDataProfile(deps, params) {
837
- let started;
838
- try {
839
- started = await startDataProfileWorkflow(deps, params);
840
- }
841
- catch (cause) {
842
- await compensateStartFailure(deps, params.analysisId, "Retry", cause);
843
- throw cause;
844
- }
845
- if (started.isErr())
846
- await refuseClaimedProfile(deps, params.analysisId, "Retry", started.error);
864
+ export function runDataProfile(deps, params) {
865
+ return new ResultAsync(dispatchClaimedProfile(deps, params, "Retry"));
847
866
  }
848
867
  /**
849
868
  * Concise, UI-safe failure reason. `data_profile_error` is surfaced verbatim
@@ -176,8 +176,9 @@ export async function routeAdHocRequest(deps, input) {
176
176
  content: `Request:\n${input.request}\n\nPersisted data-profile orientation:\n${orientation}\n\nEligible specialists:\n${catalog}`,
177
177
  },
178
178
  ],
179
+ // No forced tool choice: some Claude models reject it with a
180
+ // 400. A reply without the call falls back below as `malformed`.
179
181
  tools: routeTool(),
180
- toolChoice: { type: "tool", toolName: "submit_route" },
181
182
  }, forSubAgent(input.session, AD_HOC_ROUTER_AGENT_ID), signal));
182
183
  const call = Array.isArray(response.message.content)
183
184
  ? response.message.content.find((part) => part.type === "tool-call" && part.toolName === "submit_route")
@@ -228,10 +228,8 @@ export function createGenerateAnalogyReportTool(deps) {
228
228
  if (directParse.ok)
229
229
  return ok(directParse.value);
230
230
  // Slow path: a single-shot conversion call (no tools) transforms the
231
- // raw output into a valid envelope. The harness anthropic provider
232
- // silently drops `temperature` on 4.7+ models, so an explicit
233
- // Sonnet-vs-Opus split is unnecessary — the parse+validate+envelope
234
- // cascade below is the real safety net.
231
+ // raw output into a valid envelope. The parse+validate+envelope
232
+ // cascade below is the safety net.
235
233
  try {
236
234
  const reply = unwrapOrThrow(await deps.provider.chat({
237
235
  tools: {},
@@ -65,9 +65,9 @@ const PLANNER_AGENT_ID = "planner";
65
65
  * path and submits the plan that it had, not the plan that it was building.
66
66
  * Thus the wall-clock guard, not this number, is what bounds the worst case.
67
67
  */
68
- const PLANNER_MAX_ITERATIONS = 200;
68
+ const PLANNER_MAX_ITERATIONS = 300;
69
69
  /** Wall-clock guard for a single plan-generation invocation. */
70
- const PLAN_TIMEOUT_MS = 600_000;
70
+ const PLAN_TIMEOUT_MS = 1_800_000;
71
71
  // ── Diagnostic bounds ───────────────────────────────────────────────
72
72
  //
73
73
  // This tool runs on `passthroughStep`: it writes no ledger row and owns no
@@ -674,8 +674,8 @@ function buildPlannerSearchTools(deps) {
674
674
  queryDocsTool,
675
675
  // The environment itself. The seed already carries a rendered census of
676
676
  // both stores. These two tools are for the narrow second look: one
677
- // collection of the reference store, or one package name that a step is
678
- // about to import.
677
+ // collection of the reference store, or one batch of the package names
678
+ // that the census leaves unclear.
679
679
  createListAvailableRefsTool(deps.refStorePath === undefined ? {} : { refStorePath: deps.refStorePath }),
680
680
  // The planner is a conversation surface, thus the pool-scope reader binds
681
681
  // when the embedder gives one, and the answer names what the store HOLDS.
@@ -894,10 +894,9 @@ export function createGeneratePlanTool(deps) {
894
894
  emit: ctx.emit,
895
895
  runStep: passthroughStep,
896
896
  resolved: () => holder.outcome !== null,
897
- // Planner prose is unusable: every meaningful outcome is
898
- // a tool call, and the terminal predicate stops the loop
899
- // as soon as one is recorded.
900
- toolChoice: "required",
897
+ // No forced tool choice: some Claude models reject it with a
898
+ // 400. The prompt names the terminal tools, and the salvage
899
+ // turn below recovers a run that ends on prose.
901
900
  logger,
902
901
  usageRecorder: deps.usageRecorder,
903
902
  // Fold the planner's calls into the turn total the root loop reports.
@@ -515,8 +515,7 @@ export function createListAvailableRefsTool(deps) {
515
515
  '`category` is shorthand for a top-level group (e.g. "pathways", "regulatory-networks"), and the groups present come back in `categories`; ' +
516
516
  "`limit` caps the entries returned. The response carries `returned`, `total`, and `hasMore`, so truncation is never silent. " +
517
517
  "An empty result means the dataset is absent right now — not that it is unobtainable. Say so; never guess a path, and never silently substitute a different dataset. " +
518
- "The store is provisioned by the host and is NOT frozen: datasets can be added through the host's own provisioning path, and whatever is added shows up on a later call. " +
519
- "So before you report a gap as permanent, check whether any tool you hold reaches that provisioning path — offer that route if one exists, and call the gap permanent only when none does.",
518
+ "The store is provisioned by the host and is NOT frozen: datasets can be added through the host's own provisioning path, and whatever is added shows up on a later call.",
520
519
  inputSchema: ListAvailableRefsInputSchema,
521
520
  // `execute` resolves `path ?? category` for the subtree it inspects, and
522
521
  // it ignores `category` when `path` is present. `query` is an ADDITIVE
@@ -69,7 +69,7 @@
69
69
  * via `inspectRun`)
70
70
  */
71
71
  import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
72
- import { ResultAsync, err, ok } from "neverthrow";
72
+ import { err, ok } from "neverthrow";
73
73
  import { mkdir } from "node:fs/promises";
74
74
  import { join } from "node:path";
75
75
  import { forStep } from "../auth/types.js";
@@ -286,7 +286,7 @@ async function collectUpstreamHandoffs(args) {
286
286
  const handoffs = [];
287
287
  for (const summary of summaries) {
288
288
  const outputDir = stepSubdir(runStepDir(runId, summary.stepId), "output");
289
- const artifacts = await queryStepArtifactPaths(pool, analysisId, runId, summary.stepId, MAX_UPSTREAM_ARTIFACTS);
289
+ const artifacts = unwrapOrThrow(await queryStepArtifactPaths(pool, analysisId, runId, summary.stepId, MAX_UPSTREAM_ARTIFACTS));
290
290
  handoffs.push({
291
291
  stepId: summary.stepId,
292
292
  agentId: summary.agentId,
@@ -1124,7 +1124,9 @@ async function collectAndComplete(args) {
1124
1124
  // beat the CLI's poll-and-shutdown). These branches only fan out the UI stream part, whose
1125
1125
  // completed/failed shapes genuinely differ — the provenance record does not.
1126
1126
  if (status === "completed" || status === "partial") {
1127
- const artifactCount = await DBOS.runStep(async () => await ResultAsync.fromPromise(countArtifactsForRun(deps.pool, input.analysisId, runId), () => "the artifact count failed").unwrapOr(0), { name: "count-run-artifacts" });
1127
+ const artifactCount = await DBOS.runStep(async () => await countArtifactsForRun(deps.pool, input.analysisId, runId).unwrapOr(0), {
1128
+ name: "count-run-artifacts",
1129
+ });
1128
1130
  await emitStreamPart({
1129
1131
  type: "data-run-completed",
1130
1132
  runId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.39.0",
3
+ "version": "0.39.2",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",