@stigmer/runner 3.2.2 → 3.2.3
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/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +27 -2
- package/dist/activities/execute-cursor/approval-state.js +13 -1
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +38 -0
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +28 -12
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +26 -0
- package/dist/activities/execute-cursor/message-translator.js +78 -2
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +45 -0
- package/dist/activities/execute-cursor/session-lifecycle.js +41 -0
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.js +15 -2
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
- package/dist/activities/execute-deep-agent/hitl.d.ts +25 -0
- package/dist/activities/execute-deep-agent/hitl.js +48 -1
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +10 -1
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +14 -0
- package/dist/activities/execute-deep-agent/setup.js +12 -1
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +8 -0
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +6 -0
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -0
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/activities/workflow-event-activities.js +9 -0
- package/dist/activities/workflow-event-activities.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +20 -0
- package/dist/middleware/approval-gate.js +16 -1
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/shared/approval-policy.d.ts +24 -1
- package/dist/shared/approval-policy.js +35 -1
- package/dist/shared/approval-policy.js.map +1 -1
- package/dist/shared/with-timeout.d.ts +10 -0
- package/dist/shared/with-timeout.js +14 -1
- package/dist/shared/with-timeout.js.map +1 -1
- package/dist/workflow-engine/tasks/human-input.js +1 -0
- package/dist/workflow-engine/tasks/human-input.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +22 -0
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/human-input-orchestrator.d.ts +2 -1
- package/dist/workflows/human-input-orchestrator.js +2 -1
- package/dist/workflows/human-input-orchestrator.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/__tests__/workflow-event-activities.test.ts +29 -0
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +6 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +105 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +54 -0
- package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +122 -1
- package/src/activities/execute-cursor/approval-state.ts +32 -1
- package/src/activities/execute-cursor/hook-script.ts +40 -0
- package/src/activities/execute-cursor/index.ts +27 -14
- package/src/activities/execute-cursor/message-translator.ts +82 -1
- package/src/activities/execute-cursor/session-lifecycle.ts +76 -0
- package/src/activities/execute-cursor/turn-boundary.ts +23 -0
- package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +103 -2
- package/src/activities/execute-deep-agent/hitl.ts +51 -0
- package/src/activities/execute-deep-agent/index.ts +11 -1
- package/src/activities/execute-deep-agent/setup.ts +27 -0
- package/src/activities/execute-deep-agent/status-builder-shared.ts +8 -0
- package/src/activities/execute-deep-agent/status-builder.ts +7 -0
- package/src/activities/execute-deep-agent/v3-status-builder.ts +7 -0
- package/src/activities/workflow-event-activities.ts +9 -0
- package/src/middleware/__tests__/approval-gate.test.ts +95 -0
- package/src/middleware/approval-gate.ts +37 -0
- package/src/shared/__tests__/with-timeout.test.ts +16 -1
- package/src/shared/approval-policy.ts +41 -2
- package/src/shared/with-timeout.ts +15 -1
- package/src/workflow-engine/__tests__/tasks/human-input.test.ts +40 -0
- package/src/workflow-engine/tasks/human-input.ts +1 -0
- package/src/workflow-engine/types.ts +23 -0
- package/src/workflows/human-input-orchestrator.ts +2 -1
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
ArtifactCreatedPayloadSchema,
|
|
35
35
|
HumanInputOutcomeInfoSchema,
|
|
36
36
|
} from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/event_pb";
|
|
37
|
+
import { ApiResourceAuditActorSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/status_pb";
|
|
37
38
|
import { WorkflowExecutionStatusSchema, WorkflowTaskSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
|
|
38
39
|
import {
|
|
39
40
|
WorkflowExecutionUpdateStatusInputSchema,
|
|
@@ -309,6 +310,14 @@ export function toProtoEvent(desc: WorkflowEventDescriptor): WorkflowExecutionEv
|
|
|
309
310
|
case: "approvalResolved",
|
|
310
311
|
value: create(ApprovalResolvedPayloadSchema, {
|
|
311
312
|
resolvedBy: desc.resolvedBy,
|
|
313
|
+
resolvedByActor: desc.resolvedByActor
|
|
314
|
+
? create(ApiResourceAuditActorSchema, {
|
|
315
|
+
id: desc.resolvedByActor.id,
|
|
316
|
+
displayName: desc.resolvedByActor.display_name ?? "",
|
|
317
|
+
email: desc.resolvedByActor.email ?? "",
|
|
318
|
+
avatar: desc.resolvedByActor.avatar ?? "",
|
|
319
|
+
})
|
|
320
|
+
: undefined,
|
|
312
321
|
comment: desc.comment,
|
|
313
322
|
waitDurationMs: BigInt(desc.waitDurationMs),
|
|
314
323
|
}),
|
|
@@ -784,4 +784,99 @@ describe("ApprovalGateMiddleware", () => {
|
|
|
784
784
|
expect(result).toBeInstanceOf(ToolMessage);
|
|
785
785
|
expect((result as ToolMessage).content).toContain("unknown action");
|
|
786
786
|
});
|
|
787
|
+
|
|
788
|
+
describe("unattended approval mode (DD-014)", () => {
|
|
789
|
+
const gatedMcpPolicies = new Map<string, MergedToolPolicy>([
|
|
790
|
+
["srv/gated_tool", {
|
|
791
|
+
toolName: "gated_tool",
|
|
792
|
+
mcpServerSlug: "srv",
|
|
793
|
+
requiresApproval: true,
|
|
794
|
+
approvalMessage: "Run gated_tool",
|
|
795
|
+
source: "classifier_default",
|
|
796
|
+
}],
|
|
797
|
+
]);
|
|
798
|
+
|
|
799
|
+
it("skips a gated MCP tool without interrupting and records it in the registry", async () => {
|
|
800
|
+
const unattendedSkips = new Set<string>();
|
|
801
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
802
|
+
policies: gatedMcpPolicies,
|
|
803
|
+
toolServerMap: new Map([["gated_tool", "srv"]]),
|
|
804
|
+
unattended: true,
|
|
805
|
+
unattendedSkips,
|
|
806
|
+
}));
|
|
807
|
+
|
|
808
|
+
const handler = vi.fn(passthrough);
|
|
809
|
+
const result = await mw.wrapToolCall!(
|
|
810
|
+
makeRequest({ name: "gated_tool", args: { target: "prod" } }),
|
|
811
|
+
handler,
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
// Gateway invariant: the side effect never ran, and no interrupt was
|
|
815
|
+
// raised — the turn continues instead of parking WAITING_FOR_APPROVAL.
|
|
816
|
+
expect(handler).not.toHaveBeenCalled();
|
|
817
|
+
expect(mockedInterrupt).not.toHaveBeenCalled();
|
|
818
|
+
expect((result as ToolMessage).content).toContain("skipped automatically");
|
|
819
|
+
expect(unattendedSkips.has("call_abc123")).toBe(true);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("skips a gated built-in (shell) the same way", async () => {
|
|
823
|
+
const unattendedSkips = new Set<string>();
|
|
824
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
825
|
+
unattended: true,
|
|
826
|
+
unattendedSkips,
|
|
827
|
+
}));
|
|
828
|
+
|
|
829
|
+
const handler = vi.fn(passthrough);
|
|
830
|
+
const result = await mw.wrapToolCall!(
|
|
831
|
+
makeRequest({ name: "shell", args: { command: "rm -rf /" } }),
|
|
832
|
+
handler,
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
expect(handler).not.toHaveBeenCalled();
|
|
836
|
+
expect(mockedInterrupt).not.toHaveBeenCalled();
|
|
837
|
+
expect((result as ToolMessage).content).toContain("skipped automatically");
|
|
838
|
+
expect(unattendedSkips.has("call_abc123")).toBe(true);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it("still runs tools the policy chain auto-approved (un-gating stays the operator lever)", async () => {
|
|
842
|
+
// The operator's tool_approval_overrides path: an un-gated tool is
|
|
843
|
+
// absent from the policy map, so unattended mode never touches it.
|
|
844
|
+
const unattendedSkips = new Set<string>();
|
|
845
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
846
|
+
toolServerMap: new Map([["book_appointment", "clinic"]]),
|
|
847
|
+
unattended: true,
|
|
848
|
+
unattendedSkips,
|
|
849
|
+
}));
|
|
850
|
+
|
|
851
|
+
const result = await mw.wrapToolCall!(
|
|
852
|
+
makeRequest({ name: "book_appointment", args: { slot: "mon-10" } }),
|
|
853
|
+
passthrough,
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
expect((result as ToolMessage).content).toBe("tool result");
|
|
857
|
+
expect(unattendedSkips.size).toBe(0);
|
|
858
|
+
expect(mockedInterrupt).not.toHaveBeenCalled();
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it("never emits an execution receipt for an unattended skip (no side effect)", async () => {
|
|
862
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
863
|
+
try {
|
|
864
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
865
|
+
policies: gatedMcpPolicies,
|
|
866
|
+
toolServerMap: new Map([["gated_tool", "srv"]]),
|
|
867
|
+
unattended: true,
|
|
868
|
+
unattendedSkips: new Set<string>(),
|
|
869
|
+
}));
|
|
870
|
+
|
|
871
|
+
await mw.wrapToolCall!(makeRequest({ name: "gated_tool" }), passthrough);
|
|
872
|
+
|
|
873
|
+
const receipts = logSpy.mock.calls
|
|
874
|
+
.map((c) => String(c[0]))
|
|
875
|
+
.filter((line) => line.includes("[hitl-gateway] receipt"));
|
|
876
|
+
expect(receipts).toHaveLength(0);
|
|
877
|
+
} finally {
|
|
878
|
+
logSpy.mockRestore();
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
});
|
|
787
882
|
});
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
type PolicySource,
|
|
52
52
|
POLICY_ENGINE_VERSION,
|
|
53
53
|
resolveApprovalMessage,
|
|
54
|
+
unattendedSkipMessage,
|
|
54
55
|
} from "../shared/approval-policy.js";
|
|
55
56
|
import { toolApprovalCategory, type ToolApprovalCategory } from "../shared/tool-kind.js";
|
|
56
57
|
import { extractFilePath } from "../shared/file-tools.js";
|
|
@@ -121,6 +122,26 @@ export interface ApprovalGateConfig {
|
|
|
121
122
|
* secret; the CONTENT never leaves the workspace). Absent ⇒ nothing recorded.
|
|
122
123
|
*/
|
|
123
124
|
readonly recordBlockedSecret?: (rawPath: string) => void;
|
|
125
|
+
/**
|
|
126
|
+
* Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED):
|
|
127
|
+
* the creating surface — a messaging channel, a guest share — has no
|
|
128
|
+
* approver, so a gated tool is resolved as an automatic SKIP (the model is
|
|
129
|
+
* told to adapt) instead of `interrupt()`. The execution never enters
|
|
130
|
+
* WAITING_FOR_APPROVAL. What is gated is unchanged — only the resolution
|
|
131
|
+
* differs; an operator un-gates a specific tool for the agent via
|
|
132
|
+
* tool_approval_overrides, not by weakening this mode.
|
|
133
|
+
*/
|
|
134
|
+
readonly unattended?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* Registry of tool-call ids this gate auto-skipped under {@link unattended}
|
|
137
|
+
* — the gate is the single WRITER; `reconcileUnattendedSkips` (hitl.ts) is
|
|
138
|
+
* the reader that folds each id into a terminal TOOL_CALL_SKIPPED row with
|
|
139
|
+
* UNATTENDED_SKIP provenance after the stream. In-process, per-execution
|
|
140
|
+
* state keyed by the framework's own tool-call id (direct identity, no
|
|
141
|
+
* matching); inherited verbatim by sub-agent gates so their skips land in
|
|
142
|
+
* the same registry.
|
|
143
|
+
*/
|
|
144
|
+
readonly unattendedSkips?: Set<string>;
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
interface ApprovalDecision {
|
|
@@ -249,6 +270,22 @@ export function createApprovalGateMiddleware(
|
|
|
249
270
|
return await handler(request);
|
|
250
271
|
}
|
|
251
272
|
|
|
273
|
+
// Unattended surfaces (channels, guest shares) have no approver, so a
|
|
274
|
+
// gate that would interrupt() here resolves as an automatic SKIP: the
|
|
275
|
+
// tool does NOT run (the gateway invariant holds — no side effect
|
|
276
|
+
// without a backing authorization), the model is told to adapt in plain
|
|
277
|
+
// language, and the turn continues to normal completion instead of
|
|
278
|
+
// parking in WAITING_FOR_APPROVAL forever. The registry entry lets the
|
|
279
|
+
// post-stream reconciler stamp the terminal SKIPPED row + provenance.
|
|
280
|
+
if (config.unattended) {
|
|
281
|
+
config.unattendedSkips?.add(toolCall.id);
|
|
282
|
+
return new ToolMessage({
|
|
283
|
+
content: unattendedSkipMessage(toolName),
|
|
284
|
+
tool_call_id: toolCall.id,
|
|
285
|
+
name: toolName,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
252
289
|
const approvalRequest = {
|
|
253
290
|
tool_call_id: toolCall.id,
|
|
254
291
|
tool_name: toolName,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { withTimeout } from "../with-timeout.js";
|
|
2
|
+
import { withTimeout, TimeoutError } from "../with-timeout.js";
|
|
3
3
|
|
|
4
4
|
describe("withTimeout", () => {
|
|
5
5
|
it("resolves with the function's result when it completes in time", async () => {
|
|
@@ -14,6 +14,21 @@ describe("withTimeout", () => {
|
|
|
14
14
|
).rejects.toThrow("Cursor agent create timed out after 20ms");
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
it("rejects with a TimeoutError so callers can react to expiry by type", async () => {
|
|
18
|
+
const hang = () => new Promise<never>(() => {});
|
|
19
|
+
await expect(withTimeout(20, "expired", hang)).rejects.toBeInstanceOf(TimeoutError);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("does not wrap the function's own rejection in a TimeoutError", async () => {
|
|
23
|
+
const boom = new Error("underlying failure");
|
|
24
|
+
let caught: unknown;
|
|
25
|
+
await withTimeout(1_000, "should not fire", () => Promise.reject(boom)).catch((err) => {
|
|
26
|
+
caught = err;
|
|
27
|
+
});
|
|
28
|
+
expect(caught).toBe(boom);
|
|
29
|
+
expect(caught).not.toBeInstanceOf(TimeoutError);
|
|
30
|
+
});
|
|
31
|
+
|
|
17
32
|
it("evaluates a lazy message only on expiry", async () => {
|
|
18
33
|
let evaluated = 0;
|
|
19
34
|
const message = () => {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import type { ToolApprovalPolicy } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
|
|
19
19
|
import type { ToolApprovalOverride } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
20
20
|
import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
21
|
-
import { ApprovalAction, ApprovalPolicySource } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
21
|
+
import { ApprovalAction, ApprovalMode, ApprovalPolicySource } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
22
22
|
import { toolApprovalCategory, type ToolApprovalCategory } from "./tool-kind.js";
|
|
23
23
|
import type { ResolvedMcpServer } from "./mcp-resolver.js";
|
|
24
24
|
|
|
@@ -147,6 +147,42 @@ export function deriveActiveLeases(execution: AgentExecution): ActiveLeases {
|
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Whether this execution runs in UNATTENDED approval mode
|
|
152
|
+
* (ExecutionConfig.approval_mode): the creating surface — a messaging
|
|
153
|
+
* channel, a guest share — has no approver, so a gated tool is resolved as
|
|
154
|
+
* an automatic skip (the model is told to adapt) instead of pausing the
|
|
155
|
+
* execution for a decision that can never arrive.
|
|
156
|
+
*
|
|
157
|
+
* The mode changes HOW a gate resolves, never WHAT is gated: the four-level
|
|
158
|
+
* policy merge below is identical in both modes. Both harnesses read the
|
|
159
|
+
* mode through this one helper (the native gate skips instead of
|
|
160
|
+
* interrupting; the Cursor hook records a non-pausing "unattended" denial),
|
|
161
|
+
* so the surfaces can never diverge on what "unattended" means.
|
|
162
|
+
*/
|
|
163
|
+
export function isUnattendedApprovalMode(execution: AgentExecution): boolean {
|
|
164
|
+
return execution.spec?.executionConfig?.approvalMode === ApprovalMode.UNATTENDED;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The tool-result text for an unattended auto-skip — ONE definition for both
|
|
169
|
+
* harnesses (the native gate returns it as the skip ToolMessage; the Cursor
|
|
170
|
+
* turn boundary backfills it onto stamped SKIPPED rows), so the transcript
|
|
171
|
+
* reads identically wherever the skip happened. Deliberately instructs
|
|
172
|
+
* plain-language adaptation with NO tool/approval vocabulary reaching the end
|
|
173
|
+
* user (the channel/guest anti-leak posture).
|
|
174
|
+
*/
|
|
175
|
+
export function unattendedSkipMessage(toolName: string): string {
|
|
176
|
+
return (
|
|
177
|
+
`Tool '${toolName}' requires an approval that is not available in ` +
|
|
178
|
+
`this conversation, so it was skipped automatically. Do not retry ` +
|
|
179
|
+
`it or attempt a workaround. Adapt your plan, and explain to the ` +
|
|
180
|
+
`user in plain language what you could not do and what they can ` +
|
|
181
|
+
`do instead — never mention tools, approvals, or platform ` +
|
|
182
|
+
`mechanics.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
150
186
|
/**
|
|
151
187
|
* Provenance of a gate decision: which policy layer (or decision point) is
|
|
152
188
|
* responsible for the final requires-approval verdict.
|
|
@@ -170,7 +206,8 @@ export type PolicySource =
|
|
|
170
206
|
| "approval_lease" // Layer 4: a run-lifetime scoped lease cleared this action
|
|
171
207
|
| "builtin_category" // Non-MCP built-in gated by the shared tool taxonomy
|
|
172
208
|
| "file_capture" // Capture mode: a git-tracked built-in file edit flows, reviewed post-hoc via the file_review ledger (not gated; audit-only on the shadow receipt)
|
|
173
|
-
| "annotation_destructive_tighten"
|
|
209
|
+
| "annotation_destructive_tighten" // Layer 1 sub-case: connect-time destructiveHint tightener force-gated this MCP tool
|
|
210
|
+
| "unattended_skip"; // Layer 4 resolution: unattended approval mode auto-skipped this gated call (no approver on the creating surface)
|
|
174
211
|
|
|
175
212
|
/**
|
|
176
213
|
* Monotonic identifier of the policy-engine logic that produced a decision,
|
|
@@ -205,6 +242,8 @@ export function toProtoPolicySource(source: PolicySource | undefined): ApprovalP
|
|
|
205
242
|
return ApprovalPolicySource.BUILTIN_CATEGORY;
|
|
206
243
|
case "annotation_destructive_tighten":
|
|
207
244
|
return ApprovalPolicySource.ANNOTATION_DESTRUCTIVE_TIGHTEN;
|
|
245
|
+
case "unattended_skip":
|
|
246
|
+
return ApprovalPolicySource.UNATTENDED_SKIP;
|
|
208
247
|
case "file_capture":
|
|
209
248
|
// Capture-mode flow is never persisted on a gated tool call (the file tool
|
|
210
249
|
// is not gated — it has no WAITING_APPROVAL row); it exists only on the
|
|
@@ -15,6 +15,20 @@
|
|
|
15
15
|
* `AbortSignal.timeout` and pass the signal to the callee instead.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Thrown when the bound expires before `fn` settles. A distinct class so
|
|
20
|
+
* callers can react to expiry specifically (`err instanceof TimeoutError`)
|
|
21
|
+
* instead of sniffing message text — e.g. the agent-resolution transport
|
|
22
|
+
* recovery retries on expiry but must let deterministic failures (auth,
|
|
23
|
+
* validation) propagate untouched.
|
|
24
|
+
*/
|
|
25
|
+
export class TimeoutError extends Error {
|
|
26
|
+
constructor(message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "TimeoutError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
export async function withTimeout<T>(
|
|
19
33
|
ms: number,
|
|
20
34
|
timeoutMessage: string | (() => string),
|
|
@@ -23,7 +37,7 @@ export async function withTimeout<T>(
|
|
|
23
37
|
return new Promise<T>((resolve, reject) => {
|
|
24
38
|
const timer = setTimeout(() => {
|
|
25
39
|
const message = typeof timeoutMessage === "function" ? timeoutMessage() : timeoutMessage;
|
|
26
|
-
reject(new
|
|
40
|
+
reject(new TimeoutError(message));
|
|
27
41
|
}, ms);
|
|
28
42
|
|
|
29
43
|
fn()
|
|
@@ -317,10 +317,50 @@ describe("executeHumanInputTask", () => {
|
|
|
317
317
|
expect(resolved.taskName).toBe("gate");
|
|
318
318
|
expect(resolved.outcome).toBe("reject");
|
|
319
319
|
expect(resolved.resolvedBy).toBe("carol");
|
|
320
|
+
expect(resolved.resolvedByActor).toBeUndefined();
|
|
320
321
|
expect(resolved.autoResolved).toBe(false);
|
|
321
322
|
expect(resolved.waitDurationMs).toBeGreaterThanOrEqual(0);
|
|
322
323
|
});
|
|
323
324
|
|
|
325
|
+
it("carries the reviewer_actor display snapshot into approval_resolved and the task output", async () => {
|
|
326
|
+
const emitted: WorkflowEventDescriptor[][] = [];
|
|
327
|
+
const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
|
|
328
|
+
const reviewerActor = {
|
|
329
|
+
id: "ida_01abc",
|
|
330
|
+
display_name: "Ada Lovelace",
|
|
331
|
+
email: "ada@example.com",
|
|
332
|
+
avatar: "https://example.com/ada.png",
|
|
333
|
+
};
|
|
334
|
+
const awaitFn: AwaitHumanInputFn = async () => ({
|
|
335
|
+
outcome: "approve",
|
|
336
|
+
reviewer: "ida_01abc",
|
|
337
|
+
reviewer_actor: reviewerActor,
|
|
338
|
+
responded_at: "2026-05-20T10:00:00Z",
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const taskDef: HumanInputTaskDef = {
|
|
342
|
+
kind: "human_input",
|
|
343
|
+
humanInput: { prompt: "Confirm?" },
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const state = createState();
|
|
347
|
+
await executeHumanInputTask(taskDef, "gate", state, makeCtx(awaitFn, emitFn));
|
|
348
|
+
|
|
349
|
+
const resolved = emitted[1][0];
|
|
350
|
+
if (resolved.type !== "approval_resolved") throw new Error("unexpected");
|
|
351
|
+
expect(resolved.resolvedBy).toBe("ida_01abc");
|
|
352
|
+
expect(resolved.resolvedByActor).toEqual(reviewerActor);
|
|
353
|
+
|
|
354
|
+
// The whole signal payload IS the task output — the actor lands there
|
|
355
|
+
// for downstream tasks (e.g. notification templates) without copying.
|
|
356
|
+
expect(state.data.gate).toEqual({
|
|
357
|
+
outcome: "approve",
|
|
358
|
+
reviewer: "ida_01abc",
|
|
359
|
+
reviewer_actor: reviewerActor,
|
|
360
|
+
responded_at: "2026-05-20T10:00:00Z",
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
324
364
|
it("does not error when emitEvents is undefined (backward compat)", async () => {
|
|
325
365
|
const awaitFn: AwaitHumanInputFn = async () => ({ outcome: "approve" });
|
|
326
366
|
|
|
@@ -109,6 +109,7 @@ export async function executeHumanInputTask(
|
|
|
109
109
|
occurredAt: new Date().toISOString(),
|
|
110
110
|
outcome: result.outcome,
|
|
111
111
|
resolvedBy: result.reviewer ?? "",
|
|
112
|
+
resolvedByActor: result.reviewer_actor,
|
|
112
113
|
comment: "",
|
|
113
114
|
waitDurationMs: Date.now() - approvalRequestedAt,
|
|
114
115
|
autoResolved: result.auto_resolved ?? false,
|
|
@@ -563,13 +563,33 @@ export interface HumanInputExecutionConfig {
|
|
|
563
563
|
|
|
564
564
|
export interface HumanInputResult {
|
|
565
565
|
readonly outcome: string;
|
|
566
|
+
/** Canonical reviewer identity (identity-account ID). Empty/absent when unattributed. */
|
|
566
567
|
readonly reviewer?: string;
|
|
568
|
+
/**
|
|
569
|
+
* Display snapshot of the reviewer, stamped server-side by the control
|
|
570
|
+
* plane at decision time. Rides the signal opaquely into the task output
|
|
571
|
+
* (for downstream tasks, e.g. notifications) and the approval_resolved
|
|
572
|
+
* event (for UIs). Absent when no attribution exists — never synthesized.
|
|
573
|
+
*/
|
|
574
|
+
readonly reviewer_actor?: HumanInputReviewerActor;
|
|
567
575
|
readonly responded_at?: string;
|
|
568
576
|
readonly form_data?: Record<string, unknown>;
|
|
569
577
|
readonly auto_resolved?: boolean;
|
|
570
578
|
readonly reason?: string;
|
|
571
579
|
}
|
|
572
580
|
|
|
581
|
+
/** Write-time snapshot of the reviewer's display identity. */
|
|
582
|
+
export interface HumanInputReviewerActor {
|
|
583
|
+
/** Canonical identity (matches HumanInputResult.reviewer). */
|
|
584
|
+
readonly id: string;
|
|
585
|
+
/** Human-readable name; empty when unknown. */
|
|
586
|
+
readonly display_name?: string;
|
|
587
|
+
/** Email address; empty when unknown. */
|
|
588
|
+
readonly email?: string;
|
|
589
|
+
/** Avatar URL; empty when unknown. */
|
|
590
|
+
readonly avatar?: string;
|
|
591
|
+
}
|
|
592
|
+
|
|
573
593
|
export interface HumanInputConfig {
|
|
574
594
|
readonly prompt: string;
|
|
575
595
|
readonly outcomes?: HumanInputOutcome[];
|
|
@@ -843,7 +863,10 @@ export interface ApprovalRequestedEvent extends EventBase {
|
|
|
843
863
|
export interface ApprovalResolvedEvent extends EventBase {
|
|
844
864
|
readonly type: "approval_resolved";
|
|
845
865
|
readonly outcome: string;
|
|
866
|
+
/** Canonical reviewer identity. Empty when unattributed. */
|
|
846
867
|
readonly resolvedBy: string;
|
|
868
|
+
/** Display snapshot of the reviewer from the signal payload, if stamped. */
|
|
869
|
+
readonly resolvedByActor?: HumanInputReviewerActor;
|
|
847
870
|
readonly comment: string;
|
|
848
871
|
readonly waitDurationMs: number;
|
|
849
872
|
readonly autoResolved: boolean;
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* - "approve": returns auto-approved output
|
|
9
9
|
* - "deny": returns auto-denied output
|
|
10
10
|
*
|
|
11
|
-
* Signal payload shape:
|
|
11
|
+
* Signal payload shape:
|
|
12
|
+
* { outcome, form_data?, reviewer, reviewer_actor?, responded_at }
|
|
12
13
|
*
|
|
13
14
|
* TEMPORAL SANDBOX: This file runs inside the deterministic workflow isolate.
|
|
14
15
|
*/
|