@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.
- package/README.md +1 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/model-pricing-data.d.ts +5 -8
- package/dist/activities/execute-cursor/model-pricing-data.js +7 -22
- package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
- package/dist/activities/promote-task-output.d.ts +6 -1
- package/dist/activities/promote-task-output.js +10 -5
- package/dist/activities/promote-task-output.js.map +1 -1
- package/dist/activities/workflow-event-activities.js +10 -1
- package/dist/activities/workflow-event-activities.js.map +1 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.js +4 -1
- package/dist/config.js.map +1 -1
- package/dist/shared/model-pricing-data.js +2 -8
- package/dist/shared/model-pricing-data.js.map +1 -1
- package/dist/shared/model-registry.d.ts +2 -1
- package/dist/shared/model-registry.js +14 -11
- package/dist/shared/model-registry.js.map +1 -1
- package/dist/shared/registry-endpoint.d.ts +32 -0
- package/dist/shared/registry-endpoint.js +49 -0
- package/dist/shared/registry-endpoint.js.map +1 -0
- package/dist/workflow-engine/loader.js +4 -0
- package/dist/workflow-engine/loader.js.map +1 -1
- package/dist/workflow-engine/resolve.js +9 -2
- package/dist/workflow-engine/resolve.js.map +1 -1
- package/dist/workflow-engine/task-status-accumulator.d.ts +8 -1
- package/dist/workflow-engine/task-status-accumulator.js +2 -1
- package/dist/workflow-engine/task-status-accumulator.js.map +1 -1
- package/dist/workflow-engine/tasks/human-input.d.ts +7 -0
- package/dist/workflow-engine/tasks/human-input.js +70 -4
- package/dist/workflow-engine/tasks/human-input.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +19 -1
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/engine-core.js +1 -1
- package/dist/workflows/engine-core.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/execute-cursor/model-pricing-data.ts +8 -24
- package/src/activities/promote-task-output.ts +10 -4
- package/src/activities/workflow-event-activities.ts +11 -2
- package/src/config.ts +4 -1
- package/src/shared/__tests__/model-registry.test.ts +74 -1
- package/src/shared/__tests__/registry-endpoint.test.ts +80 -0
- package/src/shared/model-pricing-data.ts +3 -8
- package/src/shared/model-registry.ts +17 -11
- package/src/shared/registry-endpoint.ts +53 -0
- package/src/workflow-engine/__tests__/loader.test.ts +93 -0
- package/src/workflow-engine/__tests__/resolve.test.ts +50 -0
- package/src/workflow-engine/__tests__/task-status-accumulator.test.ts +35 -0
- package/src/workflow-engine/__tests__/tasks/human-input.test.ts +248 -2
- package/src/workflow-engine/loader.ts +4 -0
- package/src/workflow-engine/resolve.ts +9 -1
- package/src/workflow-engine/task-status-accumulator.ts +9 -1
- package/src/workflow-engine/tasks/human-input.ts +111 -15
- package/src/workflow-engine/types.ts +18 -0
- 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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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();
|