@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
@@ -18,8 +18,10 @@
18
18
  * callback unroutable and it lands as a `400`. The pull backstop still settles such an exec, but it pays
19
19
  * the quiet interval first.
20
20
  */
21
+ import { type Result } from "neverthrow";
21
22
  import type { Logger } from "../lib/logger.js";
22
23
  import type { SandboxClient } from "../sandbox/client.js";
24
+ import { type Suspension } from "../workflows/suspension.js";
23
25
  import type { ExecResult } from "../sandbox/types.js";
24
26
  import { type DeriveTableExecInput } from "../tools/report-session/derive-table.js";
25
27
  /** The construction-time deps of the body. The registration closes over them, thus the trigger holds none. */
@@ -35,15 +37,17 @@ export interface DeriveTableExecDeps {
35
37
  /**
36
38
  * The body, exported so a test drives it without a registered workflow.
37
39
  *
38
- * A fault of any seam call throws. The tool reads a rejection as one short detail, thus the caller needs no
39
- * result type here.
40
+ * A fault of any seam call throws. The tool reads a rejection as one short detail. A chat turn awaits this
41
+ * workflow, thus a suspension is the `err` of the result, and the workflow does not cancel
42
+ * (workflow-suspension spec). The body does not mark the analysis: the tool reports the suspension to the
43
+ * conversation agent.
40
44
  */
41
- export declare function runDeriveTableExecBody(input: DeriveTableExecInput, deps: DeriveTableExecDeps): Promise<ExecResult>;
45
+ export declare function runDeriveTableExecBody(input: DeriveTableExecInput, deps: DeriveTableExecDeps): Promise<Result<ExecResult, Suspension>>;
42
46
  /**
43
47
  * Register the derivation workflow with DBOS. It gives back the registered callable, thus a trigger
44
48
  * dispatches with `DBOS.startWorkflow`.
45
49
  */
46
- export declare function registerDeriveTableExecWorkflow(deps: DeriveTableExecDeps): (input: DeriveTableExecInput) => Promise<ExecResult>;
50
+ export declare function registerDeriveTableExecWorkflow(deps: DeriveTableExecDeps): (input: DeriveTableExecInput) => Promise<Result<ExecResult, Suspension>>;
47
51
  /**
48
52
  * The workflow id of one derivation.
49
53
  *
@@ -55,6 +59,6 @@ export declare function deriveTableExecWorkflowId(executionId: string): string;
55
59
  * The async edge. It starts the registered workflow and it awaits the terminal result.
56
60
  *
57
61
  * The tool authorizes the derivation before this call and it revokes after, thus this edge holds no
58
- * lifecycle of its own. A workflow fault rejects the returned promise.
62
+ * lifecycle of its own. A workflow fault rejects the returned promise, and a suspension is the `err`.
59
63
  */
60
- export declare function triggerDeriveTableExec(workflow: (input: DeriveTableExecInput) => Promise<ExecResult>, input: DeriveTableExecInput): Promise<ExecResult>;
64
+ export declare function triggerDeriveTableExec(workflow: (input: DeriveTableExecInput) => Promise<Result<ExecResult, Suspension>>, input: DeriveTableExecInput): Promise<Result<ExecResult, Suspension>>;
@@ -19,16 +19,22 @@
19
19
  * the quiet interval first.
20
20
  */
21
21
  import { DBOS } from "@dbos-inc/dbos-sdk";
22
+ import { err, ok } from "neverthrow";
23
+ import { forStep } from "../auth/types.js";
22
24
  import { createNoopLogger } from "../lib/console-logger.js";
23
25
  import { mintSandboxIdentity } from "../sandbox/identity.js";
26
+ import { keepSuspendingRefusal } from "../sandbox/sandbox-error.js";
27
+ import { suspensionOfSpawnRefusal } from "../workflows/suspension.js";
24
28
  import { buildDerivationExec, DERIVATION_DEADLINE_MS, DERIVATION_RESOURCES, DERIVE_RUN_LITERAL, DERIVE_STEP_LITERAL, } from "../tools/report-session/derive-table.js";
25
29
  /** The awaitExec callback. A derivation reports no live activity, thus the callback drops each event. */
26
30
  const noopEmit = () => { };
27
31
  /**
28
32
  * The body, exported so a test drives it without a registered workflow.
29
33
  *
30
- * A fault of any seam call throws. The tool reads a rejection as one short detail, thus the caller needs no
31
- * result type here.
34
+ * A fault of any seam call throws. The tool reads a rejection as one short detail. A chat turn awaits this
35
+ * workflow, thus a suspension is the `err` of the result, and the workflow does not cancel
36
+ * (workflow-suspension spec). The body does not mark the analysis: the tool reports the suspension to the
37
+ * conversation agent.
32
38
  */
33
39
  export async function runDeriveTableExecBody(input, deps) {
34
40
  const logger = (deps.logger ?? createNoopLogger()).named("derive-table-exec").with({ analysisId: input.analysisId });
@@ -36,14 +42,13 @@ export async function runDeriveTableExecBody(input, deps) {
36
42
  // One workflow submits one exec, thus the function id is a constant. The three segments are what a
37
43
  // callback host parses to find the workflow that awaits this exec.
38
44
  const execId = `${workflowId}:${DERIVE_STEP_LITERAL}:fn-0`;
39
- const sandbox = await deps.sandboxClient.createSandbox({
40
- runId: DERIVE_RUN_LITERAL,
41
- stepId: DERIVE_STEP_LITERAL,
42
- analysisId: input.analysisId,
43
- childWorkflowId: workflowId,
44
- resources: DERIVATION_RESOURCES,
45
- writableTail: input.writableTail,
46
- }, mintSandboxIdentity(DERIVE_RUN_LITERAL));
45
+ const spawned = keepSuspendingRefusal(await deps.sandboxClient.createSandbox(forStep(input.runSession, DERIVE_STEP_LITERAL), { childWorkflowId: workflowId, resources: DERIVATION_RESOURCES, writableTail: input.writableTail }, mintSandboxIdentity(DERIVE_RUN_LITERAL)));
46
+ if (spawned.isErr()) {
47
+ const suspension = suspensionOfSpawnRefusal(spawned.error);
48
+ logger.warn("derivation suspended", { reason: suspension.reason });
49
+ return err(suspension);
50
+ }
51
+ const sandbox = spawned.value;
47
52
  try {
48
53
  // A checkpointed clock, not `Date.now()`: the await gates on this absolute deadline, and a
49
54
  // wall-clock deadline that grew on replay would shift which loop iteration crosses it.
@@ -55,7 +60,7 @@ export async function runDeriveTableExecBody(input, deps) {
55
60
  inputs: input.inputs,
56
61
  output: input.output,
57
62
  }));
58
- return await deps.sandboxClient.awaitExec(sandbox, execId, noopEmit, deadline);
63
+ return ok(await deps.sandboxClient.awaitExec(sandbox, execId, noopEmit, deadline));
59
64
  }
60
65
  finally {
61
66
  try {
@@ -86,7 +91,7 @@ export function deriveTableExecWorkflowId(executionId) {
86
91
  * The async edge. It starts the registered workflow and it awaits the terminal result.
87
92
  *
88
93
  * The tool authorizes the derivation before this call and it revokes after, thus this edge holds no
89
- * lifecycle of its own. A workflow fault rejects the returned promise.
94
+ * lifecycle of its own. A workflow fault rejects the returned promise, and a suspension is the `err`.
90
95
  */
91
96
  export async function triggerDeriveTableExec(workflow, input) {
92
97
  const handle = (await DBOS.startWorkflow(workflow, {
@@ -11,11 +11,13 @@
11
11
  * authorization on every terminal path. The container is ephemeral, and it goes away with the work, thus no
12
12
  * standing machine exists.
13
13
  */
14
- import type { AuthContext, RunSession } from "../auth/types.js";
14
+ import { type Result } from "neverthrow";
15
+ import { type AuthContext, type RunSession } from "../auth/types.js";
15
16
  import type { RunAuthorizer } from "../execution/run-authorizer.js";
16
17
  import type { Logger } from "../lib/logger.js";
17
18
  import type { ExtractionArm, ExtractionArtifact, ExtractionRequest } from "../report-model/production-resolver.js";
18
19
  import type { SandboxClient } from "../sandbox/client.js";
20
+ import { type Suspension } from "../workflows/suspension.js";
19
21
  import type { ExecResult, SubmitExecBody } from "../sandbox/types.js";
20
22
  import { type ExtractValuesResult } from "./extract-values-script.js";
21
23
  export type { ExtractValuesResult } from "./extract-values-script.js";
@@ -71,13 +73,16 @@ export declare function parseExtractionOutput(result: ExecResult): ExtractValues
71
73
  * The body, extracted so a test drives it without a registered workflow. It creates the ephemeral
72
74
  * container, submits one exec, awaits the result durably, and gives the value map back. It revokes the run
73
75
  * authorization on every terminal path, and it tears the container down on both paths.
76
+ *
77
+ * A chat turn awaits this workflow, thus a suspension is the `err` of the result, and the workflow does not
78
+ * cancel (workflow-suspension spec). The body does not mark the analysis: the caller reports the suspension.
74
79
  */
75
- export declare function runExtractValuesBody(input: ExtractValuesWorkflowInput, deps: ExtractValuesDeps): Promise<ExtractValuesResult>;
80
+ export declare function runExtractValuesBody(input: ExtractValuesWorkflowInput, deps: ExtractValuesDeps): Promise<Result<ExtractValuesResult, Suspension>>;
76
81
  /**
77
82
  * Register the extraction workflow with DBOS. It gives back the registered callable, thus a trigger
78
83
  * dispatches with `DBOS.startWorkflow`.
79
84
  */
80
- export declare function registerExtractValuesWorkflow(deps: ExtractValuesDeps): (input: ExtractValuesWorkflowInput) => Promise<ExtractValuesResult>;
85
+ export declare function registerExtractValuesWorkflow(deps: ExtractValuesDeps): (input: ExtractValuesWorkflowInput) => Promise<Result<ExtractValuesResult, Suspension>>;
81
86
  /** The per-attempt workflow id. A fresh nonce keeps each pass under a distinct id. */
82
87
  export declare function extractValuesWorkflowId(analysisId: string, nonce: string): string;
83
88
  /**
@@ -86,7 +91,7 @@ export declare function extractValuesWorkflowId(analysisId: string, nonce: strin
86
91
  */
87
92
  export interface ExtractValuesTriggerDeps {
88
93
  readonly runAuthorizer: RunAuthorizer;
89
- readonly workflow: (input: ExtractValuesWorkflowInput) => Promise<ExtractValuesResult>;
94
+ readonly workflow: (input: ExtractValuesWorkflowInput) => Promise<Result<ExtractValuesResult, Suspension>>;
90
95
  }
91
96
  /** The identity and the requests for one extraction pass. */
92
97
  export interface ExtractValuesTriggerParams {
@@ -96,12 +101,13 @@ export interface ExtractValuesTriggerParams {
96
101
  }
97
102
  /**
98
103
  * The async edge. It authorizes through the `RunAuthorizer`, starts the workflow, and awaits the outcome.
99
- * The body owns the revoke, thus the trigger holds no lifecycle. A workflow fault rejects the returned
100
- * promise.
104
+ * The body owns the revoke, thus the trigger holds no lifecycle. A workflow fault and a refused
105
+ * authorization reject the returned promise. A suspension is the `err` of the result: of the workflow, or of
106
+ * an authorization that the host refused with the suspend flag.
101
107
  */
102
- export declare function triggerExtractValues(deps: ExtractValuesTriggerDeps, params: ExtractValuesTriggerParams): Promise<ExtractValuesResult>;
103
- /** Runs one extraction pass for the bound analysis, and gives the raw value map back. */
104
- export type ExtractionRunner = (requests: readonly ExtractionRequest[]) => Promise<ExtractValuesResult>;
108
+ export declare function triggerExtractValues(deps: ExtractValuesTriggerDeps, params: ExtractValuesTriggerParams): Promise<Result<ExtractValuesResult, Suspension>>;
109
+ /** Runs one extraction pass for the bound analysis, and gives the raw value map back, or the suspension. */
110
+ export type ExtractionRunner = (requests: readonly ExtractionRequest[]) => Promise<Result<ExtractValuesResult, Suspension>>;
105
111
  /**
106
112
  * Bind the trigger to one analysis and one auth context. The result runs one pass with every request in it.
107
113
  */
@@ -119,6 +125,6 @@ export declare function extractionArtifactsFromResult(result: ExtractValuesResul
119
125
  /**
120
126
  * Make the extraction arm over a runner. One `extract` call makes one run with every request in it, and it
121
127
  * gives the value map. A run fault rejects the promise, and the resolver turns the rejection into a failed
122
- * reference.
128
+ * reference. A suspension is the `err`, and the resolver reports its reason on each reference.
123
129
  */
124
130
  export declare function createExtractionArm(run: ExtractionRunner): ExtractionArm;
@@ -12,10 +12,15 @@
12
12
  * standing machine exists.
13
13
  */
14
14
  import { DBOS } from "@dbos-inc/dbos-sdk";
15
+ import { err, ok } from "neverthrow";
15
16
  import { randomUUID } from "node:crypto";
17
+ import { forStep } from "../auth/types.js";
16
18
  import { createNoopLogger } from "../lib/console-logger.js";
19
+ import { deliverNotice, passGate } from "../lib/hooks.js";
17
20
  import { generateExecutionId } from "../sandbox/execution-id.js";
18
21
  import { mintSandboxIdentity } from "../sandbox/identity.js";
22
+ import { keepSuspendingRefusal } from "../sandbox/sandbox-error.js";
23
+ import { suspensionOfRefusal, suspensionOfSpawnRefusal } from "../workflows/suspension.js";
19
24
  import { EXTRACTION_INPUT_ENV, EXTRACTION_SCRIPT, ExtractValuesResultSchema } from "./extract-values-script.js";
20
25
  /** The synthetic run id and step id of the extraction pass. Both are constants shared by every analysis. */
21
26
  const EXTRACT_VALUES_RUN_LITERAL = "extract-values";
@@ -85,24 +90,28 @@ export function parseExtractionOutput(result) {
85
90
  * The body, extracted so a test drives it without a registered workflow. It creates the ephemeral
86
91
  * container, submits one exec, awaits the result durably, and gives the value map back. It revokes the run
87
92
  * authorization on every terminal path, and it tears the container down on both paths.
93
+ *
94
+ * A chat turn awaits this workflow, thus a suspension is the `err` of the result, and the workflow does not
95
+ * cancel (workflow-suspension spec). The body does not mark the analysis: the caller reports the suspension.
88
96
  */
89
97
  export async function runExtractValuesBody(input, deps) {
90
98
  const logger = (deps.logger ?? createNoopLogger()).named("extract-values").with({ analysisId: input.analysisId });
91
99
  const { analysisId, runSession, requests, ownsMandate = true } = input; // oss-core-managed-ok
92
100
  const authorization = { runSession, ownsMandate }; // oss-core-managed-ok
101
+ const revoke = (reason) => deliverNotice(logger, "RunAuthorizer.revoke", deps.runAuthorizer.revoke(authorization, reason));
93
102
  try {
94
103
  const executionId = generateExecutionId(EXTRACT_VALUES_AGENT_ID);
95
104
  const workflowId = DBOS.workflowID ?? `${EXTRACT_VALUES_RUN_LITERAL}:${executionId}`;
96
105
  // The container mounts the analysis tree read-only. The extraction pass only reads, thus it needs
97
106
  // no writable step mount.
98
- const sandbox = await deps.sandboxClient.createSandbox({
99
- runId: EXTRACT_VALUES_RUN_LITERAL,
100
- stepId: EXTRACT_VALUES_STEP_LITERAL,
101
- analysisId,
102
- childWorkflowId: workflowId,
103
- resources: EXTRACTION_RESOURCES,
104
- readOnly: true,
105
- }, mintSandboxIdentity(EXTRACT_VALUES_RUN_LITERAL));
107
+ const spawned = keepSuspendingRefusal(await deps.sandboxClient.createSandbox(forStep(runSession, EXTRACT_VALUES_STEP_LITERAL), { childWorkflowId: workflowId, resources: EXTRACTION_RESOURCES, readOnly: true }, mintSandboxIdentity(EXTRACT_VALUES_RUN_LITERAL)));
108
+ if (spawned.isErr()) {
109
+ const suspension = suspensionOfSpawnRefusal(spawned.error);
110
+ logger.warn("extraction suspended", { reason: suspension.reason });
111
+ await revoke("extract-values-suspended");
112
+ return err(suspension);
113
+ }
114
+ const sandbox = spawned.value;
106
115
  try {
107
116
  // A checkpointed clock, not `Date.now()`: the await gates on this absolute deadline, and a
108
117
  // wall-clock deadline that grew on replay would shift which loop iteration crosses it.
@@ -110,8 +119,8 @@ export async function runExtractValuesBody(input, deps) {
110
119
  await deps.sandboxClient.submitExec(sandbox, buildExtractionExec(analysisId, requests, executionId));
111
120
  const result = await deps.sandboxClient.awaitExec(sandbox, executionId, noopEmit, deadlineAbs);
112
121
  const map = parseExtractionOutput(result);
113
- await deps.runAuthorizer.revoke(authorization, "extract-values-completed");
114
- return map;
122
+ await revoke("extract-values-completed");
123
+ return ok(map);
115
124
  }
116
125
  finally {
117
126
  try {
@@ -124,7 +133,7 @@ export async function runExtractValuesBody(input, deps) {
124
133
  }
125
134
  catch (err) {
126
135
  logger.error("extraction failed", logger.errorFields(err));
127
- await deps.runAuthorizer.revoke(authorization, "extract-values-failed");
136
+ await revoke("extract-values-failed");
128
137
  throw err;
129
138
  }
130
139
  }
@@ -141,18 +150,25 @@ export function extractValuesWorkflowId(analysisId, nonce) {
141
150
  }
142
151
  /**
143
152
  * The async edge. It authorizes through the `RunAuthorizer`, starts the workflow, and awaits the outcome.
144
- * The body owns the revoke, thus the trigger holds no lifecycle. A workflow fault rejects the returned
145
- * promise.
153
+ * The body owns the revoke, thus the trigger holds no lifecycle. A workflow fault and a refused
154
+ * authorization reject the returned promise. A suspension is the `err` of the result: of the workflow, or of
155
+ * an authorization that the host refused with the suspend flag.
146
156
  */
147
157
  export async function triggerExtractValues(deps, params) {
148
158
  const { auth, analysisId, requests } = params;
149
- const { runSession, ownsMandate } = await deps.runAuthorizer.authorize({
150
- // oss-core-managed-ok
159
+ const authorized = await passGate("RunAuthorizer.authorize", deps.runAuthorizer.authorize({
151
160
  auth,
152
161
  scope: { kind: "analysis", analysisId },
153
162
  provenance: { agentId: EXTRACT_VALUES_AGENT_ID, callPath: [EXTRACT_VALUES_AGENT_ID] },
154
163
  frame: { runId: EXTRACT_VALUES_RUN_LITERAL, stepId: EXTRACT_VALUES_STEP_LITERAL },
155
- });
164
+ }));
165
+ if (authorized.isErr()) {
166
+ const suspension = suspensionOfRefusal(authorized.error);
167
+ if (suspension !== undefined)
168
+ return err(suspension);
169
+ throw new Error(`the extraction was not authorized: ${authorized.error.reason}`);
170
+ }
171
+ const { runSession, ownsMandate } = authorized.value; // oss-core-managed-ok
156
172
  const handle = (await DBOS.startWorkflow(deps.workflow, {
157
173
  workflowID: extractValuesWorkflowId(analysisId, randomUUID()),
158
174
  })({ analysisId, runSession, requests: [...requests], ownsMandate })); // oss-core-managed-ok
@@ -182,12 +198,12 @@ export function extractionArtifactsFromResult(result) {
182
198
  /**
183
199
  * Make the extraction arm over a runner. One `extract` call makes one run with every request in it, and it
184
200
  * gives the value map. A run fault rejects the promise, and the resolver turns the rejection into a failed
185
- * reference.
201
+ * reference. A suspension is the `err`, and the resolver reports its reason on each reference.
186
202
  */
187
203
  export function createExtractionArm(run) {
188
204
  return {
189
205
  async extract(requests) {
190
- return extractionArtifactsFromResult(await run(requests));
206
+ return (await run(requests)).map(extractionArtifactsFromResult);
191
207
  },
192
208
  };
193
209
  }
@@ -3,17 +3,18 @@
3
3
  * hoc analyses. Both modes launch the ordinary executeAnalysis workflow.
4
4
  */
5
5
  import { randomUUID } from "node:crypto";
6
- import { ok } from "neverthrow";
6
+ import { err, ok } from "neverthrow";
7
7
  import { z } from "zod";
8
8
  import { SANDBOX_AGENT_META } from "../agents/sandbox/index.js";
9
9
  import { DEFAULT_SANDBOX_MAX_STEPS } from "../config/resource-limits.js";
10
10
  import { createNoopLogger } from "../lib/console-logger.js";
11
+ import { deliverNotice, passGate } from "../lib/hooks.js";
11
12
  import { unwrapOrThrow } from "../lib/result.js";
12
13
  import { buildRunCardData } from "../memory/card-builders.js";
13
14
  import { formatQuery, parseQuery } from "../sandbox/package-identity.js";
14
15
  import { AnalysisPlanSchema } from "../schemas/workflow-state.js";
15
16
  import { validatePlan } from "../schemas/validate-plan.js";
16
- import { RunDedupCollisionError, insertRun, loadPlan, queryActiveRun, reserveRunById, updateRunStatus, upsertPlan } from "../state/index.js";
17
+ import { RunDedupCollisionError, insertRun, loadPlan, queryActiveRun, reserveRunById, suspendAnalysis, updateRunStatus, upsertPlan } from "../state/index.js";
17
18
  import { routeAdHocRequest } from "./ad-hoc-router.js";
18
19
  import { adHocPlanId, adHocRunId } from "./analysis-invocation.js";
19
20
  import { defineTool } from "./define-tool.js";
@@ -383,19 +384,25 @@ export function createExecuteAnalysisTool(deps) {
383
384
  return ok({ runId: reservation.row.runId, status: "in_progress" });
384
385
  }
385
386
  }
386
- let authorization;
387
- try {
388
- authorization = await deps.runAuthorizer.authorize({
389
- auth: ctx.session.auth,
390
- scope: ctx.session.scope,
391
- provenance: ctx.session.provenance,
392
- frame: { runId },
393
- });
394
- }
395
- catch (error) {
396
- await updateRunStatus(deps.pool, runId, "failed", "run authorization failed").match(() => { }, () => { });
397
- throw error;
387
+ const authorized = await passGate("RunAuthorizer.authorize", deps.runAuthorizer.authorize({
388
+ auth: ctx.session.auth,
389
+ scope: ctx.session.scope,
390
+ provenance: ctx.session.provenance,
391
+ frame: { runId },
392
+ }));
393
+ if (authorized.isErr()) {
394
+ // The refusal ends the run before a workflow exists, thus the
395
+ // reserved row must not stay `running`.
396
+ const { kind, reason } = authorized.error;
397
+ if (kind === "suspended") {
398
+ await updateRunStatus(deps.pool, runId, "canceled", reason).match(() => { }, () => { });
399
+ unwrapOrThrow(await suspendAnalysis(deps.pool, analysisId));
400
+ return err({ error: `The analysis is suspended: ${reason}`, retryable: false });
401
+ }
402
+ await updateRunStatus(deps.pool, runId, "failed", reason).match(() => { }, () => { });
403
+ return err({ error: `The run was not authorized: ${reason}`, retryable: false });
398
404
  }
405
+ const authorization = authorized.value;
399
406
  const wfInput = workflowInput(planId, plan, {
400
407
  analysisId,
401
408
  threadId,
@@ -408,7 +415,7 @@ export function createExecuteAnalysisTool(deps) {
408
415
  await deps.runLauncher.launch(deps.executeAnalysisWorkflow, { workflowId: runId }, wfInput);
409
416
  }
410
417
  catch (error) {
411
- await deps.runAuthorizer.revoke(authorization, "workflow-start-failed").catch(() => { });
418
+ await deliverNotice(logger, "RunAuthorizer.revoke", deps.runAuthorizer.revoke(authorization, "workflow-start-failed"));
412
419
  await updateRunStatus(deps.pool, runId, "failed", "workflow start failed").match(() => { }, () => { });
413
420
  throw error;
414
421
  }
@@ -33,7 +33,10 @@
33
33
  * under the callback transport, and a report turn is not a body. Thus the tool takes an injected runner,
34
34
  * and a registered workflow owns the container. The tool holds no sandbox client, and it imports no DBOS.
35
35
  */
36
+ import { type Result } from "neverthrow";
36
37
  import { z } from "zod";
38
+ import type { RunSession } from "../../auth/types.js";
39
+ import type { Suspension } from "../../workflows/suspension.js";
37
40
  import type { RunAuthorizer } from "../../execution/run-authorizer.js";
38
41
  import { type Logger } from "../../lib/logger.js";
39
42
  import type { ResourceSpec } from "../../config/resource-limits.js";
@@ -90,6 +93,10 @@ export type DeriveTableResult = {
90
93
  } | {
91
94
  outcome: "exec-failed";
92
95
  detail: string;
96
+ } | {
97
+ outcome: "suspended";
98
+ reason: string;
99
+ detail: string;
93
100
  } | {
94
101
  outcome: "no-output";
95
102
  detail: string;
@@ -172,6 +179,7 @@ export declare function describeExecFailure(result: ExecResult): string | undefi
172
179
  */
173
180
  export interface DeriveTableExecInput {
174
181
  readonly analysisId: string;
182
+ readonly runSession: RunSession;
175
183
  readonly executionId: string;
176
184
  readonly script: string;
177
185
  readonly writableTail: string;
@@ -186,7 +194,7 @@ export interface DeriveTableExecInput {
186
194
  * and the await is legal under each transport. A fault of the sandbox rejects the promise, and the tool
187
195
  * turns that rejection into one short detail.
188
196
  */
189
- export type DeriveTableRunner = (input: DeriveTableExecInput) => Promise<ExecResult>;
197
+ export type DeriveTableRunner = (input: DeriveTableExecInput) => Promise<Result<ExecResult, Suspension>>;
190
198
  /**
191
199
  * Make the derivation tool over the session-state gateway, the derivation ledger, and the sandbox rails.
192
200
  *
@@ -38,6 +38,7 @@ import { unlink } from "node:fs/promises";
38
38
  import { join } from "node:path";
39
39
  import { z } from "zod";
40
40
  import { createNoopLogger } from "../../lib/console-logger.js";
41
+ import { deliverNotice, passGate } from "../../lib/hooks.js";
41
42
  import { classifyWithinRoot, computeSha256, computeSha256File } from "../../lib/fs-helpers.js";
42
43
  import { defaultErrorFields } from "../../lib/logger.js";
43
44
  import { snapshotEntry } from "../../report-model/reference-resolver.js";
@@ -223,19 +224,20 @@ export function createDeriveTableTool(deps) {
223
224
  logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
224
225
  return ok({ outcome: "unavailable", detail: "the workspace root did not resolve, thus the derived table has no place to land" });
225
226
  }
226
- let authorization;
227
- try {
228
- authorization = await runAuthorizer.authorize({
229
- auth: ctx.session.auth,
230
- scope: { kind: "analysis", analysisId },
231
- provenance: { agentId: DERIVE_AGENT_ID, callPath: [DERIVE_AGENT_ID] },
232
- frame: { runId: DERIVE_RUN_LITERAL, stepId: DERIVE_STEP_LITERAL },
233
- });
234
- }
235
- catch (cause) {
236
- logger.error("the derivation was not authorized", { threadId, analysisId, ...defaultErrorFields(cause) });
237
- return ok({ outcome: "unavailable", detail: "the derivation was not authorized" });
227
+ const authorized = await passGate("RunAuthorizer.authorize", runAuthorizer.authorize({
228
+ auth: ctx.session.auth,
229
+ scope: { kind: "analysis", analysisId },
230
+ provenance: { agentId: DERIVE_AGENT_ID, callPath: [DERIVE_AGENT_ID] },
231
+ frame: { runId: DERIVE_RUN_LITERAL, stepId: DERIVE_STEP_LITERAL },
232
+ }));
233
+ if (authorized.isErr()) {
234
+ const { kind, reason } = authorized.error;
235
+ logger.error("the derivation was not authorized", { threadId, analysisId, reason, suspend: kind === "suspended" });
236
+ if (kind === "suspended")
237
+ return ok({ outcome: "suspended", reason, detail: `the analysis is suspended (${reason}), thus the derivation did not run` });
238
+ return ok({ outcome: "unavailable", detail: `the derivation was not authorized: ${reason}` });
238
239
  }
240
+ const authorization = authorized.value;
239
241
  // The work sits inside the `try`, and the revoke sits in the `finally`. Thus the authorization
240
242
  // revokes on every terminal path, the thrown one included: a path builder refuses a hostile
241
243
  // stored path with a throw, and that throw must not leave an authorization standing.
@@ -247,6 +249,7 @@ export function createDeriveTableTool(deps) {
247
249
  root,
248
250
  threadId,
249
251
  analysisId,
252
+ runSession: authorization.runSession,
250
253
  script: input.script,
251
254
  sources,
252
255
  writableTail,
@@ -270,14 +273,9 @@ export function createDeriveTableTool(deps) {
270
273
  return ok(result);
271
274
  }
272
275
  finally {
273
- // A revoke fault changes no outcome of the call, thus it reaches the log alone. An absent
274
- // result means that the work threw, and a throw is a failed derivation.
275
- try {
276
- await runAuthorizer.revoke(authorization, result?.outcome === "derived" ? "derive-table-completed" : "derive-table-failed");
277
- }
278
- catch (cause) {
279
- logger.warn("the derivation authorization did not revoke", { threadId, analysisId, ...defaultErrorFields(cause) });
280
- }
276
+ // The revoke is a notice: its failure changes no outcome of the call, thus it reaches the log
277
+ // alone. An absent result means that the work threw, and a throw is a failed derivation.
278
+ await deliverNotice(logger.with({ threadId, analysisId }), "RunAuthorizer.revoke", runAuthorizer.revoke(authorization, result?.outcome === "derived" ? "derive-table-completed" : "derive-table-failed"));
281
279
  }
282
280
  },
283
281
  });
@@ -318,6 +316,7 @@ async function derive(args) {
318
316
  // thus that refusal stays a throw and it never reads as a fault of the container.
319
317
  const execInput = {
320
318
  analysisId: args.analysisId,
319
+ runSession: args.runSession,
321
320
  executionId,
322
321
  script: args.script,
323
322
  writableTail: args.writableTail,
@@ -325,14 +324,18 @@ async function derive(args) {
325
324
  inputs: args.sources.map((source) => ({ path: toSandboxPath(args.root, args.analysisId, join(args.root, source.path)), hash: source.hash })),
326
325
  output: toSandboxPath(args.root, args.analysisId, absolute),
327
326
  };
328
- let executed;
327
+ let ran;
329
328
  try {
330
- executed = await args.runDerivation(execInput);
329
+ ran = await args.runDerivation(execInput);
331
330
  }
332
331
  catch (cause) {
333
332
  args.logger.error("the derivation exec did not complete", { threadId: args.threadId, ...defaultErrorFields(cause) });
334
333
  return { outcome: "exec-failed", detail: "the derivation exec did not complete" };
335
334
  }
335
+ if (ran.isErr()) {
336
+ return { outcome: "suspended", reason: ran.error.reason, detail: `the analysis is suspended (${ran.error.reason}), thus the derivation did not run` };
337
+ }
338
+ const executed = ran.value;
336
339
  const failure = describeExecFailure(executed);
337
340
  if (failure !== undefined) {
338
341
  return { outcome: "exec-failed", detail: failure };
@@ -53,7 +53,7 @@ declare function formatRun(r: CortexRunRow, verbose: boolean, exposePaths?: bool
53
53
  synthesisReason?: string | undefined;
54
54
  runId: string;
55
55
  workflowName: string;
56
- status: "running" | "completed" | "failed" | "partial" | "canceled" | "suspended_insufficient_funds";
56
+ status: "failed" | "running" | "completed" | "partial" | "canceled" | "suspended_insufficient_funds";
57
57
  startedAt: string;
58
58
  completedAt: string | null;
59
59
  planId: string | null;
@@ -31,23 +31,30 @@
31
31
  * - await child results via `getResult` (these are themselves cached
32
32
  * in DBOS, so parent recovery does NOT re-run completed children)
33
33
  * 3. Failure isolation
34
- * - a child settling `failed`/`blocked` (or throwing for a non-budget
35
- * cause) dooms only its transitive dependents: they can never become
36
- * dependency-satisfied, are marked `skipped` on the dag-state part, and
37
- * are never dispatched. In-flight siblings and independent ready steps
38
- * continue; the loop drains and the run finalises (typically `partial`).
34
+ * - a child settling `failed`/`blocked` (or throwing for a cause that is
35
+ * not a suspension) dooms only its transitive dependents: they can never
36
+ * become dependency-satisfied, are marked `skipped` on the dag-state
37
+ * part, and are never dispatched. In-flight siblings and independent
38
+ * ready steps continue; the loop drains and the run finalises (typically
39
+ * `partial`).
39
40
  * - the halt cascade (cancel in-flight via
40
41
  * `Promise.allSettled(handles.map(h => DBOS.cancelWorkflow(h.workflowID)))`,
41
- * stop scheduling) survives only for the budget paths: a
42
- * `budget_exceeded` settlement and the `neverFits` plan-validation guard.
43
- * 4. Pause cascade (402)
44
- * - on a child cancelling itself with `budget_exceeded`: cancel siblings
45
- * then self-cancel the parent to `CANCELLED` (NOT `ERROR` — ERROR
46
- * isn't resumable; NOTES #3)
42
+ * stop scheduling) survives only for a suspension of a child and the
43
+ * `neverFits` plan-validation guard.
44
+ * 4. Suspension cascade (workflow-suspension spec)
45
+ * - a child that suspends sends a typed message on `CHILD_SUSPENDED_TOPIC`
46
+ * and cancels itself. The parent reads the kind of the message, never a
47
+ * reason string: it cancels the siblings, then self-cancels to
48
+ * `CANCELLED` (NOT `ERROR` — ERROR isn't resumable; NOTES #3)
49
+ * - a refusal of the `RunCharge.open` gate with the suspend flag suspends
50
+ * the run before any step starts
51
+ * - a `suspend` error of a synthesis model request suspends the run in
52
+ * place of the synthesis failure
47
53
  * - the analysis flips to `suspended_insufficient_funds` in
48
54
  * `collectAndComplete`. The paused parent lands in `CANCELLED`, a
49
- * durably reschedulable state; re-driving it after a top-up is a
50
- * future enhancement, not yet wired here.
55
+ * durably reschedulable state; re-driving it is a future enhancement,
56
+ * not yet wired here. The reason of the host rides to the run row, the
57
+ * charge close, the metric, and the terminal part, unread.
51
58
  * 5. `synthesizeFindings` (single sequential block, no sandbox)
52
59
  * 6. `collectAndComplete` (terminal — runs on ALL paths)
53
60
  * - determine final status from completed/cancelled/failed counts
@@ -77,6 +84,7 @@ import type { RunCharge } from "../billing/run-charge.js";
77
84
  import { type ResolveWorkspaceRoot } from "../workspace/paths.js";
78
85
  import { type PlanStep } from "./execute-analysis-scheduler.js";
79
86
  import { type SandboxStepInput, type SandboxStepResult } from "./sandbox-step.js";
87
+ import { type Suspension } from "./suspension.js";
80
88
  /** Registered child sandbox-step callable the parent's child-dispatch closes over. */
81
89
  type SandboxStepCallable = (input: SandboxStepInput) => Promise<SandboxStepResult>;
82
90
  /** Quick-display finding on the run-completed card. Mirrors the wire schema's RunCompletedFinding. */
@@ -320,13 +328,14 @@ export declare function composeStepSeed(args: {
320
328
  * its reason in `blockedReason` — an honest agent-declared blocker, not an
321
329
  * error — and a failure carries the same reason string
322
330
  * `persist-synthesis-outcome` writes to `cortex_runs.synthesis_reason`, so the
323
- * two ledgers cannot disagree about why synthesis died. Exported for the
324
- * projection tests (the `buildChildInput` pattern).
331
+ * two ledgers cannot disagree about why synthesis died. A suspension is not an
332
+ * outcome: the row is canceled, as the row of a suspended step. Exported for
333
+ * the projection tests (the `buildChildInput` pattern).
325
334
  */
326
335
  export declare function synthesisRowUpdate(outcome: {
327
336
  status: SynthesisStatus;
328
337
  reason: string | null;
329
- }, durationMs: number): UpdateStepExecutionInput;
338
+ } | Suspension, durationMs: number): UpdateStepExecutionInput;
330
339
  /**
331
340
  * Run-level literature-grounded synthesis (no sandbox). Returns the
332
341
  * quick-display findings for the run-completed card; empty when synthesis is