@stigmer/runner 3.1.9 → 3.1.11

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 (55) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/execute-cursor/model-pricing-data.d.ts +5 -8
  4. package/dist/activities/execute-cursor/model-pricing-data.js +7 -22
  5. package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
  6. package/dist/activities/promote-task-output.d.ts +6 -1
  7. package/dist/activities/promote-task-output.js +10 -5
  8. package/dist/activities/promote-task-output.js.map +1 -1
  9. package/dist/activities/workflow-event-activities.js +10 -1
  10. package/dist/activities/workflow-event-activities.js.map +1 -1
  11. package/dist/config.d.ts +8 -0
  12. package/dist/config.js +4 -1
  13. package/dist/config.js.map +1 -1
  14. package/dist/shared/model-pricing-data.js +2 -8
  15. package/dist/shared/model-pricing-data.js.map +1 -1
  16. package/dist/shared/model-registry.d.ts +2 -1
  17. package/dist/shared/model-registry.js +14 -11
  18. package/dist/shared/model-registry.js.map +1 -1
  19. package/dist/shared/registry-endpoint.d.ts +32 -0
  20. package/dist/shared/registry-endpoint.js +49 -0
  21. package/dist/shared/registry-endpoint.js.map +1 -0
  22. package/dist/workflow-engine/loader.js +4 -0
  23. package/dist/workflow-engine/loader.js.map +1 -1
  24. package/dist/workflow-engine/resolve.js +9 -2
  25. package/dist/workflow-engine/resolve.js.map +1 -1
  26. package/dist/workflow-engine/task-status-accumulator.d.ts +8 -1
  27. package/dist/workflow-engine/task-status-accumulator.js +2 -1
  28. package/dist/workflow-engine/task-status-accumulator.js.map +1 -1
  29. package/dist/workflow-engine/tasks/human-input.d.ts +7 -0
  30. package/dist/workflow-engine/tasks/human-input.js +70 -4
  31. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  32. package/dist/workflow-engine/types.d.ts +19 -1
  33. package/dist/workflow-engine/types.js.map +1 -1
  34. package/dist/workflows/engine-core.js +1 -1
  35. package/dist/workflows/engine-core.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/activities/execute-cursor/model-pricing-data.ts +8 -24
  38. package/src/activities/promote-task-output.ts +10 -4
  39. package/src/activities/workflow-event-activities.ts +11 -2
  40. package/src/config.ts +4 -1
  41. package/src/shared/__tests__/model-registry.test.ts +74 -1
  42. package/src/shared/__tests__/registry-endpoint.test.ts +80 -0
  43. package/src/shared/model-pricing-data.ts +3 -8
  44. package/src/shared/model-registry.ts +17 -11
  45. package/src/shared/registry-endpoint.ts +53 -0
  46. package/src/workflow-engine/__tests__/loader.test.ts +93 -0
  47. package/src/workflow-engine/__tests__/resolve.test.ts +50 -0
  48. package/src/workflow-engine/__tests__/task-status-accumulator.test.ts +35 -0
  49. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +248 -2
  50. package/src/workflow-engine/loader.ts +4 -0
  51. package/src/workflow-engine/resolve.ts +9 -1
  52. package/src/workflow-engine/task-status-accumulator.ts +9 -1
  53. package/src/workflow-engine/tasks/human-input.ts +111 -15
  54. package/src/workflow-engine/types.ts +18 -0
  55. package/src/workflows/engine-core.ts +2 -2
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
2
  import { executeHumanInputTask } from "../../tasks/human-input.js";
3
3
  import { createState } from "../../state.js";
4
+ import { evaluateExpressionBatch } from "../../expression.js";
5
+ import { TaskStatusAccumulator } from "../../task-status-accumulator.js";
4
6
  import type {
5
7
  HumanInputTaskDef,
6
8
  TaskExecutionContext,
@@ -8,14 +10,28 @@ import type {
8
10
  HumanInputResult,
9
11
  AwaitHumanInputFn,
10
12
  EmitEventsFn,
13
+ ExpressionEvaluator,
14
+ PromoteTaskOutputFn,
11
15
  WorkflowEventDescriptor,
12
16
  } from "../../types.js";
13
17
 
14
18
  const notAvailable = () => { throw new Error("not available in test"); };
15
19
 
16
- function makeCtx(awaitFn?: AwaitHumanInputFn, emitFn?: EmitEventsFn): TaskExecutionContext {
20
+ interface CtxOptions {
21
+ readonly awaitFn?: AwaitHumanInputFn;
22
+ readonly emitFn?: EmitEventsFn;
23
+ readonly evaluateExpressions?: ExpressionEvaluator;
24
+ readonly promoteTaskOutput?: PromoteTaskOutputFn;
25
+ readonly taskStatusAccumulator?: TaskStatusAccumulator;
26
+ }
27
+
28
+ function makeCtx(
29
+ awaitFn?: AwaitHumanInputFn,
30
+ emitFn?: EmitEventsFn,
31
+ options?: CtxOptions,
32
+ ): TaskExecutionContext {
17
33
  return {
18
- evaluateExpressions: async () => ({}),
34
+ evaluateExpressions: options?.evaluateExpressions ?? (async () => ({})),
19
35
  doc: { document: { dsl: "1.0.0", name: "test" }, do: [] },
20
36
  sleep: notAvailable,
21
37
  listen: notAvailable,
@@ -27,6 +43,8 @@ function makeCtx(awaitFn?: AwaitHumanInputFn, emitFn?: EmitEventsFn): TaskExecut
27
43
  callFunction: notAvailable,
28
44
  callAgent: notAvailable,
29
45
  emitEvents: emitFn,
46
+ promoteTaskOutput: options?.promoteTaskOutput,
47
+ taskStatusAccumulator: options?.taskStatusAccumulator,
30
48
  };
31
49
  }
32
50
 
@@ -413,4 +431,232 @@ describe("executeHumanInputTask", () => {
413
431
  expect(resolved.outcome).toBe("approve");
414
432
  });
415
433
  });
434
+
435
+ describe("review payload", () => {
436
+ const awaitApprove: AwaitHumanInputFn = async () => ({ outcome: "approve" });
437
+
438
+ function gateWith(payload: unknown, uiHint?: string): HumanInputTaskDef {
439
+ return {
440
+ kind: "human_input",
441
+ humanInput: { prompt: "Review the material", payload, uiHint },
442
+ };
443
+ }
444
+
445
+ /** Captures emitted batches and returns the approval_requested event. */
446
+ function captureRequested(emitted: WorkflowEventDescriptor[][]) {
447
+ const requested = emitted[0].find((e) => e.type === "approval_requested");
448
+ if (!requested || requested.type !== "approval_requested") {
449
+ throw new Error("approval_requested not emitted");
450
+ }
451
+ return requested;
452
+ }
453
+
454
+ it("resolves a whole-value expression payload against workflow state", async () => {
455
+ const emitted: WorkflowEventDescriptor[][] = [];
456
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
457
+
458
+ const state = createState();
459
+ state.context = { draft: { title: "Q3 plan", items: [1, 2, 3] } };
460
+
461
+ await executeHumanInputTask(
462
+ gateWith("${ $context.draft }", "plan-review"),
463
+ "gate", state,
464
+ makeCtx(awaitApprove, emitFn, { evaluateExpressions: evaluateExpressionBatch }),
465
+ );
466
+
467
+ const requested = captureRequested(emitted);
468
+ expect(requested.payload).toEqual({ title: "Q3 plan", items: [1, 2, 3] });
469
+ expect(requested.uiHint).toBe("plan-review");
470
+ expect(requested.payloadArtifactId).toBeUndefined();
471
+ });
472
+
473
+ it("records the uiHint on the task status entry so listPendingApprovals can badge by review type", async () => {
474
+ const accumulator = new TaskStatusAccumulator();
475
+ accumulator.taskStarted("gate", "human_input");
476
+
477
+ await executeHumanInputTask(
478
+ gateWith({ text: "inline" }, "plan-review"),
479
+ "gate", createState(),
480
+ makeCtx(awaitApprove, undefined, {
481
+ evaluateExpressions: evaluateExpressionBatch,
482
+ taskStatusAccumulator: accumulator,
483
+ }),
484
+ );
485
+
486
+ const entry = accumulator.toArray().find((e) => e.taskName === "gate");
487
+ expect(entry?.uiHint).toBe("plan-review");
488
+ });
489
+
490
+ it("resolves embedded expressions inside an inline object payload", async () => {
491
+ const emitted: WorkflowEventDescriptor[][] = [];
492
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
493
+
494
+ const state = createState();
495
+ state.context = { triage: { severity: "P1" } };
496
+
497
+ await executeHumanInputTask(
498
+ gateWith({ summary: "Severity: ${ $context.triage.severity }", static: true }),
499
+ "gate", state,
500
+ makeCtx(awaitApprove, emitFn, { evaluateExpressions: evaluateExpressionBatch }),
501
+ );
502
+
503
+ const requested = captureRequested(emitted);
504
+ expect(requested.payload).toEqual({ summary: "Severity: P1", static: true });
505
+ expect(requested.uiHint).toBeUndefined();
506
+ });
507
+
508
+ it("does not re-evaluate literal ${ } text inside resolved payload data (injection safety)", async () => {
509
+ const emitted: WorkflowEventDescriptor[][] = [];
510
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
511
+
512
+ // External data (e.g. a webhook body) containing literal expression
513
+ // syntax must survive resolution verbatim.
514
+ const state = createState();
515
+ state.context = { article: { body: "Use ${ .secrets.KEY } to authenticate" } };
516
+
517
+ await executeHumanInputTask(
518
+ gateWith("${ $context.article }"),
519
+ "gate", state,
520
+ makeCtx(awaitApprove, emitFn, { evaluateExpressions: evaluateExpressionBatch }),
521
+ );
522
+
523
+ const requested = captureRequested(emitted);
524
+ expect(requested.payload).toEqual({ body: "Use ${ .secrets.KEY } to authenticate" });
525
+ });
526
+
527
+ it("fails the task when payload resolution errors", async () => {
528
+ const failingEvaluator: ExpressionEvaluator = async () => {
529
+ throw new Error("jq: compile error");
530
+ };
531
+
532
+ await expect(
533
+ executeHumanInputTask(
534
+ gateWith("${ $context.draft | invalid_filter }"),
535
+ "gate", createState(),
536
+ makeCtx(awaitApprove, undefined, { evaluateExpressions: failingEvaluator }),
537
+ ),
538
+ ).rejects.toThrow("failed to resolve 'payload' expressions");
539
+ });
540
+
541
+ it("fails the task when payload resolves to null (missing context path)", async () => {
542
+ await expect(
543
+ executeHumanInputTask(
544
+ gateWith("${ $context.does_not_exist }"),
545
+ "gate", createState(),
546
+ makeCtx(awaitApprove, undefined, { evaluateExpressions: evaluateExpressionBatch }),
547
+ ),
548
+ ).rejects.toThrow("'payload' resolved to null");
549
+ });
550
+
551
+ it("promotes the payload and emits an artifact reference instead of inline data", async () => {
552
+ const emitted: WorkflowEventDescriptor[][] = [];
553
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
554
+
555
+ const promoteFn: PromoteTaskOutputFn = vi.fn(async () => ({
556
+ output: { _artifact_ref: "art_review123" },
557
+ artifactIds: ["art_review123"],
558
+ artifactCreatedEvents: [{
559
+ type: "artifact_created" as const,
560
+ artifactId: "art_review123",
561
+ displayName: "gate — review-payload.json",
562
+ contentType: "application/json",
563
+ sizeBytes: 300_000,
564
+ occurredAt: new Date().toISOString(),
565
+ }],
566
+ }));
567
+
568
+ const state = createState();
569
+ state.context = { proposal: { records: ["huge"] } };
570
+
571
+ await executeHumanInputTask(
572
+ gateWith("${ $context.proposal }", "infra-proposal"),
573
+ "gate", state,
574
+ makeCtx(awaitApprove, emitFn, {
575
+ evaluateExpressions: evaluateExpressionBatch,
576
+ promoteTaskOutput: promoteFn,
577
+ }),
578
+ );
579
+
580
+ expect(promoteFn).toHaveBeenCalledWith(
581
+ { records: ["huge"] }, "", "gate", "gate — review-payload.json",
582
+ );
583
+
584
+ const requested = captureRequested(emitted);
585
+ expect(requested.payload).toBeUndefined();
586
+ expect(requested.payloadArtifactId).toBe("art_review123");
587
+ expect(requested.uiHint).toBe("infra-proposal");
588
+
589
+ const artifactEvent = emitted[0].find((e) => e.type === "artifact_created");
590
+ expect(artifactEvent).toBeDefined();
591
+ if (artifactEvent?.type !== "artifact_created") throw new Error("unexpected");
592
+ expect(artifactEvent.artifactId).toBe("art_review123");
593
+ });
594
+
595
+ it("delivers inline when the promotion activity returns no artifacts (below threshold)", async () => {
596
+ const emitted: WorkflowEventDescriptor[][] = [];
597
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
598
+
599
+ const promoteFn: PromoteTaskOutputFn = async (taskOutput) => ({
600
+ output: taskOutput, artifactIds: [], artifactCreatedEvents: [],
601
+ });
602
+
603
+ const state = createState();
604
+ state.context = { note: "small" };
605
+
606
+ await executeHumanInputTask(
607
+ gateWith("${ $context.note }"),
608
+ "gate", state,
609
+ makeCtx(awaitApprove, emitFn, {
610
+ evaluateExpressions: evaluateExpressionBatch,
611
+ promoteTaskOutput: promoteFn,
612
+ }),
613
+ );
614
+
615
+ const requested = captureRequested(emitted);
616
+ expect(requested.payload).toBe("small");
617
+ expect(requested.payloadArtifactId).toBeUndefined();
618
+ });
619
+
620
+ it("falls back to inline delivery when promotion fails (best-effort policy)", async () => {
621
+ const emitted: WorkflowEventDescriptor[][] = [];
622
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
623
+
624
+ const promoteFn: PromoteTaskOutputFn = async () => {
625
+ throw new Error("artifact store unavailable");
626
+ };
627
+
628
+ const state = createState();
629
+ state.context = { doc: { text: "content" } };
630
+
631
+ await executeHumanInputTask(
632
+ gateWith("${ $context.doc }"),
633
+ "gate", state,
634
+ makeCtx(awaitApprove, emitFn, {
635
+ evaluateExpressions: evaluateExpressionBatch,
636
+ promoteTaskOutput: promoteFn,
637
+ }),
638
+ );
639
+
640
+ const requested = captureRequested(emitted);
641
+ expect(requested.payload).toEqual({ text: "content" });
642
+ expect(requested.payloadArtifactId).toBeUndefined();
643
+ });
644
+
645
+ it("omits payload fields entirely when the task config has no payload", async () => {
646
+ const emitted: WorkflowEventDescriptor[][] = [];
647
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
648
+
649
+ const taskDef: HumanInputTaskDef = {
650
+ kind: "human_input",
651
+ humanInput: { prompt: "Simple gate" },
652
+ };
653
+
654
+ await executeHumanInputTask(taskDef, "gate", createState(), makeCtx(awaitApprove, emitFn));
655
+
656
+ const requested = captureRequested(emitted);
657
+ expect(requested.payload).toBeUndefined();
658
+ expect(requested.uiHint).toBeUndefined();
659
+ expect(requested.payloadArtifactId).toBeUndefined();
660
+ });
661
+ });
416
662
  });
@@ -520,5 +520,9 @@ function parseHumanInputConfig(taskName: string, raw: unknown): import("./types.
520
520
  : undefined,
521
521
  timeout: typeof obj.timeout === "number" ? obj.timeout : undefined,
522
522
  onTimeout: (obj.on_timeout as "fail" | "approve" | "deny") ?? undefined,
523
+ // Any JSON shape is a valid payload (expression string, object, array),
524
+ // so only null/undefined mean "no payload" here.
525
+ payload: obj.payload ?? undefined,
526
+ uiHint: typeof obj.ui_hint === "string" && obj.ui_hint ? obj.ui_hint : undefined,
523
527
  };
524
528
  }
@@ -301,8 +301,16 @@ function collectEmbeddedFromValue(
301
301
  batchExprs: Record<string, string>,
302
302
  skipPaths: Set<string>,
303
303
  ): void {
304
+ // A Phase-1-resolved path holds data, not template text: skip its
305
+ // entire subtree, whatever shape it resolved to. A strict expression
306
+ // can resolve to an object or array whose nested strings carry literal
307
+ // `${ ... }` text from external sources (webhook payloads, API
308
+ // responses, documents under review) that must never be re-interpreted
309
+ // as expressions. Checking only string values at the exact path — the
310
+ // previous behavior — left those nested strings exposed.
311
+ if (skipPaths.has(path)) return;
312
+
304
313
  if (typeof value === "string") {
305
- if (skipPaths.has(path)) return;
306
314
  if (isStrictExpr(value)) return;
307
315
  const expressions = extractEmbeddedExpressions(value);
308
316
  if (expressions.length === 0) return;
@@ -25,6 +25,13 @@ export interface TaskStatusEntry {
25
25
  readonly costMicros?: number;
26
26
  readonly inputTokens?: number;
27
27
  readonly outputTokens?: number;
28
+ /**
29
+ * Review-surface hint from the human_input task's ui_hint config.
30
+ * Set on the waiting_approval transition and retained through later
31
+ * transitions (spread semantics), so the persisted status keeps the
32
+ * record of what kind of review the gate presented.
33
+ */
34
+ readonly uiHint?: string;
28
35
  }
29
36
 
30
37
  /**
@@ -177,7 +184,7 @@ export class TaskStatusAccumulator {
177
184
  });
178
185
  }
179
186
 
180
- taskWaitingApproval(name: string): void {
187
+ taskWaitingApproval(name: string, uiHint?: string): void {
181
188
  const existing = this.entries.get(name);
182
189
  this.entries.set(name, {
183
190
  ...existing,
@@ -186,6 +193,7 @@ export class TaskStatusAccumulator {
186
193
  taskKind: existing?.taskKind ?? "",
187
194
  status: "waiting_approval",
188
195
  startedAt: existing?.startedAt,
196
+ uiHint: uiHint ?? existing?.uiHint,
189
197
  });
190
198
  }
191
199
 
@@ -17,6 +17,8 @@
17
17
  * call: human_input
18
18
  * with:
19
19
  * prompt: "Please review this deployment"
20
+ * payload: ${ $context.deployment_plan }
21
+ * ui_hint: deployment-plan
20
22
  * timeout: 300
21
23
  * on_timeout: approve
22
24
  * outcomes:
@@ -24,16 +26,22 @@
24
26
  * - name: deny
25
27
  * - name: revise
26
28
  * then: gatherMore
29
+ *
30
+ * The payload (the material under review) is expression-resolved at gate
31
+ * activation and attached to the approval_requested event — inline when
32
+ * small, as an artifact reference when at/above the promotion threshold —
33
+ * so the approval record captures exactly what the reviewer saw.
27
34
  */
28
35
 
29
36
  import type {
37
+ ArtifactCreatedEvent,
30
38
  HumanInputTaskDef,
31
39
  HumanInputConfig,
32
40
  WorkflowState,
33
41
  TaskExecutionContext,
34
42
  HumanInputResult,
35
43
  } from "../types.js";
36
- import { resolveEmbeddedExpressions } from "../resolve.js";
44
+ import { resolveConfigExpressions, resolveEmbeddedExpressions } from "../resolve.js";
37
45
  import { isStrictExpr } from "../expression-utils.js";
38
46
 
39
47
  const SIGNAL_PREFIX = "human_input_";
@@ -60,25 +68,32 @@ export async function executeHumanInputTask(
60
68
  const resolvedPrompt = await resolvePromptExpressions(
61
69
  config.prompt, state, ctx,
62
70
  );
71
+ const review = await resolveReviewPayload(config, taskName, state, ctx);
63
72
 
64
73
  const approvalRequestedAt = Date.now();
65
74
 
66
- ctx.taskStatusAccumulator?.taskWaitingApproval(taskName);
75
+ ctx.taskStatusAccumulator?.taskWaitingApproval(taskName, config.uiHint);
67
76
 
68
77
  if (ctx.emitEvents) {
69
- await ctx.emitEvents([{
70
- type: "approval_requested",
71
- taskName,
72
- occurredAt: new Date().toISOString(),
73
- prompt: resolvedPrompt,
74
- approvers: config.approvers ?? [],
75
- timeoutSeconds,
76
- outcomes: (config.outcomes ?? []).map((o) => ({
77
- name: o.name,
78
- label: o.label ?? "",
79
- })),
80
- formSchema: config.formSchema,
81
- }]);
78
+ await ctx.emitEvents([
79
+ {
80
+ type: "approval_requested",
81
+ taskName,
82
+ occurredAt: new Date().toISOString(),
83
+ prompt: resolvedPrompt,
84
+ approvers: config.approvers ?? [],
85
+ timeoutSeconds,
86
+ outcomes: (config.outcomes ?? []).map((o) => ({
87
+ name: o.name,
88
+ label: o.label ?? "",
89
+ })),
90
+ formSchema: config.formSchema,
91
+ payload: review.payload,
92
+ uiHint: config.uiHint,
93
+ payloadArtifactId: review.payloadArtifactId,
94
+ },
95
+ ...review.artifactEvents,
96
+ ]);
82
97
  }
83
98
 
84
99
  const result: HumanInputResult = await ctx.awaitHumanInput({
@@ -116,6 +131,87 @@ function validateConfig(config: HumanInputConfig, taskName: string): void {
116
131
  }
117
132
  }
118
133
 
134
+ interface ResolvedReviewPayload {
135
+ /** Resolved payload for inline delivery on the approval_requested event. */
136
+ readonly payload?: unknown;
137
+ /** Artifact holding the payload when it exceeded the promotion threshold. */
138
+ readonly payloadArtifactId?: string;
139
+ /** artifact_created events to emit alongside approval_requested. */
140
+ readonly artifactEvents: readonly ArtifactCreatedEvent[];
141
+ }
142
+
143
+ const NO_REVIEW_PAYLOAD: ResolvedReviewPayload = { artifactEvents: [] };
144
+
145
+ /**
146
+ * Resolves the review payload's `${ ... }` expressions and decides how it
147
+ * rides the approval_requested event: inline when small, or as an artifact
148
+ * reference when at/above the promotion threshold (256KB).
149
+ *
150
+ * Resolution goes through `resolveConfigExpressions` for its two-phase
151
+ * injection safety: review material resolved from `$context` (article
152
+ * drafts, webhook data) may contain literal `${ ... }` text that must
153
+ * never be re-evaluated as an expression.
154
+ *
155
+ * A resolution failure fails the task — a gate whose review material
156
+ * failed to materialize must not present an empty review. Promotion
157
+ * failure falls back to inline delivery (best-effort, matching the
158
+ * do-executor's task-output promotion policy).
159
+ */
160
+ async function resolveReviewPayload(
161
+ config: HumanInputConfig,
162
+ taskName: string,
163
+ state: WorkflowState,
164
+ ctx: TaskExecutionContext,
165
+ ): Promise<ResolvedReviewPayload> {
166
+ if (config.payload === undefined || config.payload === null) {
167
+ return NO_REVIEW_PAYLOAD;
168
+ }
169
+
170
+ let resolved: unknown;
171
+ try {
172
+ const result = await resolveConfigExpressions(
173
+ { payload: config.payload }, null, state, ctx.evaluateExpressions,
174
+ );
175
+ resolved = result.payload;
176
+ } catch (err) {
177
+ const message = err instanceof Error ? err.message : String(err);
178
+ throw new Error(
179
+ `human_input task '${taskName}': failed to resolve 'payload' expressions: ${message}`,
180
+ );
181
+ }
182
+
183
+ // jq resolves missing paths to null rather than erroring. A configured
184
+ // payload that materializes as nothing is an authoring error (typically a
185
+ // wrong $context path) — fail loudly instead of presenting an empty review.
186
+ if (resolved === undefined || resolved === null) {
187
+ throw new Error(
188
+ `human_input task '${taskName}': 'payload' resolved to null — ` +
189
+ `check that the expression references existing workflow data`,
190
+ );
191
+ }
192
+
193
+ if (ctx.promoteTaskOutput) {
194
+ try {
195
+ const promotion = await ctx.promoteTaskOutput(
196
+ resolved,
197
+ state.env?.__stigmer_execution_id as string ?? "",
198
+ taskName,
199
+ `${taskName} — review-payload.json`,
200
+ );
201
+ if (promotion.artifactIds.length > 0) {
202
+ return {
203
+ payloadArtifactId: promotion.artifactIds[0],
204
+ artifactEvents: promotion.artifactCreatedEvents,
205
+ };
206
+ }
207
+ } catch {
208
+ // Promotion is best-effort; fall through to inline delivery.
209
+ }
210
+ }
211
+
212
+ return { payload: resolved, artifactEvents: [] };
213
+ }
214
+
119
215
  /**
120
216
  * Resolves `${ ... }` expressions in the prompt string against the
121
217
  * current workflow state. Handles both strict (whole-value) and embedded
@@ -565,6 +565,14 @@ export interface HumanInputConfig {
565
565
  readonly approvers?: readonly string[];
566
566
  readonly timeout?: number;
567
567
  readonly onTimeout?: "fail" | "approve" | "deny";
568
+ /**
569
+ * Material for the reviewer to examine — a whole-value expression
570
+ * string, or an object/array with embedded expressions. Resolved at
571
+ * gate activation and attached to the approval_requested event.
572
+ */
573
+ readonly payload?: unknown;
574
+ /** Renderer discriminator forwarded verbatim to the approval UI. */
575
+ readonly uiHint?: string;
568
576
  }
569
577
 
570
578
  export interface HumanInputOutcome {
@@ -795,6 +803,14 @@ export interface ApprovalRequestedEvent extends EventBase {
795
803
  readonly timeoutSeconds: number;
796
804
  readonly outcomes: ReadonlyArray<{ name: string; label: string }>;
797
805
  readonly formSchema?: Record<string, unknown>;
806
+ /**
807
+ * Resolved review payload the gate presents. Mutually exclusive with
808
+ * payloadArtifactId — payloads at/above the promotion threshold live
809
+ * in the artifact store and only the reference rides the event.
810
+ */
811
+ readonly payload?: unknown;
812
+ readonly uiHint?: string;
813
+ readonly payloadArtifactId?: string;
798
814
  }
799
815
 
800
816
  export interface ApprovalResolvedEvent extends EventBase {
@@ -885,6 +901,8 @@ export type PromoteTaskOutputFn = (
885
901
  taskOutput: unknown,
886
902
  workflowExecutionId: string,
887
903
  taskName: string,
904
+ /** Artifact display name override; defaults to "<taskName> — output.json". */
905
+ displayName?: string,
888
906
  ) => Promise<PromoteTaskOutputResult>;
889
907
 
890
908
  // ─────────────────────────────────────────────────────────────────────
@@ -243,8 +243,8 @@ export async function runWorkflowEngine(
243
243
  taskName: agentMeta.taskName,
244
244
  workflowExecutionId: agentMeta.workflowExecutionId || executionId,
245
245
  }),
246
- promoteTaskOutput: (taskOutput: unknown, wexId: string, taskName: string) =>
247
- promoteProxy.PromoteTaskOutput(taskOutput, wexId || executionId, taskName),
246
+ promoteTaskOutput: (taskOutput: unknown, wexId: string, taskName: string, displayName?: string) =>
247
+ promoteProxy.PromoteTaskOutput(taskOutput, wexId || executionId, taskName, displayName),
248
248
  };
249
249
 
250
250
  const state = createState();