@inflexa-ai/harness 0.36.4 → 0.38.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 (96) hide show
  1. package/README.md +1 -2
  2. package/dist/auth/local-run-authorizer.js +5 -4
  3. package/dist/auth/types.d.ts +28 -9
  4. package/dist/auth/types.js +5 -3
  5. package/dist/billing/noop-run-charge.js +3 -2
  6. package/dist/billing/noop-usage-recorder.js +2 -1
  7. package/dist/billing/run-charge.d.ts +18 -5
  8. package/dist/billing/run-charge.js +5 -1
  9. package/dist/billing/usage-recorder.d.ts +11 -9
  10. package/dist/billing/usage-recorder.js +2 -3
  11. package/dist/contracts/chat-events.d.ts +4 -1
  12. package/dist/contracts/chat-parts.d.ts +1 -1
  13. package/dist/contracts/message.d.ts +15 -0
  14. package/dist/contracts/schemas/chat-parts.d.ts +4 -4
  15. package/dist/execution/artifact-registration.d.ts +6 -1
  16. package/dist/execution/artifact-registration.js +12 -4
  17. package/dist/execution/artifact-registry.d.ts +10 -3
  18. package/dist/execution/noop-artifact-registry.js +3 -4
  19. package/dist/execution/post-step-pipeline.d.ts +14 -3
  20. package/dist/execution/post-step-pipeline.js +21 -8
  21. package/dist/execution/run-authorizer.d.ts +5 -3
  22. package/dist/execution/run-canceler.js +4 -20
  23. package/dist/index.d.ts +13 -7
  24. package/dist/index.js +12 -6
  25. package/dist/lib/hooks.d.ts +49 -0
  26. package/dist/lib/hooks.js +39 -0
  27. package/dist/lib/result.d.ts +22 -11
  28. package/dist/lib/result.js +24 -14
  29. package/dist/loop/run-agent.d.ts +2 -2
  30. package/dist/loop/run-agent.js +5 -4
  31. package/dist/memory/conversation-display-replay.js +14 -1
  32. package/dist/memory/thread-history.d.ts +24 -0
  33. package/dist/memory/thread-history.js +19 -6
  34. package/dist/provenance/seam.d.ts +1 -1
  35. package/dist/providers/ai-sdk.d.ts +43 -5
  36. package/dist/providers/ai-sdk.js +55 -52
  37. package/dist/providers/anthropic.d.ts +2 -3
  38. package/dist/providers/anthropic.js +2 -2
  39. package/dist/providers/embedding.d.ts +13 -7
  40. package/dist/providers/embedding.js +26 -6
  41. package/dist/providers/errors.d.ts +67 -37
  42. package/dist/providers/errors.js +57 -58
  43. package/dist/providers/request-headers.d.ts +18 -0
  44. package/dist/providers/request-headers.js +22 -0
  45. package/dist/report-model/production-resolver.d.ts +5 -2
  46. package/dist/report-model/production-resolver.js +12 -2
  47. package/dist/runtime/assemble.d.ts +3 -2
  48. package/dist/runtime/dbos.d.ts +4 -3
  49. package/dist/runtime/dbos.js +18 -3
  50. package/dist/runtime/result-serialization.d.ts +29 -0
  51. package/dist/runtime/result-serialization.js +45 -0
  52. package/dist/sandbox/client.d.ts +11 -3
  53. package/dist/sandbox/create-sandbox.d.ts +5 -2
  54. package/dist/sandbox/create-sandbox.js +51 -25
  55. package/dist/sandbox/docker-client.d.ts +4 -3
  56. package/dist/sandbox/docker-client.js +23 -31
  57. package/dist/sandbox/k8s-client.d.ts +4 -13
  58. package/dist/sandbox/k8s-client.js +28 -61
  59. package/dist/sandbox/labels.d.ts +34 -0
  60. package/dist/sandbox/labels.js +47 -0
  61. package/dist/sandbox/mount-plan.d.ts +3 -1
  62. package/dist/sandbox/mount-plan.js +9 -0
  63. package/dist/sandbox/sandbox-error.d.ts +36 -13
  64. package/dist/sandbox/sandbox-error.js +32 -17
  65. package/dist/sandbox/types.d.ts +13 -9
  66. package/dist/schemas/validate-plan.js +13 -0
  67. package/dist/schemas/workflow-state.d.ts +4 -4
  68. package/dist/state/analyses.d.ts +4 -4
  69. package/dist/state/analyses.js +4 -4
  70. package/dist/state/init.js +11 -0
  71. package/dist/state/schema.d.ts +5 -5
  72. package/dist/tasks/data-profile.d.ts +5 -8
  73. package/dist/tasks/data-profile.js +88 -43
  74. package/dist/tasks/derive-table-exec.d.ts +10 -6
  75. package/dist/tasks/derive-table-exec.js +17 -12
  76. package/dist/tasks/extract-values.d.ts +16 -10
  77. package/dist/tasks/extract-values.js +34 -18
  78. package/dist/tools/execute-analysis.js +22 -15
  79. package/dist/tools/report-session/derive-table.d.ts +9 -1
  80. package/dist/tools/report-session/derive-table.js +25 -22
  81. package/dist/tools/research/inspect-run.d.ts +1 -1
  82. package/dist/workflows/execute-analysis.d.ts +25 -16
  83. package/dist/workflows/execute-analysis.js +223 -216
  84. package/dist/workflows/metrics.d.ts +4 -3
  85. package/dist/workflows/metrics.js +4 -3
  86. package/dist/workflows/sandbox-step.d.ts +20 -29
  87. package/dist/workflows/sandbox-step.js +71 -127
  88. package/dist/workflows/suspension.d.ts +32 -0
  89. package/dist/workflows/suspension.js +38 -0
  90. package/package.json +1 -1
  91. package/dist/billing/noop-resolver.d.ts +0 -6
  92. package/dist/billing/noop-resolver.js +0 -7
  93. package/dist/billing/resolver.d.ts +0 -64
  94. package/dist/billing/resolver.js +0 -24
  95. package/dist/loop/budget-exceeded.d.ts +0 -38
  96. package/dist/loop/budget-exceeded.js +0 -88
@@ -25,21 +25,21 @@
25
25
  * propagate untouched. `await-exec.ts` is NOT converted; `trySandbox` only
26
26
  * ever wraps the single SDK/HTTP call handed to it.
27
27
  *
28
- * The public `SandboxClient` interface keeps `Promise<T>` method signatures.
29
- * `create-sandbox.ts` is the composition seam: it builds the backend ops, maps
30
- * each `err` of an op to a `SandboxFailure`, and `unwrapOrThrow`s it, so the
31
- * seam throws the described failure with the variant on `.cause` and on
32
- * `.error`. A `catch` never sees the raw variant. (`submitExec` is the
33
- * exception — it is already its own DBOS step and throws directly, so the seam
34
- * forwards its `Promise<void>` as-is.)
35
- * Workflow consumers (`sandbox-step.ts`) already wrap each seam call in
36
- * `DBOS.runStep`, so the re-thrown error records the step as failed;
37
- * non-workflow consumers (`reaper`, `watchdog`,
38
- * `data-profile`, `run-exec`) already handle a thrown failure. They are
39
- * untouched by this wave.
28
+ * `createSandbox` of the public `SandboxClient` gives each `SandboxError` as
29
+ * an `err` value, because a refusal of the label hook must reach the spawn path
30
+ * as a value. A spawn path splits the result with `keepSuspendingRefusal`: a
31
+ * refusal with the suspend flag stays a value, and each other failure throws
32
+ * as a `SandboxFailure`.
33
+ * Each other op keeps a `Promise<T>` signature. `create-sandbox.ts` is the
34
+ * composition seam: it builds the backend ops, maps each `err` of such an op to
35
+ * a `SandboxFailure`, and `unwrapOrThrow`s it, so the seam throws the described
36
+ * failure with the variant on `.cause` and on `.error`. A `catch` never sees
37
+ * the raw variant. (`submitExec` is the exception — it is already its own DBOS
38
+ * step and throws directly, so the seam forwards its `Promise<void>` as-is.)
40
39
  */
41
40
  import { ResultAsync, err, ok } from "neverthrow";
42
41
  import { ZodError } from "zod";
42
+ import { unwrapOrThrow } from "../lib/result.js";
43
43
  const _assertDomainError = true;
44
44
  /**
45
45
  * The HTTP/driver status a backend SDK error carries. dockerode exposes it on
@@ -100,11 +100,11 @@ function causeLine(cause) {
100
100
  * on the operator surfaces.
101
101
  */
102
102
  export function describeSandboxError(e) {
103
- // `farm_unavailable` is the one variant whose head already carries its
104
- // reason, the text of the resolver. A cause line after it would put two
105
- // dashes on one row, and the throw of a resolver is in the log with its
106
- // stack. Every other variant names its reason only through the cause.
107
- const line = e.type === "farm_unavailable" ? undefined : causeLine(e.cause);
103
+ // `farm_unavailable` and `labels_refused` are the two variants whose head
104
+ // already carries its reason, the text of the resolver or of the host. A
105
+ // cause line after it would put two dashes on one row. Every other variant
106
+ // names its reason only through the cause.
107
+ const line = e.type === "farm_unavailable" || e.type === "labels_refused" ? undefined : causeLine(e.cause);
108
108
  const head = describeVariant(e);
109
109
  return line === undefined ? head : `${head} — ${line}`;
110
110
  }
@@ -123,6 +123,8 @@ function describeVariant(e) {
123
123
  return `sandbox teardown failed (${e.op}${e.sandboxId ? `: ${e.sandboxId}` : ""})`;
124
124
  case "liveness_failed":
125
125
  return `sandbox liveness probe failed (${e.op}${e.sandboxId ? `: ${e.sandboxId}` : ""})`;
126
+ case "labels_refused":
127
+ return `the label hook refused the spawn (${e.op}) — ${e.reason}`;
126
128
  case "farm_unavailable":
127
129
  return `farm unavailable (${e.op}: ${e.analysisId}) — ${e.reason}`;
128
130
  case "farm_unusable":
@@ -144,6 +146,19 @@ export class SandboxFailure extends Error {
144
146
  this.name = "SandboxFailure";
145
147
  }
146
148
  }
149
+ function isSuspendingRefusal(e) {
150
+ return e.type === "labels_refused" && e.suspend;
151
+ }
152
+ /**
153
+ * Split the result of a spawn at a DBOS boundary. Call it inside the step of
154
+ * the spawn, before the checkpoint: the cause of a backend failure does not
155
+ * survive the serialization of a `Result`.
156
+ */
157
+ export function keepSuspendingRefusal(result) {
158
+ if (result.isErr() && isSuspendingRefusal(result.error))
159
+ return err(result.error);
160
+ return ok(unwrapOrThrow(result.mapErr((e) => new SandboxFailure(e))));
161
+ }
147
162
  /**
148
163
  * Wrap a single sandbox backend call (Docker SDK op, K8s API op, or the
149
164
  * `/exec` POST). A throw becomes `err(toError(status, cause))`; the caller
@@ -8,8 +8,11 @@
8
8
  * — that one OMITS the secret, which lives only in the cached
9
9
  * `createSandbox` DBOS step output.
10
10
  */
11
+ import type { ResultAsync } from "neverthrow";
11
12
  import { z } from "zod";
13
+ import type { RunSession } from "../auth/types.js";
12
14
  import type { ResourceSpec } from "../config/resource-limits.js";
15
+ import type { GateFailure } from "../lib/hooks.js";
13
16
  import type { PackageQuery } from "./package-identity.js";
14
17
  export declare const SandboxBackend: z.ZodEnum<{
15
18
  docker: "docker";
@@ -380,11 +383,8 @@ export interface SubmitExecBody {
380
383
  stdoutByteCap?: number;
381
384
  stderrByteCap?: number;
382
385
  }
383
- /** Step-meta passed to `createSandbox` — what the registry row needs. */
384
- export interface CreateSandboxMeta {
385
- runId: string;
386
- stepId: string;
387
- analysisId: string;
386
+ /** What a spawn needs beside its session. */
387
+ export interface SandboxSpec {
388
388
  /** The first `execId` that will fire against this sandbox; nullable for
389
389
  * early-create flows where the workflow mints the first execId later. */
390
390
  execId?: string | null;
@@ -408,11 +408,15 @@ export interface CreateSandboxMeta {
408
408
  * mount, thus a run provisions exactly as before. A tail beside `readOnly` is
409
409
  * a contradiction, and the mount builders refuse it. */
410
410
  writableTail?: string;
411
- /** Host-supplied labels stamped onto the sandbox pod, verbatim. Opaque here:
412
- * the harness reads no key and no value, it only sanitizes each value into a
413
- * valid label. Absent ⇒ the pod carries the harness's own labels only. */
414
- podLabels?: Record<string, string>;
415
411
  }
412
+ /** The labels that the host adds to a sandbox. The harness stamps each one as the host gives it. */
413
+ export type SandboxLabels = Readonly<Record<string, string>>;
414
+ /**
415
+ * The sandbox label hook of the host: a gate (`lib/hooks.ts`). The sandbox
416
+ * client calls it at each spawn, on both backends, before it makes the step
417
+ * tree and before it calls a backend.
418
+ */
419
+ export type ResolveSandboxLabels = (session: RunSession) => ResultAsync<SandboxLabels, GateFailure>;
416
420
  /**
417
421
  * The identity minted for a sandbox machine *before* it is spawned — the
418
422
  * durable half of the two-step create (see the harness-sandbox-exec spec). Step 1 checkpoints this so a
@@ -14,6 +14,7 @@
14
14
  import { KNOWN_AGENT_IDS } from "../agents/sandbox-catalog.js";
15
15
  import { CycleError, DependencyError, topoSortIntoWaves } from "../execution/topo-sort.js";
16
16
  import { parseQuery } from "../sandbox/package-identity.js";
17
+ import { isLabelValue } from "../sandbox/labels.js";
17
18
  import { isSafeId, STEP_SUBDIRS, SYNTHESIS_STEP_ID } from "../workspace/paths.js";
18
19
  const KNOWN_AGENTS = new Set(KNOWN_AGENT_IDS);
19
20
  /**
@@ -157,5 +158,17 @@ export function validatePlan(plan, options) {
157
158
  `and cannot be '.' or '..' (the id becomes a workspace directory and container mount segment)`);
158
159
  }
159
160
  }
161
+ // 8. Step-id label safety. The id also becomes the value of the sandbox
162
+ // label `cortex/step-id`, and the harness stamps a label value with no
163
+ // change (sandbox-labels spec). A safe id can be too long for a label
164
+ // value, or it can start or end with a separator. Refused here, the
165
+ // planner makes the plan again; left alone, the K8s API server refuses
166
+ // the spawn of the step.
167
+ for (const step of plan.steps) {
168
+ if (isSafeId(step.id) && !isLabelValue(step.id)) {
169
+ errors.push(`Step "${step.id}" is not a valid label value — a step id has 1 to 63 characters, and it starts and ends ` +
170
+ `with a letter or a digit (the id becomes the value of the sandbox label cortex/step-id)`);
171
+ }
172
+ }
160
173
  return { valid: errors.length === 0, errors };
161
174
  }
@@ -24,10 +24,10 @@ export declare const AnalysisStepSchema: z.ZodObject<{
24
24
  packages: z.ZodOptional<z.ZodArray<z.ZodString>>;
25
25
  depends_on: z.ZodArray<z.ZodString>;
26
26
  status: z.ZodDefault<z.ZodEnum<{
27
+ failed: "failed";
27
28
  pending: "pending";
28
29
  running: "running";
29
30
  completed: "completed";
30
- failed: "failed";
31
31
  skipped: "skipped";
32
32
  }>>;
33
33
  resources: z.ZodOptional<z.ZodObject<{
@@ -61,10 +61,10 @@ export declare const AnalysisPlanSchema: z.ZodObject<{
61
61
  packages: z.ZodOptional<z.ZodArray<z.ZodString>>;
62
62
  depends_on: z.ZodArray<z.ZodString>;
63
63
  status: z.ZodDefault<z.ZodEnum<{
64
+ failed: "failed";
64
65
  pending: "pending";
65
66
  running: "running";
66
67
  completed: "completed";
67
- failed: "failed";
68
68
  skipped: "skipped";
69
69
  }>>;
70
70
  resources: z.ZodOptional<z.ZodObject<{
@@ -133,10 +133,10 @@ export declare const ContentBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
133
133
  packages: z.ZodOptional<z.ZodArray<z.ZodString>>;
134
134
  depends_on: z.ZodArray<z.ZodString>;
135
135
  status: z.ZodDefault<z.ZodEnum<{
136
+ failed: "failed";
136
137
  pending: "pending";
137
138
  running: "running";
138
139
  completed: "completed";
139
- failed: "failed";
140
140
  skipped: "skipped";
141
141
  }>>;
142
142
  resources: z.ZodOptional<z.ZodObject<{
@@ -253,10 +253,10 @@ export declare const SuspensionPayloadSchema: z.ZodObject<{
253
253
  packages: z.ZodOptional<z.ZodArray<z.ZodString>>;
254
254
  depends_on: z.ZodArray<z.ZodString>;
255
255
  status: z.ZodDefault<z.ZodEnum<{
256
+ failed: "failed";
256
257
  pending: "pending";
257
258
  running: "running";
258
259
  completed: "completed";
259
- failed: "failed";
260
260
  skipped: "skipped";
261
261
  }>>;
262
262
  resources: z.ZodOptional<z.ZodObject<{
@@ -20,12 +20,12 @@ export declare function loadAnalysisStatus(pool: Querier, resourceId: string): R
20
20
  context: string | null;
21
21
  } | null, DbError>;
22
22
  /**
23
- * Suspend an analysis due to a 402 `budget_exceeded` error.
23
+ * Mark an analysis as suspended.
24
24
  * Idempotent — no-op if already suspended.
25
25
  *
26
- * The only cause of suspension today is budget exhaustion; no reason is
27
- * persisted. If a second cause arises, add a column rather than a param so
28
- * history is queryable.
26
+ * `suspended_insufficient_funds` is the name of the one suspended state, for
27
+ * each reason of the host, thus no reason is persisted. A chat turn never calls
28
+ * it.
29
29
  */
30
30
  export declare function suspendAnalysis(pool: Querier, analysisId: string): ResultAsync<void, DbError>;
31
31
  /**
@@ -41,12 +41,12 @@ export function loadAnalysisStatus(pool, resourceId) {
41
41
  });
42
42
  }
43
43
  /**
44
- * Suspend an analysis due to a 402 `budget_exceeded` error.
44
+ * Mark an analysis as suspended.
45
45
  * Idempotent — no-op if already suspended.
46
46
  *
47
- * The only cause of suspension today is budget exhaustion; no reason is
48
- * persisted. If a second cause arises, add a column rather than a param so
49
- * history is queryable.
47
+ * `suspended_insufficient_funds` is the name of the one suspended state, for
48
+ * each reason of the host, thus no reason is persisted. A chat turn never calls
49
+ * it.
50
50
  */
51
51
  export function suspendAnalysis(pool, analysisId) {
52
52
  return tryMutation("analyses.suspendAnalysis", async () => {
@@ -270,6 +270,13 @@ CREATE INDEX IF NOT EXISTS idx_cortex_regulatory_chunks_metadata
270
270
  -- Nullable with no default: absent means that nobody measured the turn, and a
271
271
  -- zero would claim that a turn took no time. A row written before this column
272
272
  -- existed is absent for the same reason, thus the migration needs no backfill.
273
+ --
274
+ -- author names the identity that sent the user message of a turn, as the host
275
+ -- knew it at the append site. It is written on the genuine-user-start row of an
276
+ -- append only -- the row that carries display_envelope -- and never on an
277
+ -- assistant, tool, or synthetic row. Nullable with no default and no backfill:
278
+ -- absent means that no sender was recorded, which is the honest value both for a
279
+ -- row that predates the column and for a turn appended without one.
273
280
  CREATE TABLE IF NOT EXISTS messages (
274
281
  thread_id TEXT NOT NULL,
275
282
  seq BIGINT NOT NULL,
@@ -282,6 +289,7 @@ CREATE TABLE IF NOT EXISTS messages (
282
289
  tokens INTEGER NOT NULL,
283
290
  reported_usage JSONB,
284
291
  turn_duration_ms BIGINT,
292
+ author TEXT,
285
293
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
286
294
  PRIMARY KEY (thread_id, seq)
287
295
  );
@@ -547,6 +555,9 @@ export async function initCortexState(pool, injected) {
547
555
  // and every earlier message reads back with no duration — which is the
548
556
  // honest value, because nobody measured those turns.
549
557
  "ALTER TABLE messages ADD COLUMN IF NOT EXISTS turn_duration_ms BIGINT",
558
+ // Who sent the user message of the turn (see the CREATE TABLE comment).
559
+ // Purely additive: nullable with no default, thus the column rewrites no row.
560
+ "ALTER TABLE messages ADD COLUMN IF NOT EXISTS author TEXT",
550
561
  // Demote an existing JSONB envelope column to JSON (see the CREATE
551
562
  // TABLE comment). Type-guarded because ALTER ... TYPE rewrites the
552
563
  // whole table and this runs at every boot; scoped to
@@ -10,10 +10,10 @@ export declare const AnalysisStateRowSchema: z.ZodObject<{
10
10
  status: z.ZodString;
11
11
  context: z.ZodNullable<z.ZodString>;
12
12
  dataProfileStatus: z.ZodEnum<{
13
+ failed: "failed";
13
14
  pending: "pending";
14
15
  running: "running";
15
16
  completed: "completed";
16
- failed: "failed";
17
17
  }>;
18
18
  dataProfileError: z.ZodNullable<z.ZodString>;
19
19
  dataProfileStartedAt: z.ZodNullable<z.ZodString>;
@@ -46,19 +46,19 @@ export declare const ArtifactRowSchema: z.ZodObject<{
46
46
  }, z.core.$strip>;
47
47
  export type ArtifactRow = z.infer<typeof ArtifactRowSchema>;
48
48
  export declare const RunStatus: z.ZodEnum<{
49
+ failed: "failed";
49
50
  running: "running";
50
51
  completed: "completed";
51
- failed: "failed";
52
52
  partial: "partial";
53
53
  canceled: "canceled";
54
54
  suspended_insufficient_funds: "suspended_insufficient_funds";
55
55
  }>;
56
56
  export type RunStatus = z.infer<typeof RunStatus>;
57
57
  export declare const StepExecutionStatus: z.ZodEnum<{
58
+ failed: "failed";
58
59
  pending: "pending";
59
60
  running: "running";
60
61
  completed: "completed";
61
- failed: "failed";
62
62
  canceled: "canceled";
63
63
  skipped: "skipped";
64
64
  blocked: "blocked";
@@ -77,9 +77,9 @@ export declare const CortexRunRowSchema: z.ZodObject<{
77
77
  threadId: z.ZodNullable<z.ZodString>;
78
78
  workflowName: z.ZodString;
79
79
  status: z.ZodEnum<{
80
+ failed: "failed";
80
81
  running: "running";
81
82
  completed: "completed";
82
- failed: "failed";
83
83
  partial: "partial";
84
84
  canceled: "canceled";
85
85
  suspended_insufficient_funds: "suspended_insufficient_funds";
@@ -124,10 +124,10 @@ export declare const StepExecutionRowSchema: z.ZodObject<{
124
124
  wave: z.ZodNumber;
125
125
  agentId: z.ZodString;
126
126
  status: z.ZodEnum<{
127
+ failed: "failed";
127
128
  pending: "pending";
128
129
  running: "running";
129
130
  completed: "completed";
130
- failed: "failed";
131
131
  canceled: "canceled";
132
132
  skipped: "skipped";
133
133
  blocked: "blocked";
@@ -17,6 +17,7 @@
17
17
  * triggers dedup via the ledger CAS (only one `tryStart/tryRerun/tryRetry`
18
18
  * UPDATE wins); each winning attempt starts a workflow under a per-attempt id.
19
19
  */
20
+ import { type ResultAsync } from "neverthrow";
20
21
  import type { Pool } from "pg";
21
22
  import { type AuthContext, type RunSession } from "../auth/types.js";
22
23
  import type { EnvironmentStorePaths } from "../config/environment-stores.js";
@@ -25,6 +26,7 @@ import type { RunAuthorization, RunAuthorizer } from "../execution/run-authorize
25
26
  import type { StagedInput } from "../execution/staged-input.js";
26
27
  import type { SandboxAgentDeps } from "../agents/sandbox/shared.js";
27
28
  import type { BioToolKeys } from "../tools/bio/keys.js";
29
+ import { type GateRefusal } from "../lib/hooks.js";
28
30
  import type { Logger } from "../lib/logger.js";
29
31
  import type { UsageRecorder } from "../billing/usage-recorder.js";
30
32
  import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
@@ -60,13 +62,6 @@ export interface DataProfileDeps extends EnvironmentStorePaths {
60
62
  readonly skillsDir: string;
61
63
  /** LLM usage-accounting seam for the profiler agent loop; omitted falls back to the no-op recorder. */
62
64
  readonly usageRecorder?: UsageRecorder;
63
- /**
64
- * Host-supplied labels for the profiler's sandbox pod, resolved under the
65
- * profiling session. The map is opaque to the harness: it stamps each entry
66
- * and interprets none of it. Absent in a wiring that attributes nothing —
67
- * the pod then carries the harness's own labels only.
68
- */
69
- readonly resolvePodLabels?: (session: RunSession) => Promise<Record<string, string>>;
70
65
  /**
71
66
  * The farm-extension seam of the embedder. Bound, it rides into the
72
67
  * profiler agent deps, and the substrate attaches `link_packages`. Thus
@@ -187,7 +182,7 @@ export interface DataProfileTriggerParams {
187
182
  * `RunAuthorizer` seam from the opaque auth; this passes that auth straight
188
183
  * through and never inspects it.
189
184
  */
190
- export declare function authorizeDataProfile(deps: DataProfileTriggerDeps, params: DataProfileTriggerParams): Promise<RunAuthorization>;
185
+ export declare function authorizeDataProfile(deps: DataProfileTriggerDeps, params: DataProfileTriggerParams): ResultAsync<RunAuthorization, GateRefusal>;
191
186
  /**
192
187
  * Per-attempt DBOS workflow id. The `nonce` is fresh per attempt (a bare
193
188
  * `randomUUID()` minted at the trigger), so each (re-)profile of the same
@@ -227,5 +222,7 @@ export declare function triggerDataProfile(deps: DataProfileTriggerDeps, params:
227
222
  * await the workflow's completion, but a start that rejects compensates the
228
223
  * ledger (see {@link compensateStartFailure}) before re-throwing, so a caller's
229
224
  * own `.catch` still observes the error and the row never wedges at `running`.
225
+ * A refused authorization fails the row with the reason of the host, and the
226
+ * promise resolves: the row is where a caller reads the outcome.
230
227
  */
231
228
  export declare function runDataProfile(deps: DataProfileTriggerDeps, params: DataProfileTriggerParams): Promise<void>;
@@ -17,18 +17,21 @@
17
17
  * triggers dedup via the ledger CAS (only one `tryStart/tryRerun/tryRetry`
18
18
  * UPDATE wins); each winning attempt starts a workflow under a per-attempt id.
19
19
  */
20
- import { DBOS } from "@dbos-inc/dbos-sdk";
20
+ import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
21
21
  import { randomUUID } from "node:crypto";
22
- import { ok } from "neverthrow";
23
- import { forSubAgent } from "../auth/types.js";
22
+ import { err, ok } from "neverthrow";
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";
26
26
  import { durableStep } from "../loop/run-step.js";
27
27
  import { createNoopLogger } from "../lib/console-logger.js";
28
+ import { deliverNotice, passGate } from "../lib/hooks.js";
28
29
  import { unwrapOrThrow } from "../lib/result.js";
29
30
  import { defineTool } from "../tools/define-tool.js";
30
31
  import { createDetailResolver } from "../tools/detail-resolver.js";
31
32
  import { renderWorkspace } from "../prompts/briefing.js";
33
+ import { keepSuspendingRefusal } from "../sandbox/sandbox-error.js";
34
+ import { cancelSelf, suspensionOfFailure, suspensionOfRefusal, suspensionOfSpawnRefusal } from "../workflows/suspension.js";
32
35
  import { toSandboxPath } from "../workspace/paths.js";
33
36
  import { estimateDataProfileResources } from "../sandbox/estimate-data-profile-resources.js";
34
37
  import { generateExecutionId } from "../sandbox/execution-id.js";
@@ -44,7 +47,7 @@ import { scanInputTree } from "../input-scan/scan.js";
44
47
  import { PROFILE_INDEX_TYPES, buildProfileIndexEntries } from "./data-profile-index.js";
45
48
  import { absorbRecipe, renderAbsorbDelta } from "./data-profile-absorb.js";
46
49
  import { formatResolutionErrors, resolveProfileSubmission } from "./data-profile-resolve.js";
47
- import { completeDataProfile, completeEmptyDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
50
+ import { completeDataProfile, completeEmptyDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, suspendAnalysis, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
48
51
  import { elapsedSinceIso, recordRunCompleted } from "../lib/metrics.js";
49
52
  import { createProfileActivityEmitter } from "./data-profile-activity.js";
50
53
  import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.js";
@@ -139,6 +142,8 @@ async function readSetHeaders(args) {
139
142
  });
140
143
  }
141
144
  catch (err) {
145
+ if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
146
+ throw err;
142
147
  logger.warn("input-scan header readout failed (non-fatal)", logger.errorFields(err));
143
148
  return new Map();
144
149
  }
@@ -285,6 +290,21 @@ export async function runDataProfileBody(input, deps) {
285
290
  // Cortex-owned and must be revoked here.
286
291
  const { analysisId, runSession, ownsMandate = true, stagedInputs } = input; // oss-core-managed-ok
287
292
  const authorization = { runSession, ownsMandate }; // oss-core-managed-ok
293
+ const revoke = (reason) => deliverNotice(logger, "RunAuthorizer.revoke", deps.runAuthorizer.revoke(authorization, reason));
294
+ let selfCancelled = false;
295
+ // A suspension fails the row with the reason of the host, because the profile
296
+ // ledger has no suspended state and the retry path takes a `failed` row again.
297
+ const suspendProfile = async (suspension) => {
298
+ logger.warn("profile suspended", { reason: suspension.reason });
299
+ settleTerminalWrite(logger, analysisId, unwrapOrThrow(await failDataProfile(deps.pool, analysisId, suspension.reason)), "failed");
300
+ await revoke("data-profile-suspended");
301
+ unwrapOrThrow(await suspendAnalysis(deps.pool, analysisId));
302
+ await activity.failed(suspension.reason);
303
+ if (DBOS.workflowID === undefined)
304
+ return;
305
+ selfCancelled = true;
306
+ await cancelSelf("self-cancel-suspended");
307
+ };
288
308
  // The profile's activity channel. `DBOS.writeStream` is body-only, so the write is bound here
289
309
  // while every phase and phrase lives in the emitter — the phrases are the observable contract of
290
310
  // this capability, and a body that composed its own strings is a body they can drift from.
@@ -318,7 +338,7 @@ export async function runDataProfileBody(input, deps) {
318
338
  // empty-manifest path invisible to the same monitoring that watches every other.
319
339
  logProfileMonitoring(logger, { dimensions: [], probes: [], repairRounds: 0, absorb: "none" });
320
340
  settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId)), "completed");
321
- await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
341
+ await revoke("data-profile-completed");
322
342
  // This is a terminal completion like any other, so it reports one — a consumer watching
323
343
  // an empty-manifest profile sees it settle rather than seeing the stream simply stop.
324
344
  await activity.complete();
@@ -371,7 +391,7 @@ export async function runDataProfileBody(input, deps) {
371
391
  await indexProfile({ deps, analysisId, session: runSession, record, scan, logger });
372
392
  logProfileMonitoring(logger, { resolution, dimensions: [], probes: [], repairRounds: 0, absorb: "none" });
373
393
  settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, record)), "completed");
374
- await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
394
+ await revoke("data-profile-completed");
375
395
  await activity.complete();
376
396
  return;
377
397
  }
@@ -397,7 +417,7 @@ export async function runDataProfileBody(input, deps) {
397
417
  absorb: absorb.kind,
398
418
  });
399
419
  settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, absorbed)), "completed");
400
- await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
420
+ await revoke("data-profile-completed");
401
421
  await activity.complete();
402
422
  return;
403
423
  }
@@ -408,27 +428,10 @@ export async function runDataProfileBody(input, deps) {
408
428
  // from the agent loop would leave the longest wait of all unreported — the same defect the
409
429
  // run panel's activity readout was built to remove.
410
430
  await activity.sandboxInit();
411
- let podLabels;
412
- if (deps.resolvePodLabels) {
413
- try {
414
- podLabels = await deps.resolvePodLabels(childSession);
415
- }
416
- catch (err) {
417
- logger.warn("pod-label resolution failed", logger.errorFields(err));
418
- }
419
- // A wired resolver that yields nothing is the one loud case: the host
420
- // asked for attribution and the pod spawns without it.
421
- if (!podLabels || Object.keys(podLabels).length === 0)
422
- logger.warn("sandbox spawned with no pod labels");
423
- }
424
- const sandbox = await deps.sandboxClient.createSandbox({
425
- runId: DATA_PROFILE_RUN_LITERAL,
426
- stepId: DATA_PROFILE_STEP_LITERAL,
427
- analysisId,
428
- childWorkflowId: workflowId,
429
- resources: estimateDataProfileResources(stagedInputs),
430
- podLabels,
431
- }, mintSandboxIdentity(DATA_PROFILE_RUN_LITERAL));
431
+ const spawned = keepSuspendingRefusal(await deps.sandboxClient.createSandbox(forStep(runSession, DATA_PROFILE_STEP_LITERAL), { childWorkflowId: workflowId, resources: estimateDataProfileResources(stagedInputs) }, mintSandboxIdentity(DATA_PROFILE_RUN_LITERAL)));
432
+ if (spawned.isErr())
433
+ return await suspendProfile(suspensionOfSpawnRefusal(spawned.error));
434
+ const sandbox = spawned.value;
432
435
  try {
433
436
  // Checkpointed clock, not `Date.now()`: `awaitExec` gates on this absolute
434
437
  // deadline and its recovery-pull is a durable step, so a wall-clock deadline
@@ -545,6 +548,7 @@ export async function runDataProfileBody(input, deps) {
545
548
  runStep: durableStep,
546
549
  resolved: () => accepted !== null,
547
550
  usageRecorder: deps.usageRecorder,
551
+ isFatalLoopError: (err) => err instanceof DBOSErrors.DBOSWorkflowCancelledError,
548
552
  }, {
549
553
  tools: [submitProfileTool],
550
554
  nudge: "You stopped without calling submit_profile, so no profile was " +
@@ -574,7 +578,7 @@ export async function runDataProfileBody(input, deps) {
574
578
  // 6. Complete — store the FULL profiler finding plus the input signature for
575
579
  // staleness detection. The scratch tree is gone; this row is all that survives.
576
580
  settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, profileRecord)), "completed");
577
- await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
581
+ await revoke("data-profile-completed");
578
582
  // LAST statement of the success path, and that placement is the guarantee that exactly
579
583
  // one terminal activity is ever emitted. Everything that could still throw — the ledger
580
584
  // write above, the revoke — is already behind us, so any failure on the way here reaches
@@ -593,10 +597,15 @@ export async function runDataProfileBody(input, deps) {
593
597
  }
594
598
  }
595
599
  catch (err) {
600
+ if (selfCancelled || err instanceof DBOSErrors.DBOSWorkflowCancelledError)
601
+ throw err;
602
+ const suspension = suspensionOfFailure(err);
603
+ if (suspension !== undefined)
604
+ return await suspendProfile(suspension);
596
605
  logger.error("profile failed", logger.errorFields(err));
597
606
  const reason = profileFailureReason(err);
598
607
  settleTerminalWrite(logger, analysisId, unwrapOrThrow(await failDataProfile(deps.pool, analysisId, reason)), "failed");
599
- await deps.runAuthorizer.revoke(authorization, "data-profile-failed");
608
+ await revoke("data-profile-failed");
600
609
  // The same bounded, user-safe line the ledger receives — never the raw error, whose paths and
601
610
  // stack frames stay in the log record above. This is the only other terminal emission, so
602
611
  // reaching it means the success path did not emit one.
@@ -633,14 +642,14 @@ function settleTerminalWrite(logger, analysisId, write, status) {
633
642
  * `RunAuthorizer` seam from the opaque auth; this passes that auth straight
634
643
  * through and never inspects it.
635
644
  */
636
- export async function authorizeDataProfile(deps, params) {
645
+ export function authorizeDataProfile(deps, params) {
637
646
  const { auth, analysisId } = params;
638
- return deps.runAuthorizer.authorize({
647
+ return passGate("RunAuthorizer.authorize", deps.runAuthorizer.authorize({
639
648
  auth,
640
649
  scope: { kind: "analysis", analysisId },
641
650
  provenance: { agentId: DATA_PROFILE_AGENT_ID, callPath: [DATA_PROFILE_AGENT_ID] },
642
651
  frame: { runId: DATA_PROFILE_RUN_LITERAL, stepId: DATA_PROFILE_STEP_LITERAL },
643
- });
652
+ }));
644
653
  }
645
654
  /**
646
655
  * Per-attempt DBOS workflow id. The `nonce` is fresh per attempt (a bare
@@ -666,7 +675,10 @@ export function dataProfileWorkflowId(analysisId, nonce) {
666
675
  * not awaited.
667
676
  */
668
677
  async function startDataProfileWorkflow(deps, params) {
669
- const { runSession, ownsMandate } = await authorizeDataProfile(deps, params); // oss-core-managed-ok
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
670
682
  const attemptNonce = randomUUID();
671
683
  await DBOS.startWorkflow(deps.workflow, {
672
684
  workflowID: dataProfileWorkflowId(params.analysisId, attemptNonce),
@@ -676,6 +688,20 @@ async function startDataProfileWorkflow(deps, params) {
676
688
  ownsMandate, // oss-core-managed-ok
677
689
  stagedInputs: params.stagedInputs,
678
690
  });
691
+ return ok(undefined);
692
+ }
693
+ /**
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`.
697
+ */
698
+ 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;
702
+ });
703
+ if (started?.isErr())
704
+ await refuseClaimedProfile(deps, params.analysisId, phase, started.error);
679
705
  }
680
706
  /**
681
707
  * Attempt to claim and run data profiling for an analysis, then start the workflow
@@ -738,12 +764,12 @@ export async function triggerDataProfile(deps, params) {
738
764
  }
739
765
  const started = unwrapOrThrow(await tryStartDataProfile(deps.pool, analysisId));
740
766
  if (started) {
741
- startDataProfileWorkflow(deps, params).catch((err) => compensateStartFailure(deps, analysisId, "Run", err));
767
+ void dispatchClaimedProfile(deps, params, "Run");
742
768
  return "started";
743
769
  }
744
770
  const restarted = unwrapOrThrow(await tryRerunDataProfile(deps.pool, analysisId));
745
771
  if (restarted) {
746
- startDataProfileWorkflow(deps, params).catch((err) => compensateStartFailure(deps, analysisId, "Re-run", err));
772
+ void dispatchClaimedProfile(deps, params, "Re-run");
747
773
  return "restarted";
748
774
  }
749
775
  const status = unwrapOrThrow(await loadDataProfileStatus(deps.pool, analysisId));
@@ -765,10 +791,24 @@ export async function triggerDataProfile(deps, params) {
765
791
  * Best-effort — a compensation write that itself fails is only logged, since
766
792
  * there is no further channel to report it on.
767
793
  */
768
- async function compensateStartFailure(deps, analysisId, phase, err) {
794
+ async function compensateStartFailure(deps, analysisId, phase, cause) {
795
+ const logger = (deps.logger ?? createNoopLogger()).named("data-profile").with({ analysisId });
796
+ logger.error("start failed", { phase, ...logger.errorFields(cause) });
797
+ await failClaimedProfile(deps, analysisId, phase, profileFailureReason(cause));
798
+ }
799
+ async function refuseClaimedProfile(deps, analysisId, phase, refusal) {
769
800
  const logger = (deps.logger ?? createNoopLogger()).named("data-profile").with({ analysisId });
770
- logger.error("start failed", { phase, ...logger.errorFields(err) });
771
- const failed = await failDataProfile(deps.pool, analysisId, profileFailureReason(err));
801
+ logger.error("the profile was not authorized", { phase, reason: refusal.reason, suspend: refusal.kind === "suspended" });
802
+ await failClaimedProfile(deps, analysisId, phase, refusal.reason);
803
+ if (suspensionOfRefusal(refusal) === undefined)
804
+ return;
805
+ const marked = await suspendAnalysis(deps.pool, analysisId);
806
+ if (marked.isErr())
807
+ logger.error("the analysis was not marked as suspended", { phase, err: marked.error });
808
+ }
809
+ async function failClaimedProfile(deps, analysisId, phase, reason) {
810
+ const logger = (deps.logger ?? createNoopLogger()).named("data-profile").with({ analysisId });
811
+ const failed = await failDataProfile(deps.pool, analysisId, reason);
772
812
  if (failed.isErr()) {
773
813
  logger.error("failed to mark failed after a start error", { phase, err: failed.error });
774
814
  }
@@ -790,15 +830,20 @@ async function compensateStartFailure(deps, analysisId, phase, err) {
790
830
  * await the workflow's completion, but a start that rejects compensates the
791
831
  * ledger (see {@link compensateStartFailure}) before re-throwing, so a caller's
792
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.
793
835
  */
794
836
  export async function runDataProfile(deps, params) {
837
+ let started;
795
838
  try {
796
- await startDataProfileWorkflow(deps, params);
839
+ started = await startDataProfileWorkflow(deps, params);
797
840
  }
798
- catch (err) {
799
- await compensateStartFailure(deps, params.analysisId, "Retry", err);
800
- throw err;
841
+ catch (cause) {
842
+ await compensateStartFailure(deps, params.analysisId, "Retry", cause);
843
+ throw cause;
801
844
  }
845
+ if (started.isErr())
846
+ await refuseClaimedProfile(deps, params.analysisId, "Retry", started.error);
802
847
  }
803
848
  /**
804
849
  * Concise, UI-safe failure reason. `data_profile_error` is surfaced verbatim