@codemation/core 0.11.1 → 0.12.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 (89) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/{CostCatalogContract-DZgcUBE4.d.cts → CostCatalogContract-DD7fQ4FF.d.cts} +2 -2
  3. package/dist/{EngineRuntimeRegistration.types-Cggm5GVY.d.cts → EngineRuntimeRegistration.types-DTV5_7Jw.d.cts} +3 -3
  4. package/dist/{EngineRuntimeRegistration.types-BQbS9_gs.d.ts → EngineRuntimeRegistration.types-Dl92Hdoi.d.ts} +2 -2
  5. package/dist/InMemoryRunDataFactory-qMiYjhCK.d.cts +202 -0
  6. package/dist/{ItemsInputNormalizer-D-MH8MBs.js → ItemsInputNormalizer-BhuxvZh5.js} +2 -2
  7. package/dist/{ItemsInputNormalizer-D-MH8MBs.js.map → ItemsInputNormalizer-BhuxvZh5.js.map} +1 -1
  8. package/dist/{ItemsInputNormalizer-_Mfcd3YU.d.ts → ItemsInputNormalizer-C09a7iFP.d.ts} +2 -2
  9. package/dist/{ItemsInputNormalizer-C_dpn76M.d.cts → ItemsInputNormalizer-DLaD6rTl.d.cts} +3 -3
  10. package/dist/{ItemsInputNormalizer-CwdOhSAK.cjs → ItemsInputNormalizer-Div-fb6a.cjs} +2 -2
  11. package/dist/{ItemsInputNormalizer-CwdOhSAK.cjs.map → ItemsInputNormalizer-Div-fb6a.cjs.map} +1 -1
  12. package/dist/{RunIntentService-BVur7x9n.d.ts → RunIntentService-BOSGwmqn.d.ts} +18 -4
  13. package/dist/{RunIntentService-CEF-sFfI.d.cts → RunIntentService-CWMMrAP4.d.cts} +18 -4
  14. package/dist/{agentMcpTypes-ZiNbNsEi.d.cts → agentMcpTypes-DUmniLOY.d.cts} +183 -4
  15. package/dist/bootstrap/index.cjs +3 -3
  16. package/dist/bootstrap/index.d.cts +63 -7
  17. package/dist/bootstrap/index.d.ts +5 -5
  18. package/dist/bootstrap/index.js +3 -3
  19. package/dist/{bootstrap-D_Yyi0wL.js → bootstrap-CKTMMNmL.js} +173 -4
  20. package/dist/bootstrap-CKTMMNmL.js.map +1 -0
  21. package/dist/{bootstrap-BxuTFTLB.cjs → bootstrap-D460dCgS.cjs} +175 -4
  22. package/dist/bootstrap-D460dCgS.cjs.map +1 -0
  23. package/dist/browser.cjs +3 -2
  24. package/dist/browser.d.cts +4 -4
  25. package/dist/browser.d.ts +3 -3
  26. package/dist/browser.js +3 -3
  27. package/dist/contracts.d.cts +5 -5
  28. package/dist/contracts.d.ts +2 -2
  29. package/dist/{di-0Wop7z1y.js → di-DdsgWfVy.js} +31 -2
  30. package/dist/di-DdsgWfVy.js.map +1 -0
  31. package/dist/{di-BlEKdoZS.cjs → di-tO6R7VJV.cjs} +36 -1
  32. package/dist/di-tO6R7VJV.cjs.map +1 -0
  33. package/dist/{executionPersistenceContracts-BgZMRsTa.d.cts → executionPersistenceContracts-DenJJK2T.d.cts} +2 -2
  34. package/dist/{index-62Ba9f7D.d.ts → index-BZDhEQ6W.d.ts} +277 -101
  35. package/dist/{index-zWGtEhrf.d.ts → index-CSKKuK60.d.ts} +441 -5
  36. package/dist/index.cjs +71 -161
  37. package/dist/index.cjs.map +1 -1
  38. package/dist/index.d.cts +395 -97
  39. package/dist/index.d.ts +5 -5
  40. package/dist/index.js +56 -159
  41. package/dist/index.js.map +1 -1
  42. package/dist/{params-B5SENSzZ.d.cts → params-DqRvku2h.d.cts} +2 -2
  43. package/dist/{runtime-cxmUkk0l.js → runtime-BPZgnZ9G.js} +611 -16
  44. package/dist/runtime-BPZgnZ9G.js.map +1 -0
  45. package/dist/{runtime-DBzq5YBi.cjs → runtime-CyW9c9XM.cjs} +670 -15
  46. package/dist/runtime-CyW9c9XM.cjs.map +1 -0
  47. package/dist/testing.cjs +3 -3
  48. package/dist/testing.d.cts +3 -3
  49. package/dist/testing.d.ts +3 -3
  50. package/dist/testing.js +3 -3
  51. package/package.json +1 -1
  52. package/src/authoring/defineHumanApprovalNode.types.ts +379 -0
  53. package/src/authoring/index.ts +6 -0
  54. package/src/bootstrap/runtime/EngineRuntimeRegistrar.ts +29 -0
  55. package/src/contracts/CodemationTelemetryAttributeNames.ts +10 -0
  56. package/src/contracts/credentialTypes.ts +10 -0
  57. package/src/contracts/hitlSeamTypes.ts +34 -0
  58. package/src/contracts/humanTaskStoreTypes.ts +48 -0
  59. package/src/contracts/inboxChannelTypes.ts +58 -0
  60. package/src/contracts/index.ts +3 -0
  61. package/src/contracts/runTypes.ts +61 -3
  62. package/src/contracts/runtimeTypes.ts +112 -0
  63. package/src/credentials/CredentialMaterialProvider.types.ts +61 -0
  64. package/src/credentials/ManagedCredentialMaterialWriteError.ts +14 -0
  65. package/src/credentials/ManagedMaterialFetchError.ts +16 -0
  66. package/src/execution/ActivationEnqueueService.ts +16 -0
  67. package/src/execution/DefaultExecutionContextFactory.ts +11 -0
  68. package/src/execution/NodeExecutionSnapshotFactory.ts +7 -1
  69. package/src/execution/NodeExecutor.ts +60 -1
  70. package/src/execution/NodeExecutorFactory.ts +12 -2
  71. package/src/execution/NodeSuspensionHandler.ts +220 -0
  72. package/src/execution/PersistedRunStateTerminalBuilder.ts +5 -2
  73. package/src/execution/RunStateSemantics.ts +5 -0
  74. package/src/execution/RunSuspendedError.ts +21 -0
  75. package/src/index.ts +40 -0
  76. package/src/orchestration/Engine.ts +12 -2
  77. package/src/orchestration/EngineWaiters.ts +1 -1
  78. package/src/orchestration/NodeExecutionRequestHandlerService.ts +25 -2
  79. package/src/orchestration/RunContinuationService.ts +226 -2
  80. package/src/orchestration/TestSuiteOrchestrator.ts +5 -4
  81. package/src/runtime/RunIntentService.ts +3 -0
  82. package/src/workflow/dsl/ChainCursorResolver.ts +36 -0
  83. package/dist/InMemoryRunDataFactory-C7YItvHG.d.cts +0 -123
  84. package/dist/bootstrap-BxuTFTLB.cjs.map +0 -1
  85. package/dist/bootstrap-D_Yyi0wL.js.map +0 -1
  86. package/dist/di-0Wop7z1y.js.map +0 -1
  87. package/dist/di-BlEKdoZS.cjs.map +0 -1
  88. package/dist/runtime-DBzq5YBi.cjs.map +0 -1
  89. package/dist/runtime-cxmUkk0l.js.map +0 -1
@@ -0,0 +1,58 @@
1
+ import type { TypeToken } from "../di";
2
+ import type { HumanTaskActor, HumanTaskHandle, HumanTaskSubject } from "./runtimeTypes";
3
+ import type { Item } from "./workflowTypes";
4
+
5
+ /**
6
+ * A single inbox delivery channel.
7
+ * Implementations: `LocalInboxChannel`, `ControlPlaneInboxChannel`.
8
+ */
9
+ export interface InboxChannel {
10
+ readonly kind: "local" | "control-plane-inbox";
11
+ deliver(args: InboxDeliverArgs): Promise<InboxDelivery>;
12
+ updateOnDecision?(args: InboxOnDecisionArgs): Promise<void>;
13
+ updateOnTimeout?(args: InboxOnTimeoutArgs): Promise<void>;
14
+ }
15
+
16
+ export type InboxDeliverArgs = Readonly<{
17
+ task: HumanTaskHandle;
18
+ subject: HumanTaskSubject;
19
+ priority: "low" | "normal" | "high";
20
+ item: Item;
21
+ /** Present in managed mode (from `PairingConfig.workspaceId`). */
22
+ workspaceId?: string;
23
+ }>;
24
+
25
+ export type InboxDelivery =
26
+ | { kind: "local"; inboxItemId: string }
27
+ | { kind: "cp"; inboxItemId: string; workspaceId: string };
28
+
29
+ export type InboxOnDecisionArgs = Readonly<{
30
+ delivery: InboxDelivery;
31
+ decision: { approved: boolean; note?: string };
32
+ actor: HumanTaskActor;
33
+ }>;
34
+
35
+ export type InboxOnTimeoutArgs = Readonly<{
36
+ delivery: InboxDelivery;
37
+ policy: "halt" | "auto-accept";
38
+ }>;
39
+
40
+ /**
41
+ * Resolves the correct `InboxChannel` for the current deployment mode
42
+ * (local dev vs. managed/CP). Implemented in `@codemation/host`.
43
+ */
44
+ export interface InboxChannelResolverSeam {
45
+ resolve(): { channel: InboxChannel; workspaceId?: string };
46
+ }
47
+
48
+ export const InboxChannelResolverToken = Symbol.for("codemation.core.InboxChannelResolver") as TypeToken<
49
+ InboxChannelResolverSeam | undefined
50
+ >;
51
+
52
+ export const LocalInboxChannelToken = Symbol.for("codemation.core.LocalInboxChannel") as TypeToken<
53
+ InboxChannel | undefined
54
+ >;
55
+
56
+ export const ControlPlaneInboxChannelToken = Symbol.for("codemation.core.ControlPlaneInboxChannel") as TypeToken<
57
+ InboxChannel | undefined
58
+ >;
@@ -1,5 +1,8 @@
1
1
  export * from "./AgentBindError";
2
2
  export * from "./agentMcpTypes";
3
+ export * from "./hitlSeamTypes";
4
+ export * from "./humanTaskStoreTypes";
5
+ export * from "./inboxChannelTypes";
3
6
  export * from "./NoOpAgentMcpIntegration";
4
7
  export * from "./baseTypes";
5
8
  export * from "./assertionTypes";
@@ -128,7 +128,18 @@ export interface RunQueueEntry {
128
128
  }>;
129
129
  }
130
130
 
131
- export type NodeExecutionStatus = "pending" | "queued" | "running" | "completed" | "failed" | "skipped";
131
+ export type NodeExecutionStatus =
132
+ | "pending"
133
+ | "queued"
134
+ | "running"
135
+ | "completed"
136
+ | "failed"
137
+ | "skipped"
138
+ | "hitl-approved"
139
+ | "hitl-rejected"
140
+ | "hitl-timeout"
141
+ | "hitl-auto-accepted"
142
+ | "hitl-cancelled";
132
143
 
133
144
  export interface NodeExecutionError {
134
145
  message: string;
@@ -245,7 +256,10 @@ export interface ExecutionFrontierPlan {
245
256
  preservedPinnedNodeIds: ReadonlyArray<NodeId>;
246
257
  }
247
258
 
248
- export type RunStatus = "running" | "pending" | "completed" | "failed";
259
+ export type RunStatus = "running" | "pending" | "completed" | "failed" | "suspended" | "halted";
260
+
261
+ /** Reason a run transitioned to {@link RunStatus} `"halted"`. */
262
+ export type RunHaltReason = "hitl-rejected" | "hitl-timeout" | "hitl-cancelled";
249
263
 
250
264
  export interface RunSummary {
251
265
  runId: RunId;
@@ -283,6 +297,37 @@ export interface PersistedRunSchedulingState {
283
297
  queue: RunQueueEntry[];
284
298
  }
285
299
 
300
+ /** One persisted suspension entry per suspended item. */
301
+ export interface PersistedSuspensionEntry {
302
+ /** Opaque task identifier (UUID v4). */
303
+ readonly taskId: string;
304
+ readonly nodeId: NodeId;
305
+ readonly activationId: NodeActivationId;
306
+ readonly itemIndex: number;
307
+ /** SHA-256 hex digest of the decision schema JSON (for schema-drift detection). */
308
+ readonly decisionSchemaHash: string;
309
+ /** Serialized return value from `SuspensionRequest.deliver` (stored on the HumanTask row). */
310
+ readonly deliveryRef: JsonValue;
311
+ /** ISO timestamp when the task expires. */
312
+ readonly timeoutAt: string;
313
+ readonly onTimeout: "halt" | "auto-accept";
314
+ }
315
+
316
+ /**
317
+ * When a node is re-activated after suspension, the engine writes the resume context here
318
+ * so `NodeExecutionRequestHandlerService` can splice `resumeContext` into ctx.
319
+ * Cleared once the re-activation is consumed.
320
+ */
321
+ export interface PendingResumeEntry {
322
+ readonly activationId: NodeActivationId;
323
+ readonly nodeId: NodeId;
324
+ /**
325
+ * Typed as `unknown` here to avoid a circular import between runTypes ↔ runtimeTypes.
326
+ * `NodeExecutionRequestHandlerService` casts this to `ResumeContext` from runtimeTypes.
327
+ */
328
+ readonly resumeContext: unknown;
329
+ }
330
+
286
331
  export interface PersistedRunState {
287
332
  runId: RunId;
288
333
  workflowId: WorkflowId;
@@ -301,12 +346,24 @@ export interface PersistedRunState {
301
346
  /** Successful node completions so far (for activation budget). */
302
347
  engineCounters?: EngineRunCounters;
303
348
  status: RunStatus;
349
+ /** Populated when `status === "halted"` to discriminate why the run was halted. */
350
+ reason?: RunHaltReason;
304
351
  pending?: PendingNodeExecution;
305
352
  queue: RunQueueEntry[];
306
353
  outputsByNode: Record<NodeId, NodeOutputs>;
307
354
  nodeSnapshotsByNodeId: Record<NodeId, NodeExecutionSnapshot>;
308
355
  /** Append-only history of connection invocations (LLM/tool) nested under owning nodes. */
309
356
  connectionInvocations?: ReadonlyArray<ConnectionInvocationRecord>;
357
+ /**
358
+ * One entry per outstanding HITL suspension (per-item).
359
+ * Present and non-empty iff `status === "suspended"`.
360
+ */
361
+ suspension?: ReadonlyArray<PersistedSuspensionEntry>;
362
+ /**
363
+ * Written by `resumeRun()` so `NodeExecutionRequestHandlerService` can splice `resumeContext`
364
+ * into the ctx when re-executing the suspended node. Cleared once consumed.
365
+ */
366
+ pendingResume?: PendingResumeEntry;
310
367
  }
311
368
 
312
369
  export interface WorkflowExecutionRepository {
@@ -349,7 +406,8 @@ export interface WorkflowExecutionPruneRepository {
349
406
  export type RunResult =
350
407
  | { runId: RunId; workflowId: WorkflowId; startedAt: string; status: "completed"; outputs: Items }
351
408
  | { runId: RunId; workflowId: WorkflowId; startedAt: string; status: "pending"; pending: PendingNodeExecution }
352
- | { runId: RunId; workflowId: WorkflowId; startedAt: string; status: "failed"; error: { message: string } };
409
+ | { runId: RunId; workflowId: WorkflowId; startedAt: string; status: "failed"; error: { message: string } }
410
+ | { runId: RunId; workflowId: WorkflowId; startedAt: string; status: "halted"; reason: RunHaltReason };
353
411
 
354
412
  export type WebhookRunResult = Readonly<{
355
413
  runId: RunId;
@@ -19,6 +19,105 @@ import type { WorkflowActivationPolicy } from "./workflowActivationPolicy";
19
19
  import type { TriggerInstanceId, WebhookTriggerMatcher } from "./webhookTypes";
20
20
  import type { ZodType } from "zod";
21
21
 
22
+ // ---------------------------------------------------------------------------
23
+ // HITL primitives
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Opaque unique identifier for a single HumanTask instance. */
27
+ export type HumanTaskId = string;
28
+
29
+ /**
30
+ * Duration string — ISO 8601 duration ("PT24H") or shorthand ("24h").
31
+ * Parsed by the timeout job; stored as-is in the suspension record.
32
+ */
33
+ export type Duration = string;
34
+
35
+ /**
36
+ * Minimal handle handed to the `deliver` callback so it can route to the correct
37
+ * inbox channel.
38
+ */
39
+ export interface HumanTaskHandle {
40
+ readonly taskId: HumanTaskId;
41
+ readonly runId: string;
42
+ readonly nodeId: string;
43
+ readonly expiresAt: Date;
44
+ /** TODO: real signed URL; placeholder empty string for now. */
45
+ readonly resumeUrl: string;
46
+ /**
47
+ * Arbitrary JSON metadata copied from `SuspensionRequest.request.metadata` at suspension time.
48
+ * Used by the agent runtime to round-trip the `agentCheckpoint` back to the
49
+ * resumed node via `ctx.resumeContext.task.metadata`.
50
+ */
51
+ readonly metadata?: Readonly<Record<string, import("./workflowTypes").JsonValue>>;
52
+ }
53
+
54
+ /** Human-readable description surface shown to the reviewer. */
55
+ export interface HumanTaskSubject {
56
+ readonly title: string;
57
+ readonly summary: string;
58
+ readonly attributes?: import("./workflowTypes").JsonValue;
59
+ }
60
+
61
+ /** Identity of the person who made a decision on the task. */
62
+ export interface HumanTaskActor {
63
+ readonly actorId: string;
64
+ readonly displayName?: string;
65
+ }
66
+
67
+ /**
68
+ * Resume context injected into `NodeExecutionContext` when the engine re-activates
69
+ * a previously suspended node. `defineHumanApprovalNode` wraps this with parsed
70
+ * `TDecision`; at the engine layer `decision.value` is `unknown`.
71
+ */
72
+ export interface ResumeContext {
73
+ readonly decision:
74
+ | Readonly<{ kind: "decided"; value: unknown; actor: HumanTaskActor; decidedAt: Date }>
75
+ | Readonly<{ kind: "timed_out"; at: Date }>
76
+ | Readonly<{ kind: "auto_accepted"; at: Date }>;
77
+ readonly delivery: import("./workflowTypes").JsonValue;
78
+ readonly task: HumanTaskHandle;
79
+ }
80
+
81
+ /**
82
+ * Thrown by a node's `execute()` to request durable suspension of the current item.
83
+ * The engine catches this, persists the suspension entry, calls `deliver`, and
84
+ * continues to the next item (per-item semantics).
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * throw new SuspensionRequest({
89
+ * decisionSchema: z.object({ approved: z.boolean() }),
90
+ * timeout: "PT24H",
91
+ * onTimeout: "halt",
92
+ * subject: { title: "Approve invoice", summary: "Invoice #1234 needs approval" },
93
+ * deliver: async (handle) => {
94
+ * await notifySlack(handle);
95
+ * return { channel: "slack", ts: "..." };
96
+ * },
97
+ * });
98
+ * ```
99
+ */
100
+ export class SuspensionRequest<
101
+ TDecision = unknown,
102
+ TDelivery extends import("./workflowTypes").JsonValue = import("./workflowTypes").JsonValue,
103
+ > extends Error {
104
+ constructor(
105
+ readonly request: Readonly<{
106
+ decisionSchema: ZodType<TDecision>;
107
+ timeout: Duration;
108
+ onTimeout: "halt" | "auto-accept";
109
+ subject: HumanTaskSubject;
110
+ metadata?: Readonly<Record<string, import("./workflowTypes").JsonValue>>;
111
+ deliver: (handle: HumanTaskHandle) => Promise<TDelivery>;
112
+ }>,
113
+ ) {
114
+ // Extending Error so wrappers like InProcessRetryRunner preserve identity
115
+ // (`instanceof SuspensionRequest`) instead of coercing via String(thrown).
116
+ super(`SuspensionRequest(${request.subject?.title ?? "untitled"})`);
117
+ this.name = "SuspensionRequest";
118
+ }
119
+ }
120
+
22
121
  import type {
23
122
  ActivationIdFactory,
24
123
  BinaryAttachment,
@@ -183,6 +282,14 @@ export interface ExecutionContext {
183
282
  * Collections registered in the codemation config, keyed by collection name.
184
283
  */
185
284
  readonly collections?: CollectionsContext;
285
+ /**
286
+ * Resolve a DI token from the host container.
287
+ * Allows nodes to reach host-side services (e.g. `InboxChannelResolverToken`)
288
+ * without importing host code. Wired by `DefaultExecutionContextFactory`; throws
289
+ * a clear error when no resolver is configured (e.g. in unit tests that don't
290
+ * set up the full container).
291
+ */
292
+ resolve<T>(token: TypeToken<T>): T;
186
293
  }
187
294
 
188
295
  export interface ExecutionContextFactory {
@@ -208,6 +315,11 @@ export interface NodeExecutionContext<TConfig extends NodeConfigBase = NodeConfi
208
315
  config: TConfig;
209
316
  telemetry: NodeExecutionTelemetry;
210
317
  binary: NodeBinaryAttachmentService;
318
+ /**
319
+ * Present when this node activation is a HITL resume.
320
+ * The node checks `ctx.resumeContext !== undefined` and takes the resume branch.
321
+ */
322
+ resumeContext?: ResumeContext;
211
323
  }
212
324
 
213
325
  export interface PollingTriggerHandle {
@@ -0,0 +1,61 @@
1
+ import type { OAuthMaterial } from "./OAuthFlowExecutor.types";
2
+
3
+ /**
4
+ * Material provider seam — see `docs/design/credentials-oauth-unification.md`,
5
+ * "Material provider seam" section. Sits beside the workspace's
6
+ * `CredentialStore`; persistence of the row stays at the store, persistence of
7
+ * the bytes goes through this provider so they can live at the control plane
8
+ * in managed mode.
9
+ */
10
+
11
+ /**
12
+ * Pointer to material bytes. For local rows `ref` is the workspace instance id
13
+ * and the bytes co-locate with the row (existing `CredentialOAuth2Material` /
14
+ * `CredentialSecretMaterial` tables). For control-plane rows `ref` is the
15
+ * CP-side credential id; the workspace stores only the pointer.
16
+ */
17
+ export type CredentialMaterialRef = Readonly<{
18
+ source: "local" | "control-plane";
19
+ id: string;
20
+ }>;
21
+
22
+ /**
23
+ * Decrypted material bytes returned by a provider. Shape matches
24
+ * `OAuthMaterial` — every supported credential type today is OAuth-shaped.
25
+ */
26
+ export type MaterialBundle = OAuthMaterial;
27
+
28
+ /**
29
+ * Caller context recorded by the CP material endpoint per fetch (D5 in the
30
+ * `credentials-vault` sprint README). The local provider accepts but ignores
31
+ * it; standalone mode has no audit log.
32
+ */
33
+ export type CallerContext = Readonly<{
34
+ workspaceId: string;
35
+ caller:
36
+ | Readonly<{ kind: "workflow-node"; workflowId: string; nodeId: string }>
37
+ | Readonly<{ kind: "concierge"; chatId: string }>
38
+ | Readonly<{ kind: "research-agent"; chatId: string }>
39
+ | Readonly<{ kind: "manual"; userId: string }>;
40
+ reason?: string;
41
+ }>;
42
+
43
+ export interface CredentialMaterialProvider {
44
+ getMaterial(ref: CredentialMaterialRef, context: CallerContext): Promise<MaterialBundle>;
45
+ setMaterial(ref: CredentialMaterialRef, material: MaterialBundle): Promise<void>;
46
+ }
47
+
48
+ /**
49
+ * Thrown by a provider when asked to operate on a `ref.source` it does not
50
+ * handle (e.g. the local provider being asked to read `control-plane` bytes).
51
+ * Exported so `instanceof`-checks work across the workspace boundary.
52
+ */
53
+ export class IllegalMaterialSourceError extends Error {
54
+ constructor(
55
+ public readonly source: CredentialMaterialRef["source"],
56
+ public readonly providerName: string,
57
+ ) {
58
+ super(`Provider "${providerName}" cannot handle material source "${source}".`);
59
+ this.name = "IllegalMaterialSourceError";
60
+ }
61
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Thrown by managed-mode providers when `setMaterial` is called. Managed
3
+ * credential bytes are owned by the control plane; the workspace must not
4
+ * mutate them. See `docs/design/credentials-oauth-unification.md` and
5
+ * `planning/sprints/credentials-vault/02-controlplane-material-provider.md`.
6
+ */
7
+ export class ManagedCredentialMaterialWriteError extends Error {
8
+ constructor(
9
+ message: string = "managed credentials are owned by the control plane; use the Connected apps page to create or modify them.",
10
+ ) {
11
+ super(message);
12
+ this.name = "ManagedCredentialMaterialWriteError";
13
+ }
14
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Thrown by `ControlPlaneCredentialMaterialProvider` when the control-plane
3
+ * material endpoint returns a non-2xx response or a malformed body. Exposes
4
+ * the HTTP status and the raw error body so call sites can surface actionable
5
+ * detail without parsing strings.
6
+ */
7
+ export class ManagedMaterialFetchError extends Error {
8
+ constructor(
9
+ public readonly status: number,
10
+ public readonly providerErrorBody: string,
11
+ message?: string,
12
+ ) {
13
+ super(message ?? `Control-plane material fetch failed: HTTP ${status} ${providerErrorBody}`);
14
+ this.name = "ManagedMaterialFetchError";
15
+ }
16
+ }
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  ConnectionInvocationRecord,
3
3
  EngineRunCounters,
4
+ PendingResumeEntry,
5
+ PersistedSuspensionEntry,
4
6
  PreparedNodeActivationDispatch,
5
7
  NodeActivationRequest,
6
8
  NodeActivationScheduler,
@@ -45,6 +47,16 @@ export type ActivationEnqueueRequest = {
45
47
  planner: RunQueuePlanner;
46
48
  engineCounters?: EngineRunCounters;
47
49
  connectionInvocations?: ReadonlyArray<ConnectionInvocationRecord>;
50
+ /**
51
+ * Remaining suspension entries after consuming one for a HITL resume.
52
+ * When provided, saved alongside the new pending state so they survive the enqueue.
53
+ */
54
+ suspension?: ReadonlyArray<PersistedSuspensionEntry>;
55
+ /**
56
+ * Resume context to attach to the re-activated node's execution context.
57
+ * Written here and consumed by `NodeExecutionRequestHandlerService` when building ctx.
58
+ */
59
+ pendingResume?: PendingResumeEntry;
48
60
  };
49
61
 
50
62
  export class ActivationEnqueueService {
@@ -114,6 +126,10 @@ export class ActivationEnqueueService {
114
126
  ...args.previousNodeSnapshotsByNodeId,
115
127
  [args.request.nodeId]: queuedSnapshot,
116
128
  },
129
+ // HITL: preserve suspension entries and resume context when re-activating a
130
+ // suspended node. Omit fields when not provided (avoids polluting normal enqueue).
131
+ ...(args.suspension !== undefined ? { suspension: args.suspension } : {}),
132
+ ...(args.pendingResume !== undefined ? { pendingResume: args.pendingResume } : {}),
117
133
  });
118
134
  await this.dispatchPreparedActivation(preparedDispatch);
119
135
  return {
@@ -6,6 +6,7 @@ import type {
6
6
  ExecutionContextFactory,
7
7
  ExecutionTelemetryFactory,
8
8
  NodeExecutionStatePublisher,
9
+ NodeResolver,
9
10
  ParentExecutionRef,
10
11
  RunDataSnapshot,
11
12
  RunId,
@@ -13,6 +14,7 @@ import type {
13
14
  WorkflowId,
14
15
  } from "../types";
15
16
  import { NoOpCostTrackingTelemetryFactory, NoOpExecutionTelemetryFactory } from "../types";
17
+ import type { TypeToken } from "../di";
16
18
 
17
19
  import {
18
20
  DefaultExecutionBinaryService,
@@ -29,6 +31,7 @@ export class DefaultExecutionContextFactory implements ExecutionContextFactory {
29
31
  private readonly costTrackingFactory: CostTrackingTelemetryFactory = new NoOpCostTrackingTelemetryFactory(),
30
32
  private readonly currentDate: () => Date = () => new Date(),
31
33
  private readonly collections?: CollectionsContext,
34
+ private readonly nodeResolver?: NodeResolver,
32
35
  ) {}
33
36
 
34
37
  create(args: {
@@ -72,6 +75,14 @@ export class DefaultExecutionContextFactory implements ExecutionContextFactory {
72
75
  getCredential: args.getCredential,
73
76
  testContext: args.testContext,
74
77
  collections: this.collections,
78
+ resolve: <T>(token: TypeToken<T>): T => {
79
+ if (!this.nodeResolver) {
80
+ throw new Error(
81
+ "ExecutionContext.resolve() is not available: no NodeResolver was provided to DefaultExecutionContextFactory.",
82
+ );
83
+ }
84
+ return this.nodeResolver.resolve(token);
85
+ },
75
86
  };
76
87
  }
77
88
  }
@@ -2,6 +2,7 @@ import type {
2
2
  JsonValue,
3
3
  NodeActivationId,
4
4
  NodeExecutionSnapshot,
5
+ NodeExecutionStatus,
5
6
  NodeId,
6
7
  NodeInputsByPort,
7
8
  NodeOutputs,
@@ -70,6 +71,11 @@ export class NodeExecutionSnapshotFactory {
70
71
  inputsByPort: NodeInputsByPort;
71
72
  outputs: NodeOutputs;
72
73
  fromPinnedOutput?: boolean;
74
+ /** Override the terminal status for HITL outcomes (defaults to `"completed"`). */
75
+ hitlStatus?: Extract<
76
+ NodeExecutionStatus,
77
+ "hitl-approved" | "hitl-rejected" | "hitl-timeout" | "hitl-auto-accepted" | "hitl-cancelled"
78
+ >;
73
79
  }): NodeExecutionSnapshot {
74
80
  const fromPinnedOutput = args.fromPinnedOutput ?? false;
75
81
  const startedAt = fromPinnedOutput ? (args.previous?.startedAt ?? args.finishedAt) : args.previous?.startedAt;
@@ -79,7 +85,7 @@ export class NodeExecutionSnapshotFactory {
79
85
  nodeId: args.nodeId,
80
86
  activationId: args.activationId,
81
87
  parent: args.parent,
82
- status: "completed",
88
+ status: args.hitlStatus ?? "completed",
83
89
  queuedAt: args.previous?.queuedAt,
84
90
  startedAt,
85
91
  finishedAt: args.finishedAt,
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { isPortsEmission, isUnbrandedPortsEmissionShape } from "../contracts/emitPorts";
3
3
  import { CredentialUnboundError } from "../contracts/credentialTypes";
4
+ import { SuspensionRequest } from "../contracts/runtimeTypes";
4
5
 
5
6
  import type {
6
7
  Item,
@@ -8,9 +9,11 @@ import type {
8
9
  NodeActivationRequest,
9
10
  NodeExecutionContext,
10
11
  NodeOutputs,
12
+ PersistedRunState,
11
13
  RunnableNode,
12
14
  RunnableNodeConfig,
13
15
  RunnableNodeExecuteArgs,
16
+ RunId,
14
17
  TriggerNode,
15
18
  WorkflowNodeInstanceFactory,
16
19
  } from "../types";
@@ -20,6 +23,8 @@ import { FanInMergeByOriginMerger } from "./FanInMergeByOriginMerger";
20
23
  import { ItemExprResolver } from "./ItemExprResolver";
21
24
  import { InProcessRetryRunner } from "./InProcessRetryRunner";
22
25
  import { NodeOutputNormalizer } from "./NodeOutputNormalizer";
26
+ import { NodeSuspensionHandler } from "./NodeSuspensionHandler";
27
+ import { RunSuspendedError } from "./RunSuspendedError";
23
28
  import { RunnableOutputBehaviorResolver } from "./RunnableOutputBehaviorResolver";
24
29
 
25
30
  export class NodeExecutor {
@@ -33,6 +38,10 @@ export class NodeExecutor {
33
38
  private readonly retryRunner: InProcessRetryRunner,
34
39
  itemExprResolver?: ItemExprResolver,
35
40
  outputBehaviorResolver?: RunnableOutputBehaviorResolver,
41
+ /** Required for HITL suspension support. When omitted, `SuspensionRequest` throws upward. */
42
+ private readonly suspensionHandler?: NodeSuspensionHandler,
43
+ /** Required alongside `suspensionHandler`. */
44
+ private readonly loadRunState?: (runId: RunId) => Promise<PersistedRunState | undefined>,
36
45
  ) {
37
46
  this.itemExprResolver = itemExprResolver ?? new ItemExprResolver();
38
47
  this.outputBehaviorResolver = outputBehaviorResolver ?? new RunnableOutputBehaviorResolver();
@@ -173,6 +182,7 @@ export class NodeExecutor {
173
182
  }) as NodeOutputs;
174
183
  }
175
184
  const byPort: Partial<Record<string, Item[]>> = {};
185
+ let hasSuspension = false;
176
186
  for (let i = 0; i < inputBatch.length; i++) {
177
187
  const item = inputBatch[i] as Item;
178
188
  this.assertItemJsonNotTopLevelArray(request.nodeId, item);
@@ -194,7 +204,51 @@ export class NodeExecutor {
194
204
  items: inputBatch,
195
205
  ctx: iterationCtx,
196
206
  };
197
- const raw = await Promise.resolve(node.execute(args));
207
+ let raw: unknown;
208
+ try {
209
+ raw = await Promise.resolve(node.execute(args));
210
+ } catch (e) {
211
+ // Use both instanceof AND name check: under tsx/dev with mixed source/dist resolution,
212
+ // SuspensionRequest may load as two distinct class objects and instanceof fails. The
213
+ // name brand survives the duality because both copies set name="SuspensionRequest".
214
+ const isSuspension =
215
+ e instanceof SuspensionRequest ||
216
+ (e instanceof Error &&
217
+ e.name === "SuspensionRequest" &&
218
+ typeof (e as { request?: unknown }).request === "object");
219
+ if (isSuspension) {
220
+ if (!this.suspensionHandler || !this.loadRunState) {
221
+ // Suspension not supported in this executor configuration — propagate as a regular error.
222
+ throw new Error(
223
+ `Node ${request.nodeId} threw SuspensionRequest but this NodeExecutor has no suspensionHandler configured.`,
224
+ { cause: e },
225
+ );
226
+ }
227
+ // Per-item suspension: load current state, persist the suspension entry, and
228
+ // continue processing remaining items. If deliver throws it propagates upward.
229
+ const state = await this.loadRunState(request.runId);
230
+ if (!state) {
231
+ throw new Error(`NodeExecutor: run state not found for runId ${request.runId} during suspension`, {
232
+ cause: e,
233
+ });
234
+ }
235
+ // handleSuspension throws RunSuspendedError after persisting — we re-throw it
236
+ // to exit the loop immediately. Partial byPort outputs are intentionally dropped
237
+ // (TODO: consider stashing outputs of non-suspended items alongside the suspension).
238
+ await this.suspensionHandler.handle({
239
+ runId: request.runId,
240
+ nodeId: request.nodeId,
241
+ activationId: request.activationId,
242
+ itemIndex: i,
243
+ suspensionRequest: e as SuspensionRequest,
244
+ state,
245
+ telemetry: iterationCtx.telemetry,
246
+ });
247
+ hasSuspension = true; // unreachable — handler always throws, but satisfies TS control-flow
248
+ continue;
249
+ }
250
+ throw e;
251
+ }
198
252
  const normalized = this.outputNormalizer.normalizeExecuteResult({
199
253
  baseItem: item,
200
254
  raw,
@@ -209,6 +263,11 @@ export class NodeExecutor {
209
263
  byPort[port] = list;
210
264
  }
211
265
  }
266
+ if (hasSuspension) {
267
+ // Unreachable in practice (suspensionHandler always throws RunSuspendedError) but
268
+ // guards against future refactors that might change handler behaviour.
269
+ throw new RunSuspendedError(request.runId, "unknown");
270
+ }
212
271
  return byPort as NodeOutputs;
213
272
  }
214
273
 
@@ -1,7 +1,8 @@
1
- import type { WorkflowNodeInstanceFactory } from "../types";
1
+ import type { PersistedRunState, RunId, WorkflowNodeInstanceFactory } from "../types";
2
2
 
3
3
  import { InProcessRetryRunner } from "./InProcessRetryRunner";
4
4
  import { NodeExecutor } from "./NodeExecutor";
5
+ import { NodeSuspensionHandler } from "./NodeSuspensionHandler";
5
6
  import { RunnableOutputBehaviorResolver } from "./RunnableOutputBehaviorResolver";
6
7
 
7
8
  export class NodeExecutorFactory {
@@ -9,7 +10,16 @@ export class NodeExecutorFactory {
9
10
  workflowNodeInstanceFactory: WorkflowNodeInstanceFactory,
10
11
  retryRunner: InProcessRetryRunner,
11
12
  outputBehaviorResolver: RunnableOutputBehaviorResolver,
13
+ suspensionHandler?: NodeSuspensionHandler,
14
+ loadRunState?: (runId: RunId) => Promise<PersistedRunState | undefined>,
12
15
  ): NodeExecutor {
13
- return new NodeExecutor(workflowNodeInstanceFactory, retryRunner, undefined, outputBehaviorResolver);
16
+ return new NodeExecutor(
17
+ workflowNodeInstanceFactory,
18
+ retryRunner,
19
+ undefined,
20
+ outputBehaviorResolver,
21
+ suspensionHandler,
22
+ loadRunState,
23
+ );
14
24
  }
15
25
  }