@inflexa-ai/harness 0.23.0 → 0.25.0

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 (123) hide show
  1. package/README.md +1 -2
  2. package/dist/agents/conversation-agent.d.ts +0 -9
  3. package/dist/agents/sandbox/__fixtures__/deps.js +6 -0
  4. package/dist/agents/sandbox/data-profiler.js +5 -4
  5. package/dist/agents/sandbox/shared.js +11 -0
  6. package/dist/agents/sandbox/types.d.ts +1 -1
  7. package/dist/app/data-profile-orientation.js +30 -1
  8. package/dist/app/data-profile-policy.d.ts +33 -7
  9. package/dist/app/data-profile-policy.js +31 -9
  10. package/dist/contracts/chat-parts.d.ts +29 -33
  11. package/dist/contracts/content-url.d.ts +7 -41
  12. package/dist/contracts/content-url.js +7 -48
  13. package/dist/contracts/data-profile.d.ts +166 -0
  14. package/dist/contracts/index.d.ts +3 -2
  15. package/dist/contracts/index.js +1 -1
  16. package/dist/contracts/message.d.ts +1 -1
  17. package/dist/contracts/message.js +1 -1
  18. package/dist/contracts/part-registry.js +2 -3
  19. package/dist/contracts/schemas/chat-parts.d.ts +16 -53
  20. package/dist/contracts/schemas/chat-parts.js +15 -25
  21. package/dist/contracts/schemas/index.d.ts +1 -1
  22. package/dist/contracts/schemas/index.js +1 -1
  23. package/dist/execution/input-signature.d.ts +33 -0
  24. package/dist/execution/input-signature.js +0 -0
  25. package/dist/execution/run-authorizer.d.ts +1 -1
  26. package/dist/execution/run-authorizer.js +1 -1
  27. package/dist/index.d.ts +5 -4
  28. package/dist/index.js +5 -2
  29. package/dist/input-scan/coverage.d.ts +22 -0
  30. package/dist/input-scan/coverage.js +85 -0
  31. package/dist/input-scan/enrich.d.ts +41 -0
  32. package/dist/input-scan/enrich.js +227 -0
  33. package/dist/input-scan/formats.d.ts +41 -0
  34. package/dist/input-scan/formats.js +279 -0
  35. package/dist/input-scan/index.d.ts +12 -0
  36. package/dist/input-scan/index.js +11 -0
  37. package/dist/input-scan/scan.d.ts +50 -0
  38. package/dist/input-scan/scan.js +182 -0
  39. package/dist/input-scan/shapes.d.ts +46 -0
  40. package/dist/input-scan/shapes.js +0 -0
  41. package/dist/input-scan/types.d.ts +172 -0
  42. package/dist/input-scan/types.js +12 -0
  43. package/dist/lib/zod-issues.d.ts +2 -3
  44. package/dist/lib/zod-issues.js +2 -3
  45. package/dist/loop/__fixtures__/scripted-provider.js +3 -1
  46. package/dist/loop/run-agent.d.ts +5 -0
  47. package/dist/loop/run-agent.js +10 -1
  48. package/dist/loop/run-to-terminal.d.ts +1 -1
  49. package/dist/loop/run-to-terminal.js +1 -1
  50. package/dist/memory/card-builders.d.ts +0 -27
  51. package/dist/memory/card-builders.js +0 -75
  52. package/dist/memory/conversation-display-backfill.d.ts +2 -4
  53. package/dist/memory/conversation-display-backfill.js +3 -9
  54. package/dist/memory/conversation-display-storage.d.ts +3 -4
  55. package/dist/memory/conversation-display-storage.js +3 -4
  56. package/dist/memory/reconstruct-cards.d.ts +5 -7
  57. package/dist/memory/reconstruct-cards.js +6 -20
  58. package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
  59. package/dist/prompts/sandbox/data-profiler.js +159 -275
  60. package/dist/provenance/exec-frame.js +9 -3
  61. package/dist/runtime/boot.js +2 -12
  62. package/dist/sandbox/client.d.ts +7 -0
  63. package/dist/sandbox/create-sandbox.js +1 -0
  64. package/dist/sandbox/docker-client.d.ts +1 -0
  65. package/dist/sandbox/docker-client.js +20 -15
  66. package/dist/sandbox/k8s-client.d.ts +6 -5
  67. package/dist/sandbox/k8s-client.js +55 -39
  68. package/dist/sandbox/reaper.d.ts +24 -13
  69. package/dist/sandbox/reaper.js +47 -14
  70. package/dist/sandbox/types.d.ts +6 -5
  71. package/dist/schemas/data-profile-schemas.d.ts +62 -0
  72. package/dist/schemas/data-profile-schemas.js +68 -2
  73. package/dist/state/data-profile.d.ts +7 -88
  74. package/dist/state/index.d.ts +1 -1
  75. package/dist/tasks/data-profile-activity.d.ts +8 -0
  76. package/dist/tasks/data-profile-activity.js +1 -0
  77. package/dist/tasks/data-profile-index.d.ts +45 -0
  78. package/dist/tasks/data-profile-index.js +134 -0
  79. package/dist/tasks/data-profile.d.ts +8 -23
  80. package/dist/tasks/data-profile.js +150 -117
  81. package/dist/tools/report-session/preview-report.d.ts +2 -1
  82. package/dist/tools/report-session/preview-report.js +9 -1
  83. package/dist/tools/research/generate-plan.js +1 -1
  84. package/dist/tools/research/inspect-data-profile.d.ts +30 -4
  85. package/dist/tools/research/inspect-data-profile.js +66 -10
  86. package/dist/tools/start-report-session.js +2 -2
  87. package/dist/tools/workspace/scan-inputs.d.ts +38 -0
  88. package/dist/tools/workspace/scan-inputs.js +61 -0
  89. package/dist/tools/workspace/workspace-search.d.ts +1 -1
  90. package/dist/tools/workspace/workspace-search.js +12 -9
  91. package/dist/workspace/filesystem.d.ts +27 -0
  92. package/dist/workspace/filesystem.js +27 -0
  93. package/dist/workspace/paths.d.ts +0 -21
  94. package/dist/workspace/paths.js +0 -37
  95. package/package.json +1 -3
  96. package/dist/agents/report-builder.d.ts +0 -34
  97. package/dist/agents/report-builder.js +0 -34
  98. package/dist/execution/report-render.d.ts +0 -39
  99. package/dist/execution/report-render.js +0 -98
  100. package/dist/execution/report-runner.d.ts +0 -111
  101. package/dist/execution/report-runner.js +0 -230
  102. package/dist/prompts/report-builder.d.ts +0 -1
  103. package/dist/prompts/report-builder.js +0 -174
  104. package/dist/tools/iterate-report.d.ts +0 -227
  105. package/dist/tools/iterate-report.js +0 -883
  106. package/dist/tools/lib/preview-lock.d.ts +0 -20
  107. package/dist/tools/lib/preview-lock.js +0 -35
  108. package/dist/tools/lib/report-preflight.d.ts +0 -39
  109. package/dist/tools/lib/report-preflight.js +0 -283
  110. package/dist/tools/report/build-report.d.ts +0 -13
  111. package/dist/tools/report/build-report.js +0 -27
  112. package/dist/tools/report/index.d.ts +0 -15
  113. package/dist/tools/report/index.js +0 -15
  114. package/dist/tools/report/mint-preview-url.d.ts +0 -26
  115. package/dist/tools/report/mint-preview-url.js +0 -47
  116. package/dist/tools/report/preview-publisher.d.ts +0 -53
  117. package/dist/tools/report/preview-publisher.js +0 -45
  118. package/dist/tools/report/preview-snapshot.d.ts +0 -34
  119. package/dist/tools/report/preview-snapshot.js +0 -98
  120. package/dist/tools/report/submit-report.d.ts +0 -26
  121. package/dist/tools/report/submit-report.js +0 -148
  122. package/dist/tools/report/version-fs.d.ts +0 -15
  123. package/dist/tools/report/version-fs.js +0 -165
@@ -12,7 +12,7 @@
12
12
  * both sides address one directory by construction.
13
13
  */
14
14
  import { relative as relativePath, sep } from "node:path";
15
- import { BatchV1Api, CoreV1Api, KubeConfig } from "@kubernetes/client-node";
15
+ import { BatchV1Api, CoreV1Api, KubeConfig, } from "@kubernetes/client-node";
16
16
  import { ResultAsync, err, ok } from "neverthrow";
17
17
  import { trySandbox } from "./sandbox-error.js";
18
18
  import { buildMountPlan, buildSessionSubPaths } from "./mount-plan.js";
@@ -29,18 +29,24 @@ const POD_READY_TIMEOUT_MS = 5 * 60_000;
29
29
  const POD_POLL_INTERVAL_MS = 1_000;
30
30
  const MANAGED_BY_LABEL = "app.kubernetes.io/managed-by";
31
31
  const MANAGED_BY_VALUE = "cortex";
32
- const OWNER_WORKFLOW_LABEL = "cortex/owner-workflow-id";
32
+ /**
33
+ * Annotation key of the owning DBOS workflow id. An annotation value has neither
34
+ * a length cap nor a charset restriction, so the id round-trips verbatim and can
35
+ * be handed back to `DBOS.getWorkflowStatus`. Deliberately not a label: nothing
36
+ * selects on it, and a label value could not hold it (see {@link sanitizeLabelValue}).
37
+ */
38
+ const OWNER_WORKFLOW_ANNOTATION = "cortex/owner-workflow-id";
33
39
  const RUN_ID_LABEL = "cortex/run-id";
34
40
  const STEP_ID_LABEL = "cortex/step-id";
35
41
  const ANALYSIS_ID_LABEL = "cortex/analysis-id";
36
42
  /**
37
43
  * Coerce an arbitrary identifier into a valid K8s label value:
38
44
  * ≤63 chars, `[A-Za-z0-9._-]` only, starting and ending alphanumeric.
39
- * Workflow ids legitimately contain `:` (exec-id routing packs
40
- * `workflowId:stepId:fnId`), which is illegal in a label — without this an
41
- * otherwise-valid Job is rejected with a 422 at admission. Sanitization is a
42
- * no-op for already-valid ids, so DBOS-keyed lookups (adoption, reaper) match
43
- * unchanged; only the synthetic, non-DBOS data-profile id is rewritten.
45
+ * Without it one malformed value gets the whole Job rejected at admission.
46
+ *
47
+ * Lossy by construction: `:` becomes `-` and anything past 63 chars is dropped,
48
+ * neither of which is recoverable. No label value may therefore be used as a
49
+ * lookup key — see {@link OWNER_WORKFLOW_ANNOTATION}.
44
50
  */
45
51
  export function sanitizeLabelValue(value) {
46
52
  return value
@@ -49,6 +55,10 @@ export function sanitizeLabelValue(value) {
49
55
  .slice(0, 63)
50
56
  .replace(/[^A-Za-z0-9]+$/, "");
51
57
  }
58
+ /** The owner workflow id recorded on a Job, or null if it records none. */
59
+ function readOwnerWorkflowId(metadata) {
60
+ return metadata?.annotations?.[OWNER_WORKFLOW_ANNOTATION] ?? null;
61
+ }
52
62
  function deleteJobIgnoreMissing(batchApi, namespace, name) {
53
63
  return trySandbox(async () => {
54
64
  await batchApi.deleteNamespacedJob({
@@ -230,11 +240,13 @@ function buildJobSpec(meta, config, identity) {
230
240
  metadata: {
231
241
  name: sandboxId,
232
242
  namespace: config.namespace,
243
+ annotations: {
244
+ [OWNER_WORKFLOW_ANNOTATION]: meta.childWorkflowId,
245
+ },
233
246
  labels: {
234
247
  [MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
235
248
  role: "sandbox",
236
249
  "cortex/sandbox-id": sandboxId,
237
- [OWNER_WORKFLOW_LABEL]: sanitizeLabelValue(meta.childWorkflowId),
238
250
  [STEP_ID_LABEL]: sanitizeLabelValue(meta.stepId),
239
251
  ...attributionLabels,
240
252
  },
@@ -339,8 +351,8 @@ function waitForJobGone(batchApi, namespace, name) {
339
351
  * dead prior attempt under the same checkpointed name: delete it, wait for the
340
352
  * name to free, and recreate fresh.
341
353
  *
342
- * Adoption (and deletion) is **owner-guarded**: the existing Job's
343
- * `cortex/owner-workflow-id` label must match this step's `ownerWorkflowId`.
354
+ * Adoption (and deletion) is **owner-guarded**: the existing Job's recorded
355
+ * owner workflow id must match this step's `ownerWorkflowId`.
344
356
  * Only a recovery re-run carries the same checkpointed identity, so a mismatch
345
357
  * means an (astronomically rare) name collision with a *different* step —
346
358
  * adopting its pod would HMAC-fail every callback, and deleting its Job would
@@ -363,13 +375,13 @@ function createOrAdoptJob(batchApi, coreApi, namespace, sandboxId, ownerWorkflow
363
375
  const existingRead = await trySandbox(() => batchApi.readNamespacedJob({ namespace, name: sandboxId }), createFailed);
364
376
  if (existingRead.isErr())
365
377
  return err(existingRead.error);
366
- const owner = existingRead.value.metadata?.labels?.[OWNER_WORKFLOW_LABEL];
378
+ const owner = readOwnerWorkflowId(existingRead.value.metadata);
367
379
  if (owner !== ownerWorkflowId) {
368
380
  return err({
369
381
  type: "name_conflict",
370
382
  op: "k8s.createSandbox",
371
383
  sandboxId,
372
- owner: owner ?? null,
384
+ owner,
373
385
  });
374
386
  }
375
387
  const terminal = await existingPodIsTerminal(coreApi, namespace, sandboxId);
@@ -396,7 +408,7 @@ export function createK8sSandboxOps(config) {
396
408
  return new ResultAsync((async () => {
397
409
  const { sandboxId } = identity;
398
410
  const job = buildJobSpec(meta, config, identity);
399
- const adopted = await createOrAdoptJob(batchApi, coreApi, config.namespace, sandboxId, sanitizeLabelValue(meta.childWorkflowId), job);
411
+ const adopted = await createOrAdoptJob(batchApi, coreApi, config.namespace, sandboxId, meta.childWorkflowId, job);
400
412
  if (adopted.isErr())
401
413
  return err(adopted.error);
402
414
  // A Job whose pod never schedules (quota rejection, no nodes, spot
@@ -452,40 +464,44 @@ export function createK8sSandboxOps(config) {
452
464
  const ts = j.metadata?.creationTimestamp;
453
465
  return {
454
466
  sandboxId: labels["cortex/sandbox-id"] ?? j.metadata?.name ?? "",
455
- ownerWorkflowId: labels[OWNER_WORKFLOW_LABEL] ?? null,
467
+ ownerWorkflowId: readOwnerWorkflowId(j.metadata),
456
468
  createdAtMs: ts ? new Date(ts).getTime() : null,
457
469
  };
458
470
  })
459
471
  .filter((s) => s.sandboxId.length > 0));
460
472
  },
461
473
  isAlive(ref) {
462
- return trySandbox(() => coreApi.listNamespacedPod({
463
- namespace: config.namespace,
464
- labelSelector: `cortex/sandbox-id=${ref.sandboxId}`,
465
- }), (status, cause) => ({
466
- type: "liveness_failed",
467
- op: "k8s.isAlive",
468
- sandboxId: ref.sandboxId,
469
- status,
470
- cause,
471
- }))
472
- .map((pods) => {
473
- const pod = pods.items[0];
474
- if (!pod)
475
- return { alive: false, oomKilled: false };
476
- const phase = pod.status?.phase;
477
- // `Pending` and `Running` are alive; `Succeeded`, `Failed`,
478
- // `Unknown` are dead. `Pending` covers startup.
479
- const alive = phase === "Pending" || phase === "Running";
480
- const oomKilled = !alive &&
481
- (pod.status?.containerStatuses ?? []).some((cs) => cs.state?.terminated?.reason === "OOMKilled" || cs.lastState?.terminated?.reason === "OOMKilled");
482
- return { alive, oomKilled };
483
- })
484
- .orElse((e) =>
485
- // A 404 means the pod is gone — observably dead.
486
- statusOf(e) === 404 ? ok({ alive: false, oomKilled: false }) : err(e));
474
+ return isAliveById(ref.sandboxId);
487
475
  },
476
+ isAliveById,
488
477
  };
478
+ function isAliveById(sandboxId) {
479
+ return trySandbox(() => coreApi.listNamespacedPod({
480
+ namespace: config.namespace,
481
+ labelSelector: `cortex/sandbox-id=${sandboxId}`,
482
+ }), (status, cause) => ({
483
+ type: "liveness_failed",
484
+ op: "k8s.isAlive",
485
+ sandboxId,
486
+ status,
487
+ cause,
488
+ }))
489
+ .map((pods) => {
490
+ const pod = pods.items[0];
491
+ if (!pod)
492
+ return { alive: false, oomKilled: false };
493
+ const phase = pod.status?.phase;
494
+ // `Pending` and `Running` are alive; `Succeeded`, `Failed`,
495
+ // `Unknown` are dead. `Pending` covers startup.
496
+ const alive = phase === "Pending" || phase === "Running";
497
+ const oomKilled = !alive &&
498
+ (pod.status?.containerStatuses ?? []).some((cs) => cs.state?.terminated?.reason === "OOMKilled" || cs.lastState?.terminated?.reason === "OOMKilled");
499
+ return { alive, oomKilled };
500
+ })
501
+ .orElse((e) =>
502
+ // A 404 means the pod is gone — observably dead.
503
+ statusOf(e) === 404 ? ok({ alive: false, oomKilled: false }) : err(e));
504
+ }
489
505
  }
490
506
  /**
491
507
  * Best-effort Job removal after a failed create. The original failure is what
@@ -5,9 +5,10 @@
5
5
  * Distinct from the liveness watchdog (`sandbox/watchdog.ts`): the watchdog
6
6
  * sweeps registry→cluster and unblocks stuck recvs; the reaper sweeps
7
7
  * cluster→registry and garbage-collects. It lists every Cortex-managed sandbox
8
- * machine in the configured namespace, reads each machine's owner-workflow-id
9
- * label, and reaps any whose owning workflow is terminal/missing — tearing the
10
- * machine down and reconciling its step row.
8
+ * machine in the configured namespace, resolves each machine's recorded owner
9
+ * workflow, and reaps the ones whose owner is terminal — tearing the machine
10
+ * down and reconciling its step row. A machine whose owner does not resolve at
11
+ * all is torn down only once a liveness probe says it is dead.
11
12
  *
12
13
  * Why a sweep and not teardown-on-cancel: DBOS forbids running a step in a
13
14
  * cancelled workflow, so the body cannot tear down on the cancel path; and only
@@ -22,14 +23,18 @@ import type { Pool } from "pg";
22
23
  import type { Logger } from "../lib/logger.js";
23
24
  import type { SandboxClient } from "./client.js";
24
25
  import type { ManagedSandbox } from "./types.js";
25
- export type ReapDecision = "leave" | "reap";
26
+ export type ReapDecision = "leave" | "reap" | "reap-if-dead";
26
27
  /**
27
- * Decide whether a managed machine is reapable. The owning workflow's status
28
- * is authoritative — DBOS writes it at enqueue, *before* the machine exists, so
29
- * a just-spawned machine is always observably in-flight and never mistaken for
30
- * an orphan. Only the no-status case (no owner label, or the workflow is gone)
31
- * falls back to a creation-time grace, the one window the status signal can't
32
- * cover.
28
+ * Decide whether a managed machine is reapable. A resolved owner status is
29
+ * authoritative in both directions — DBOS records it at enqueue, *before* the
30
+ * machine exists, so an in-flight workflow's machine is always observably
31
+ * in-flight and a terminal one's is always reapable.
32
+ *
33
+ * A missing status is authoritative for neither. It means the machine records
34
+ * no owner, or the id it records did not resolve, or DBOS pruned the row —
35
+ * and age cannot separate those from a genuine orphan. So the decision defers
36
+ * to a liveness probe: only an observably dead machine is torn down. Reaping a
37
+ * live machine on age alone is what this branch exists to prevent.
33
38
  */
34
39
  export declare function classifyManaged(sb: ManagedSandbox, status: {
35
40
  status: string;
@@ -38,11 +43,11 @@ export declare function classifyManaged(sb: ManagedSandbox, status: {
38
43
  export declare function terminalStepStatus(workflowStatus: string | null): "canceled" | "failed" | "completed";
39
44
  export interface ReaperDeps {
40
45
  pool: Pool;
41
- sandboxClient: Pick<SandboxClient, "listManagedSandboxes" | "teardownById">;
46
+ sandboxClient: Pick<SandboxClient, "listManagedSandboxes" | "teardownById" | "isAliveById">;
42
47
  getStatus: (workflowId: string) => Promise<{
43
48
  status: string;
44
49
  } | null>;
45
- /** Creation-time grace for label-less / orphaned machines. */
50
+ /** Creation-time grace before an unattributable machine is even probed. */
46
51
  graceMs?: number;
47
52
  /** Wall-clock ms; injected for tests. */
48
53
  nowMs?: () => number;
@@ -53,6 +58,12 @@ export interface ReapSummary {
53
58
  reapedCount: number;
54
59
  rowsReconciled: number;
55
60
  leftCount: number;
61
+ /**
62
+ * Machines past the grace whose owner did not resolve but which are still
63
+ * alive, so they were left standing. A non-zero value is a standing leak
64
+ * *and* a bug signal — it means owner ids are not round-tripping.
65
+ */
66
+ liveUnattributed: number;
56
67
  }
57
68
  /**
58
69
  * One cluster→registry sweep. Lists managed machines, classifies each against
@@ -62,7 +73,7 @@ export interface ReapSummary {
62
73
  export declare function reapOnce(deps: ReaperDeps): Promise<ReapSummary>;
63
74
  export interface RegisterReaperDeps {
64
75
  pool: Pool;
65
- sandboxClient: Pick<SandboxClient, "listManagedSandboxes" | "teardownById">;
76
+ sandboxClient: Pick<SandboxClient, "listManagedSandboxes" | "teardownById" | "isAliveById">;
66
77
  graceMs?: number;
67
78
  logger?: Logger;
68
79
  }
@@ -5,9 +5,10 @@
5
5
  * Distinct from the liveness watchdog (`sandbox/watchdog.ts`): the watchdog
6
6
  * sweeps registry→cluster and unblocks stuck recvs; the reaper sweeps
7
7
  * cluster→registry and garbage-collects. It lists every Cortex-managed sandbox
8
- * machine in the configured namespace, reads each machine's owner-workflow-id
9
- * label, and reaps any whose owning workflow is terminal/missing — tearing the
10
- * machine down and reconciling its step row.
8
+ * machine in the configured namespace, resolves each machine's recorded owner
9
+ * workflow, and reaps the ones whose owner is terminal — tearing the machine
10
+ * down and reconciling its step row. A machine whose owner does not resolve at
11
+ * all is torn down only once a liveness probe says it is dead.
11
12
  *
12
13
  * Why a sweep and not teardown-on-cancel: DBOS forbids running a step in a
13
14
  * cancelled workflow, so the body cannot tear down on the cancel path; and only
@@ -33,20 +34,22 @@ const DEFAULT_GRACE_MS = 10 * 60_000;
33
34
  */
34
35
  const ACTIVE_WORKFLOW_STATUSES = new Set(["PENDING", "ENQUEUED", "RUNNING"]);
35
36
  /**
36
- * Decide whether a managed machine is reapable. The owning workflow's status
37
- * is authoritative — DBOS writes it at enqueue, *before* the machine exists, so
38
- * a just-spawned machine is always observably in-flight and never mistaken for
39
- * an orphan. Only the no-status case (no owner label, or the workflow is gone)
40
- * falls back to a creation-time grace, the one window the status signal can't
41
- * cover.
37
+ * Decide whether a managed machine is reapable. A resolved owner status is
38
+ * authoritative in both directions — DBOS records it at enqueue, *before* the
39
+ * machine exists, so an in-flight workflow's machine is always observably
40
+ * in-flight and a terminal one's is always reapable.
41
+ *
42
+ * A missing status is authoritative for neither. It means the machine records
43
+ * no owner, or the id it records did not resolve, or DBOS pruned the row —
44
+ * and age cannot separate those from a genuine orphan. So the decision defers
45
+ * to a liveness probe: only an observably dead machine is torn down. Reaping a
46
+ * live machine on age alone is what this branch exists to prevent.
42
47
  */
43
48
  export function classifyManaged(sb, status, nowMs, graceMs) {
44
- if (status && ACTIVE_WORKFLOW_STATUSES.has(status.status))
45
- return "leave";
46
49
  if (status)
47
- return "reap";
50
+ return ACTIVE_WORKFLOW_STATUSES.has(status.status) ? "leave" : "reap";
48
51
  const ageMs = sb.createdAtMs == null ? Number.POSITIVE_INFINITY : nowMs - sb.createdAtMs;
49
- return ageMs >= graceMs ? "reap" : "leave";
52
+ return ageMs >= graceMs ? "reap-if-dead" : "leave";
50
53
  }
51
54
  /** Map the owning workflow's terminal status to the step row's terminal status. */
52
55
  export function terminalStepStatus(workflowStatus) {
@@ -73,12 +76,41 @@ export async function reapOnce(deps) {
73
76
  let reapedCount = 0;
74
77
  let rowsReconciled = 0;
75
78
  let leftCount = 0;
79
+ let liveUnattributed = 0;
76
80
  for (const sb of managed) {
77
81
  const status = sb.ownerWorkflowId ? await deps.getStatus(sb.ownerWorkflowId) : null;
78
- if (classifyManaged(sb, status, nowMs, graceMs) === "leave") {
82
+ const decision = classifyManaged(sb, status, nowMs, graceMs);
83
+ if (decision === "leave") {
79
84
  leftCount++;
80
85
  continue;
81
86
  }
87
+ if (decision === "reap-if-dead") {
88
+ // A recorded id that resolves to nothing is an anomaly worth naming: either
89
+ // DBOS pruned the row or the id never reached the machine intact.
90
+ if (sb.ownerWorkflowId) {
91
+ logger.warn("owner workflow id resolved to no workflow", { sandboxId: sb.sandboxId, ownerWorkflowId: sb.ownerWorkflowId });
92
+ }
93
+ let liveness;
94
+ try {
95
+ liveness = await deps.sandboxClient.isAliveById(sb.sandboxId);
96
+ }
97
+ catch (err) {
98
+ // A probe that cannot answer is not a dead machine. Leave it; the next
99
+ // sweep re-probes.
100
+ logger.warn("liveness probe threw — leaving this machine", { sandboxId: sb.sandboxId, ...logger.errorFields(err) });
101
+ leftCount++;
102
+ continue;
103
+ }
104
+ if (liveness.alive) {
105
+ logger.warn("machine has no resolvable owner but is still alive — left standing", {
106
+ sandboxId: sb.sandboxId,
107
+ ownerWorkflowId: sb.ownerWorkflowId,
108
+ });
109
+ leftCount++;
110
+ liveUnattributed++;
111
+ continue;
112
+ }
113
+ }
82
114
  try {
83
115
  await deps.sandboxClient.teardownById(sb.sandboxId);
84
116
  reapedCount++;
@@ -98,6 +130,7 @@ export async function reapOnce(deps) {
98
130
  reapedCount,
99
131
  rowsReconciled,
100
132
  leftCount,
133
+ liveUnattributed,
101
134
  };
102
135
  // Spread: an interface has no implicit index signature, so it needs widening to `LogFields`.
103
136
  logger.info("sweep completed", { ...summary });
@@ -229,8 +229,8 @@ export interface CreateSandboxMeta {
229
229
  /** The first `execId` that will fire against this sandbox; nullable for
230
230
  * early-create flows where the workflow mints the first execId later. */
231
231
  execId?: string | null;
232
- /** Owning DBOS child workflow id (`"${parentRunId}-${N}"`). Stamped onto the
233
- * sandbox machine as the `cortex/owner-workflow-id` label so the reaper can
232
+ /** Owning DBOS child workflow id (`"${parentRunId}-${N}"`). Recorded verbatim
233
+ * on the sandbox machine under `cortex/owner-workflow-id` so the reaper can
234
234
  * map a cluster-side machine back to its workflow and check liveness. */
235
235
  childWorkflowId: string;
236
236
  /** Backend-specific extras carried through to the per-backend impl. */
@@ -270,9 +270,10 @@ export interface SandboxIdentity {
270
270
  /**
271
271
  * A sandbox machine the backend is running that Cortex *manages*
272
272
  * (`app.kubernetes.io/managed-by=cortex`), enumerated by the reaper for the
273
- * cluster→registry sweep (see the harness-sandbox-exec spec). The `ownerWorkflowId` comes from the
274
- * `cortex/owner-workflow-id` label and may be null for legacy/label-less
275
- * machines, which are only reaped past a creation-time grace.
273
+ * cluster→registry sweep (see the harness-sandbox-exec spec). `ownerWorkflowId`
274
+ * is the id as minted, so it is usable as a DBOS lookup key; null means the
275
+ * machine records no owner, and such a machine is reaped only past a
276
+ * creation-time grace and only when observably dead.
276
277
  */
277
278
  export interface ManagedSandbox {
278
279
  sandboxId: string;
@@ -47,6 +47,53 @@ export declare const OrganismSchema: z.ZodObject<{
47
47
  notes: z.ZodOptional<z.ZodString>;
48
48
  }, z.core.$strip>;
49
49
  export type Organism = z.infer<typeof OrganismSchema>;
50
+ /** Kinds an output may carry. A tree needing more than this is not being grouped. */
51
+ export declare const MAX_KINDS = 30;
52
+ /** Axes an output may carry — an experimental design with more varying dimensions is vanishingly rare. */
53
+ export declare const MAX_AXES = 8;
54
+ /**
55
+ * Individually described files an output may carry. `files` holds NOTABLE SINGLETONS
56
+ * — the metadata sheet, the README, the paper, an outlier that fits no kind — not the
57
+ * dataset's contents.
58
+ */
59
+ export declare const MAX_NOTABLE_FILES = 50;
60
+ /**
61
+ * A repeating set of files that are the same sort of thing.
62
+ *
63
+ * A kind is a claim about MEANING, so only the agent can author one: the input scan
64
+ * reports shapes (these filenames differ only here) and cannot establish that the
65
+ * files are the same sort of thing. Kinds need not correspond one-to-one with observed
66
+ * shapes — one kind may span several shapes, and one shape may split into several
67
+ * kinds.
68
+ *
69
+ * A singleton is a kind of `count` 1. There is one concept with a count, not two
70
+ * concepts with a threshold between them, so degenerate datasets need no special case.
71
+ */
72
+ export declare const KindSchema: z.ZodObject<{
73
+ name: z.ZodString;
74
+ memberRepresents: z.ZodString;
75
+ description: z.ZodString;
76
+ count: z.ZodNumber;
77
+ pathPattern: z.ZodString;
78
+ format: z.ZodOptional<z.ZodString>;
79
+ axisLabels: z.ZodOptional<z.ZodArray<z.ZodString>>;
80
+ }, z.core.$strip>;
81
+ export type ProfilerKind = z.infer<typeof KindSchema>;
82
+ /**
83
+ * What varies across a kind's members.
84
+ *
85
+ * The scan reports that a filename position varies and which values it takes; what the
86
+ * variation IS — a subject, a timepoint, a treatment arm, a chromosome shard — is not
87
+ * derivable from the values, so the label is required and agent-supplied. An axis
88
+ * evident from a metadata sheet but not from filenames belongs here too.
89
+ */
90
+ export declare const AxisSchema: z.ZodObject<{
91
+ label: z.ZodString;
92
+ cardinality: z.ZodNumber;
93
+ exampleValues: z.ZodOptional<z.ZodArray<z.ZodString>>;
94
+ description: z.ZodOptional<z.ZodString>;
95
+ }, z.core.$strip>;
96
+ export type ProfilerAxis = z.infer<typeof AxisSchema>;
50
97
  export declare const ProfilerOutputSchema: z.ZodObject<{
51
98
  files: z.ZodArray<z.ZodObject<{
52
99
  path: z.ZodString;
@@ -59,6 +106,21 @@ export declare const ProfilerOutputSchema: z.ZodObject<{
59
106
  warnings: z.ZodOptional<z.ZodArray<z.ZodString>>;
60
107
  metrics: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
61
108
  }, z.core.$strip>>;
109
+ kinds: z.ZodArray<z.ZodObject<{
110
+ name: z.ZodString;
111
+ memberRepresents: z.ZodString;
112
+ description: z.ZodString;
113
+ count: z.ZodNumber;
114
+ pathPattern: z.ZodString;
115
+ format: z.ZodOptional<z.ZodString>;
116
+ axisLabels: z.ZodOptional<z.ZodArray<z.ZodString>>;
117
+ }, z.core.$strip>>;
118
+ axes: z.ZodOptional<z.ZodArray<z.ZodObject<{
119
+ label: z.ZodString;
120
+ cardinality: z.ZodNumber;
121
+ exampleValues: z.ZodOptional<z.ZodArray<z.ZodString>>;
122
+ description: z.ZodOptional<z.ZodString>;
123
+ }, z.core.$strip>>>;
62
124
  analysisSummary: z.ZodString;
63
125
  domain: z.ZodString;
64
126
  subtype: z.ZodOptional<z.ZodString>;
@@ -24,7 +24,7 @@ export const ProfilerFileSchema = z.object({
24
24
  metrics: z
25
25
  .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
26
26
  .optional()
27
- .describe("Format-specific profiling metrics as flat key-value pairs (e.g. sparsity, medianLibrarySize, tiTvRatio, gcContent, missingRate)"),
27
+ .describe("Identity-establishing facts as flat key-value pairs (e.g. sparsity, delimiter, normalizationState, missingRate)"),
28
28
  });
29
29
  /**
30
30
  * Where an extracted subject fact came from. Used so downstream agents can
@@ -48,8 +48,74 @@ export const OrganismSchema = z.object({
48
48
  "low: inferred from gene IDs or other indirect signals, OR sources disagree (in which case also record the conflict in qualityAssessment.concerns)."),
49
49
  notes: z.string().optional().describe("Brief note when source is 'inferred' or confidence is 'low' (one sentence)."),
50
50
  });
51
+ /** Kinds an output may carry. A tree needing more than this is not being grouped. */
52
+ export const MAX_KINDS = 30;
53
+ /** Axes an output may carry — an experimental design with more varying dimensions is vanishingly rare. */
54
+ export const MAX_AXES = 8;
55
+ /**
56
+ * Individually described files an output may carry. `files` holds NOTABLE SINGLETONS
57
+ * — the metadata sheet, the README, the paper, an outlier that fits no kind — not the
58
+ * dataset's contents.
59
+ */
60
+ export const MAX_NOTABLE_FILES = 50;
61
+ /**
62
+ * A repeating set of files that are the same sort of thing.
63
+ *
64
+ * A kind is a claim about MEANING, so only the agent can author one: the input scan
65
+ * reports shapes (these filenames differ only here) and cannot establish that the
66
+ * files are the same sort of thing. Kinds need not correspond one-to-one with observed
67
+ * shapes — one kind may span several shapes, and one shape may split into several
68
+ * kinds.
69
+ *
70
+ * A singleton is a kind of `count` 1. There is one concept with a count, not two
71
+ * concepts with a threshold between them, so degenerate datasets need no special case.
72
+ */
73
+ export const KindSchema = z.object({
74
+ name: z.string().describe("Short label for this set, e.g. 'per-patient variant calls', 'reference transcriptome'."),
75
+ memberRepresents: z
76
+ .string()
77
+ .describe("What ONE member of this set represents — 'one patient's somatic variant calls', 'one sequencing lane of one sample'. " +
78
+ "This is your grouping decision stated outright, and it is NOT the description: it cannot be answered by " +
79
+ "restating the shape the scan observed."),
80
+ description: z.string().describe("What this set contains and the role it plays in the analysis."),
81
+ count: z.number().int().describe("How many files are in this set."),
82
+ pathPattern: z
83
+ .string()
84
+ .describe("Glob matching this set's members, relative to the analysis root (e.g. 'data/inputs/vcf/*.vcf.gz'). " +
85
+ "Coverage is computed by matching this against the scanned tree, so a pattern that matches nothing reads as an uncovered kind."),
86
+ format: z.string().optional().describe("File format shared by the members (VCF, h5ad, CSV, …)."),
87
+ axisLabels: z
88
+ .array(z.string())
89
+ .max(MAX_AXES)
90
+ .optional()
91
+ .describe("Labels of the axes (see `axes`) that vary across this set's members — the design this kind participates in."),
92
+ });
93
+ /**
94
+ * What varies across a kind's members.
95
+ *
96
+ * The scan reports that a filename position varies and which values it takes; what the
97
+ * variation IS — a subject, a timepoint, a treatment arm, a chromosome shard — is not
98
+ * derivable from the values, so the label is required and agent-supplied. An axis
99
+ * evident from a metadata sheet but not from filenames belongs here too.
100
+ */
101
+ export const AxisSchema = z.object({
102
+ label: z.string().describe("What this dimension is: 'patient', 'timepoint', 'treatment arm', 'chromosome shard', 'replicate'."),
103
+ cardinality: z.number().int().describe("How many distinct values this dimension takes."),
104
+ exampleValues: z.array(z.string()).max(20).optional().describe("A few observed values, for orientation."),
105
+ description: z.string().optional().describe("Anything a downstream planner needs about this dimension (ordering, pairing, imbalance)."),
106
+ });
51
107
  export const ProfilerOutputSchema = z.object({
52
- files: z.array(ProfilerFileSchema),
108
+ /**
109
+ * Notable singletons only. The workspace filesystem is the authoritative file list
110
+ * — `list_files`, `grep`, and the vector index all read the live tree — so a record
111
+ * per input file would store a stale duplicate of something that cannot go wrong.
112
+ */
113
+ files: z.array(ProfilerFileSchema).max(MAX_NOTABLE_FILES),
114
+ kinds: z
115
+ .array(KindSchema)
116
+ .max(MAX_KINDS)
117
+ .describe("The repeating sets this dataset is made of. Group; do not enumerate members — the cap is a hard bound, not a target."),
118
+ axes: z.array(AxisSchema).max(MAX_AXES).optional().describe("What varies across the kinds' members — the experimental design, as far as it is observable."),
53
119
  analysisSummary: z.string().describe("Narrative overview of the dataset — structure, content, analytical potential, and limitations"),
54
120
  domain: z
55
121
  .string()
@@ -5,98 +5,17 @@
5
5
  */
6
6
  import type { ResultAsync } from "neverthrow";
7
7
  import { type DbError } from "../lib/db-result.js";
8
- import type { Organism } from "../schemas/data-profile-schemas.js";
8
+ import type { DataProfileLifecycleStatus, DataProfileResult } from "../contracts/data-profile.js";
9
9
  import type { Querier } from "./db.js";
10
10
  /**
11
- * One profiled input file's **drift signature**. `fileId` is a path identity the
12
- * embedder derives (two files at the same path share it regardless of content),
13
- * so `size` + `mtimeMs` are what let a consumer notice that the bytes behind a
14
- * path changed. The harness treats all three as opaque labels: it persists them
15
- * verbatim and never stats a source file or compares signatures itself.
11
+ * The persisted profile's shape lives in `contracts/` — it is read by consumers outside
12
+ * this package (a host route, a UI), and they must not import this module, which pulls in
13
+ * `pg` and the whole ledger query surface, to obtain a type. Re-exported here so ledger
14
+ * call sites keep one import.
16
15
  */
17
- export interface DataProfileInputFile {
18
- fileId: string;
19
- size: number;
20
- mtimeMs: number;
21
- }
22
- /**
23
- * One profiled file's persisted record.
24
- *
25
- * `path` + `description` are the original pair every snapshot carries. Everything
26
- * below them is **optional on read** for the same reason `DataProfileResult.inputFiles`
27
- * is: a snapshot written before the record was widened carries only the pair, and a
28
- * reader must render that row rather than reject it. A writer fills in whatever the
29
- * profiler reported.
30
- */
31
- export interface DataProfileFile {
32
- path: string;
33
- description: string;
34
- /** Semantic data type (count-matrix, variants, clinical-metadata, document, …). */
35
- dataType?: string;
36
- /** File format (CSV, h5ad, VCF, BAM, …). */
37
- format?: string;
38
- rows?: number | null;
39
- cols?: number | null;
40
- /** Searchable labels the profiler attached. */
41
- tags?: string[];
42
- /** Quality issues specific to this file. */
43
- warnings?: string[];
44
- /** Flat format-specific metrics (sparsity, medianLibrarySize, tiTvRatio, …). */
45
- metrics?: Record<string, string | number | boolean>;
46
- }
47
- /** Dataset-wide quality findings the profiler recorded. */
48
- export interface DataProfileQualityAssessment {
49
- concerns: string[];
50
- strengths: string[];
51
- }
52
- /**
53
- * The profile snapshot `completeDataProfile` persists and `loadDataProfileStatus` reads back.
54
- *
55
- * This row is the **only** durable home of the profile: the profiler's `runs/data-profile/`
56
- * scratch tree is wiped when profiling completes, so there is no file to fall back on. It
57
- * therefore carries the profiler's full finding, not a summary of it — anything dropped here
58
- * is destroyed, and a downstream agent can only recover it by re-reading the raw input files.
59
- *
60
- * Every field past `summary` / `files` / `inputFileIds` / `profiledAt` is **optional on read**:
61
- * a snapshot written before the record was widened carries only those four, and a reader must
62
- * render it rather than throw. There is no parse at the read boundary (the jsonb column is cast
63
- * straight to this type), so optionality *is* the compatibility mechanism — exactly as it
64
- * already is for `inputFiles`.
65
- */
66
- export interface DataProfileResult {
67
- summary: string;
68
- files: DataProfileFile[];
69
- /** Which files the profile covered — the audit record. */
70
- inputFileIds: string[];
71
- /**
72
- * Whether the same bytes were covered — the drift comparand. Optional on read:
73
- * a snapshot written before this field existed carries only `inputFileIds`, and
74
- * a consumer treats the absence as drift (re-profiling repairs it), exactly as
75
- * it already treats a wholly absent `result`.
76
- */
77
- inputFiles?: DataProfileInputFile[];
78
- profiledAt: string;
79
- /** Scientific domain (transcriptomics, cheminformatics, clinical, …). */
80
- domain?: string;
81
- /** Specific subtype within the domain (bulk-rna-seq, single-cell, LC-MS/MS, …). */
82
- subtype?: string;
83
- /**
84
- * Subject organism with its provenance and confidence. Explicit `null` is a real,
85
- * distinct value — the profiler looked and no input identified an organism — as
86
- * opposed to absent, which means the snapshot predates this field.
87
- */
88
- organism?: Organism | null;
89
- tissue?: string | null;
90
- cellType?: string | null;
91
- condition?: string | null;
92
- /** Public dataset accessions found in any input (GSE/SRP/PRJNA/E-MTAB/phs/EGAS). */
93
- accessions?: string[];
94
- /** Conditions, groups, comparisons, replicates, pairing. */
95
- experimentalDesign?: string;
96
- qualityAssessment?: DataProfileQualityAssessment;
97
- }
16
+ export type { DataProfileAxis, DataProfileCoverage, DataProfileFile, DataProfileInputFile, DataProfileInputSignature, DataProfileKind, DataProfileOrganism, DataProfileQualityAssessment, DataProfileLifecycleStatus, DataProfileResult, DataProfileSubjectSource, } from "../contracts/data-profile.js";
98
17
  export interface DataProfileStatus {
99
- status: "pending" | "running" | "completed" | "failed";
18
+ status: DataProfileLifecycleStatus;
100
19
  error: string | null;
101
20
  startedAt: string | null;
102
21
  completedAt: string | null;
@@ -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, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
24
- export type { DataProfileStatus, DataProfileResult, DataProfileInputFile, DataProfileFile, DataProfileQualityAssessment } from "./data-profile.js";
24
+ export type { DataProfileStatus, DataProfileResult, DataProfileInputFile, DataProfileFile, DataProfileQualityAssessment, 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";