elasticdash-sdk 0.2.0 → 0.2.5
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/cli.js +86 -1
- package/dist/cli.js.map +1 -1
- package/dist/execution/tool-runner.d.ts.map +1 -1
- package/dist/execution/tool-runner.js +16 -7
- package/dist/execution/tool-runner.js.map +1 -1
- package/dist/index.cjs +161 -11
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/interceptors/http.d.ts.map +1 -1
- package/dist/interceptors/http.js +75 -2
- package/dist/interceptors/http.js.map +1 -1
- package/dist/interceptors/telemetry-push.d.ts +22 -0
- package/dist/interceptors/telemetry-push.d.ts.map +1 -1
- package/dist/interceptors/telemetry-push.js +81 -0
- package/dist/interceptors/telemetry-push.js.map +1 -1
- package/dist/interceptors/tool.d.ts.map +1 -1
- package/dist/interceptors/tool.js +35 -1
- package/dist/interceptors/tool.js.map +1 -1
- package/dist/internals/errors.d.ts +10 -0
- package/dist/internals/errors.d.ts.map +1 -0
- package/dist/internals/errors.js +14 -0
- package/dist/internals/errors.js.map +1 -0
- package/dist/internals/mock-resolver.d.ts +5 -0
- package/dist/internals/mock-resolver.d.ts.map +1 -1
- package/dist/internals/mock-resolver.js +26 -3
- package/dist/internals/mock-resolver.js.map +1 -1
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +9 -0
- package/dist/observability.js.map +1 -1
- package/dist/workflow-runner-worker.js +16 -3
- package/dist/workflow-runner-worker.js.map +1 -1
- package/dist/workflow-runner.d.ts.map +1 -1
- package/dist/workflow-runner.js +11 -0
- package/dist/workflow-runner.js.map +1 -1
- package/docs/partial-mocking.md +191 -0
- package/package.json +1 -1
- package/src/cli.ts +87 -2
- package/src/execution/tool-runner.ts +16 -7
- package/src/index.ts +4 -0
- package/src/interceptors/http.ts +66 -1
- package/src/interceptors/telemetry-push.ts +80 -0
- package/src/interceptors/tool.ts +33 -1
- package/src/internals/errors.ts +14 -0
- package/src/internals/mock-resolver.ts +25 -3
- package/src/observability.ts +10 -0
- package/src/workflow-runner-worker.ts +18 -2
- package/src/workflow-runner.ts +9 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { gunzipSync } from 'node:zlib'
|
|
3
4
|
import type { WorkflowEvent } from '../capture/event.js'
|
|
4
5
|
import { extractSystemPrompt, replaceSystemPrompt, extractUserPrompts, replaceUserPrompt, lookupMockEntry, normaliseMockResult } from '../internals/mock-resolver.js'
|
|
5
6
|
import type { AIMockEntry, UserPromptMockEntry } from '../internals/mock-resolver.js'
|
|
@@ -175,6 +176,85 @@ export function getHttpRunContext(): HttpRunContext | undefined {
|
|
|
175
176
|
return httpRunAls.getStore() ?? (g[GLOBAL_CTX_KEY] as HttpRunContext | undefined)
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Option-A inbound bridge for HTTP-mode workflows.
|
|
181
|
+
*
|
|
182
|
+
* The elasticdash CLI subprocess attaches `x-elasticdash-mock-config` to
|
|
183
|
+
* outbound fetches when `--mock-config-file` is in effect (see
|
|
184
|
+
* `interceptors/http.ts`). Call this once at request entry inside the
|
|
185
|
+
* receiving Next.js / Express / Hono / Fastify handler — the function
|
|
186
|
+
* reads that header, decodes the base64-JSON payload, and seeds an
|
|
187
|
+
* HttpRunContext so every `wrapTool` / `wrapAI` call in the same request
|
|
188
|
+
* honors the mocks. After this, no further per-route plumbing is needed.
|
|
189
|
+
*
|
|
190
|
+
* Behavior:
|
|
191
|
+
* - No header → no-op, normal live execution
|
|
192
|
+
* - Existing HttpRunContext (e.g. from a prior `initHttpRunContext`
|
|
193
|
+
* call for dashboard mode) → merges in the inbound mocks on top
|
|
194
|
+
* - No existing context → creates a fresh one with just the mocks
|
|
195
|
+
*
|
|
196
|
+
* Accepts any object exposing a Headers-like `headers.get(name)`. Works
|
|
197
|
+
* with `NextRequest`, `Request`, and Node's `IncomingMessage` (via
|
|
198
|
+
* `req.headers` as a plain object).
|
|
199
|
+
*/
|
|
200
|
+
export function applyInboundMockConfig(req: unknown): void {
|
|
201
|
+
if (!req || typeof req !== 'object') return
|
|
202
|
+
const HEADER = 'x-elasticdash-mock-config'
|
|
203
|
+
|
|
204
|
+
// Polymorphic read across Request-like, NextRequest, and Node http.
|
|
205
|
+
let raw: string | null | undefined
|
|
206
|
+
const r = req as { headers?: unknown }
|
|
207
|
+
if (r.headers && typeof r.headers === 'object') {
|
|
208
|
+
const h = r.headers as { get?: (name: string) => string | null } & Record<string, string | string[] | undefined>
|
|
209
|
+
if (typeof h.get === 'function') {
|
|
210
|
+
raw = h.get(HEADER)
|
|
211
|
+
} else {
|
|
212
|
+
const v = h[HEADER] ?? h[HEADER.toUpperCase()]
|
|
213
|
+
raw = Array.isArray(v) ? v[0] : v
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!raw) return
|
|
217
|
+
|
|
218
|
+
// Header format:
|
|
219
|
+
// "gz1:<base64-gzip>" — current SDK (>=0.2.5) — gzipped JSON, used to
|
|
220
|
+
// stay under Node's default 8KB header limit when configs include
|
|
221
|
+
// large trace-derived outputs.
|
|
222
|
+
// "<base64-json>" — legacy plain base64 (back-compat with earlier SDKs).
|
|
223
|
+
let payload: { toolMockConfig?: Record<string, ToolMockEntry>; aiMockConfig?: Record<string, AIMockEntry>; strict?: boolean }
|
|
224
|
+
try {
|
|
225
|
+
let json: string
|
|
226
|
+
if (raw.startsWith('gz1:')) {
|
|
227
|
+
json = gunzipSync(Buffer.from(raw.slice(4), 'base64')).toString('utf-8')
|
|
228
|
+
} else {
|
|
229
|
+
json = Buffer.from(raw, 'base64').toString('utf-8')
|
|
230
|
+
}
|
|
231
|
+
payload = JSON.parse(json)
|
|
232
|
+
} catch {
|
|
233
|
+
debugLog(`[elasticdash] applyInboundMockConfig: failed to decode ${HEADER} header`)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const existing = getHttpRunContext()
|
|
238
|
+
const ctx = existing
|
|
239
|
+
? {
|
|
240
|
+
...existing,
|
|
241
|
+
// Merge — inbound mocks layer over any dashboard-supplied ones.
|
|
242
|
+
toolMockConfig: { ...(existing.toolMockConfig ?? {}), ...(payload.toolMockConfig ?? {}) },
|
|
243
|
+
aiMockConfig: { ...(existing.aiMockConfig ?? {}), ...(payload.aiMockConfig ?? {}) },
|
|
244
|
+
}
|
|
245
|
+
: buildContext('inbound-mock', '', [], {}, payload.toolMockConfig, payload.aiMockConfig)
|
|
246
|
+
// `enterWith` scopes the context to the rest of this async chain — i.e.
|
|
247
|
+
// the current HTTP request. Do NOT set GLOBAL_CTX_KEY here: that would
|
|
248
|
+
// leak the mocks into other requests on the same server. Concurrent
|
|
249
|
+
// requests with different mock configs need full per-request isolation.
|
|
250
|
+
httpRunAls.enterWith(ctx)
|
|
251
|
+
|
|
252
|
+
// Strict mode: still a global. Scope it to this request via try/finally
|
|
253
|
+
// in the caller if you need it isolated; for now we set it as long as
|
|
254
|
+
// any inbound request says so. Follow-up: per-request strict on the ctx.
|
|
255
|
+
if (payload.strict === true) g['__ELASTICDASH_STRICT__'] = true
|
|
256
|
+
}
|
|
257
|
+
|
|
178
258
|
/** Returns the frozen WorkflowEvent for the given event id, or undefined if not frozen. */
|
|
179
259
|
export function getHttpFrozenEvent(id: number): WorkflowEvent | undefined {
|
|
180
260
|
return getHttpRunContext()?.frozenEvents.get(id)
|
package/src/interceptors/tool.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { getCurrentTrace } from '../trace-adapter/context.js'
|
|
|
3
3
|
import { rawDateNow } from './side-effects.js'
|
|
4
4
|
import { getHttpRunContext, getHttpFrozenEvent, getHttpToolMock, pushTelemetryEvent, tryAutoInitHttpContext, getObservabilityContext } from './telemetry-push.js'
|
|
5
5
|
import { getEdReplayContext, replayCall } from '../ci/replay.js'
|
|
6
|
+
import { resolveMock } from '../internals/mock-resolver.js'
|
|
6
7
|
import { debugLog } from '../utils/debug.js'
|
|
7
8
|
|
|
8
9
|
const TOOL_WRAPPER_ACTIVE_KEY = '__elasticdash_tool_wrapper_active__'
|
|
@@ -220,10 +221,41 @@ export function wrapTool<Args extends unknown[], R>(
|
|
|
220
221
|
return replayed
|
|
221
222
|
}
|
|
222
223
|
|
|
224
|
+
// Module-imported mock dispatch: check __ELASTICDASH_TOOL_MOCKS__ globals.
|
|
225
|
+
// For HTTP mode the same check happens in the no-capture branch above
|
|
226
|
+
// (getHttpToolMock); in CLI/capture mode we route through resolveMock so
|
|
227
|
+
// the same mock-config-file shape works for both paths. resolveMock also
|
|
228
|
+
// enforces __ELASTICDASH_STRICT__ — when strict + no entry, it throws
|
|
229
|
+
// ElasticdashStrictModeError, which we record as a tool error event so
|
|
230
|
+
// the envelope still carries the failed call for the MCP to surface.
|
|
231
|
+
const start = rawDateNow()
|
|
232
|
+
let mock: { mocked: true; result: unknown } | { mocked: false }
|
|
233
|
+
try {
|
|
234
|
+
mock = resolveMock(name)
|
|
235
|
+
} catch (e) {
|
|
236
|
+
const durationMs = rawDateNow() - start
|
|
237
|
+
const errorEvent = { id, type: 'tool' as const, name, input, output: { error: String(e) }, timestamp: start, durationMs }
|
|
238
|
+
recorder.record(errorEvent)
|
|
239
|
+
if (httpCtx) pushTelemetryEvent(errorEvent)
|
|
240
|
+
if (trace && typeof trace.recordToolCall === 'function') {
|
|
241
|
+
trace.recordToolCall({ name, args: toTraceArgs(input), result: { error: String(e) }, workflowEventId: id, durationMs })
|
|
242
|
+
}
|
|
243
|
+
throw e
|
|
244
|
+
}
|
|
245
|
+
if (mock.mocked) {
|
|
246
|
+
const durationMs = rawDateNow() - start
|
|
247
|
+
const mockEvent = { id, type: 'tool' as const, name, input, output: mock.result, timestamp: start, durationMs }
|
|
248
|
+
recorder.record(mockEvent)
|
|
249
|
+
if (httpCtx) pushTelemetryEvent(mockEvent)
|
|
250
|
+
if (trace && typeof trace.recordToolCall === 'function') {
|
|
251
|
+
trace.recordToolCall({ name, args: toTraceArgs(input), result: mock.result, workflowEventId: id, durationMs })
|
|
252
|
+
}
|
|
253
|
+
return mock.result as R
|
|
254
|
+
}
|
|
255
|
+
|
|
223
256
|
const g = globalThis as Record<string, unknown>
|
|
224
257
|
const prev = g[TOOL_WRAPPER_ACTIVE_KEY]
|
|
225
258
|
g[TOOL_WRAPPER_ACTIVE_KEY] = true
|
|
226
|
-
const start = rawDateNow()
|
|
227
259
|
|
|
228
260
|
try {
|
|
229
261
|
const output = await fn(...args)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when strict mode is active and a tool is called that has no
|
|
3
|
+
* matching mock entry and no replay history. Lets callers (the workflow
|
|
4
|
+
* runner, tests, the MCP) detect strict-mode violations precisely.
|
|
5
|
+
*/
|
|
6
|
+
export class ElasticdashStrictModeError extends Error {
|
|
7
|
+
readonly tool_name: string
|
|
8
|
+
|
|
9
|
+
constructor(toolName: string) {
|
|
10
|
+
super(`tool "${toolName}" called but no mock entry. Add it to live_tools or to mocked_tools.`)
|
|
11
|
+
this.name = 'ElasticdashStrictModeError'
|
|
12
|
+
this.tool_name = toolName
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
* wrapped function calls `resolveMock` / `resolveAIMock` at its entry point.
|
|
7
7
|
* The worker writes the mock config to `__ELASTICDASH_TOOL_MOCKS__` /
|
|
8
8
|
* `__ELASTICDASH_AI_MOCKS__` before the workflow runs and clears it after.
|
|
9
|
+
*
|
|
10
|
+
* Strict mode (`__ELASTICDASH_STRICT__ === true`) makes any non-replay,
|
|
11
|
+
* non-mock tool call throw `ElasticdashStrictModeError`. Replay is enforced
|
|
12
|
+
* upstream by `wrapTool`'s capture context, so by the time `resolveMock`
|
|
13
|
+
* runs we are on the live path and any miss is a strict-mode violation.
|
|
9
14
|
*/
|
|
10
15
|
|
|
16
|
+
import { ElasticdashStrictModeError } from './errors.js'
|
|
17
|
+
|
|
11
18
|
interface ToolMockEntry {
|
|
12
19
|
mode: 'live' | 'mock-all' | 'mock-specific'
|
|
13
20
|
callIndices?: number[]
|
|
@@ -69,12 +76,25 @@ export function normaliseMockResult(value: unknown): unknown {
|
|
|
69
76
|
*/
|
|
70
77
|
export function resolveMock(toolName: string): MockResult {
|
|
71
78
|
const g = globalThis as Record<string, unknown>
|
|
79
|
+
const strict = g['__ELASTICDASH_STRICT__'] === true
|
|
72
80
|
|
|
73
81
|
const mocks = g['__ELASTICDASH_TOOL_MOCKS__'] as Record<string, ToolMockEntry> | undefined
|
|
74
|
-
if (!mocks)
|
|
82
|
+
if (!mocks) {
|
|
83
|
+
if (strict) throw new ElasticdashStrictModeError(toolName)
|
|
84
|
+
return { mocked: false }
|
|
85
|
+
}
|
|
75
86
|
|
|
76
87
|
const entry = mocks[toolName]
|
|
77
|
-
|
|
88
|
+
// `mode: 'live'` is the explicit live-allowlist marker — emitted by the MCP
|
|
89
|
+
// (or hand-set by the user) to say "this tool is in live_tools, run it live
|
|
90
|
+
// and don't trip strict mode." Without an entry at all, strict still throws.
|
|
91
|
+
if (entry && entry.mode === 'live') {
|
|
92
|
+
return { mocked: false }
|
|
93
|
+
}
|
|
94
|
+
if (!entry) {
|
|
95
|
+
if (strict) throw new ElasticdashStrictModeError(toolName)
|
|
96
|
+
return { mocked: false }
|
|
97
|
+
}
|
|
78
98
|
|
|
79
99
|
// Initialise counters map if not yet present
|
|
80
100
|
if (!g['__ELASTICDASH_TOOL_CALL_COUNTERS__']) {
|
|
@@ -96,10 +116,12 @@ export function resolveMock(toolName: string): MockResult {
|
|
|
96
116
|
const data = entry.mockData ?? {}
|
|
97
117
|
return { mocked: true, result: normaliseMockResult(data[callNumber]) }
|
|
98
118
|
}
|
|
99
|
-
// Counter already incremented; this specific call runs live
|
|
119
|
+
// Counter already incremented; this specific call runs live (or trips strict)
|
|
120
|
+
if (strict) throw new ElasticdashStrictModeError(toolName)
|
|
100
121
|
return { mocked: false }
|
|
101
122
|
}
|
|
102
123
|
|
|
124
|
+
if (strict) throw new ElasticdashStrictModeError(toolName)
|
|
103
125
|
return { mocked: false }
|
|
104
126
|
}
|
|
105
127
|
|
package/src/observability.ts
CHANGED
|
@@ -53,6 +53,16 @@ let shutdownRegistered = false
|
|
|
53
53
|
* fall back to 'unknown-workflow'.
|
|
54
54
|
*/
|
|
55
55
|
function resolveDefaultWorkflowName(cwd: string, workflows: ReturnType<typeof scanWorkflows>): string {
|
|
56
|
+
// Explicit override from the caller (e.g. `elasticdash run-workflow <name>`
|
|
57
|
+
// sets ELASTICDASH_WORKFLOW_NAME before initialising observability). When
|
|
58
|
+
// present, this wins — the scanner is only used as a fallback heuristic
|
|
59
|
+
// when no one knows the workflow name yet.
|
|
60
|
+
const envName = process.env.ELASTICDASH_WORKFLOW_NAME
|
|
61
|
+
if (envName && envName.trim()) {
|
|
62
|
+
debugLog(`[elasticdash] Workflow name from ELASTICDASH_WORKFLOW_NAME: ${envName}`)
|
|
63
|
+
return envName.trim()
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
// Filter out SDK utility functions that users commonly export from ed_workflows.ts
|
|
57
67
|
const UTILITY_PREFIXES = ['edStartTrace', 'edEndTrace', 'setElasticDashModule', 'setElasticDash']
|
|
58
68
|
const candidates = workflows.filter(w => !UTILITY_PREFIXES.some(p => w.name === p))
|
|
@@ -27,6 +27,7 @@ import { pathToFileURL } from 'node:url'
|
|
|
27
27
|
import type { TraceHandle } from './trace-adapter/context.js'
|
|
28
28
|
import type { WorkflowEvent } from './capture/event.js'
|
|
29
29
|
import type { AgentState } from './types/agent.js'
|
|
30
|
+
import { ElasticdashStrictModeError } from './internals/errors.js'
|
|
30
31
|
import fs from 'node:fs'
|
|
31
32
|
|
|
32
33
|
const TOOL_WRAPPER_ACTIVE_KEY = '__elasticdash_tool_wrapper_active__'
|
|
@@ -95,6 +96,7 @@ async function loadAndWrapTools(
|
|
|
95
96
|
toolsModulePath: string,
|
|
96
97
|
trace: TraceHandle,
|
|
97
98
|
toolMockConfig?: ToolMockConfig,
|
|
99
|
+
strict?: boolean,
|
|
98
100
|
): Promise<Record<string, (...a: unknown[]) => unknown>> {
|
|
99
101
|
try {
|
|
100
102
|
// Use absolute file URL for ESM import
|
|
@@ -156,6 +158,16 @@ async function loadAndWrapTools(
|
|
|
156
158
|
toolCallCounters[name]++
|
|
157
159
|
}
|
|
158
160
|
|
|
161
|
+
// Strict mode: no replay match, no mock hit → fail closed and
|
|
162
|
+
// record the failed call so envelopes/trace surfaces show why.
|
|
163
|
+
if (strict) {
|
|
164
|
+
const err = new ElasticdashStrictModeError(name)
|
|
165
|
+
const durationMs = rawDateNow() - start
|
|
166
|
+
if (ctx) ctx.recorder.record({ id, type: 'tool', name, input: recordedArgs, output: { error: err.message }, timestamp: start, durationMs })
|
|
167
|
+
trace.recordToolCall({ name, args: recordedArgs, result: { error: err.message }, workflowEventId: id, durationMs })
|
|
168
|
+
throw err
|
|
169
|
+
}
|
|
170
|
+
|
|
159
171
|
const g = globalThis as Record<string, unknown>
|
|
160
172
|
const prev = g[TOOL_WRAPPER_ACTIVE_KEY]
|
|
161
173
|
const restoreWrapperFlag = () => {
|
|
@@ -227,6 +239,8 @@ async function main() {
|
|
|
227
239
|
promptMockConfig?: Record<string, unknown>
|
|
228
240
|
/** Optional user prompt mock config: keyed by original user message text */
|
|
229
241
|
userPromptMockConfig?: Record<string, { mode: 'live' | 'replace-all' | 'replace-specific'; replacement: string; callIndices?: number[] }>
|
|
242
|
+
/** Fail-closed mode: throw ElasticdashStrictModeError for any non-replay, non-mock tool call. */
|
|
243
|
+
strict?: boolean
|
|
230
244
|
}
|
|
231
245
|
try {
|
|
232
246
|
payload = JSON.parse(raw)
|
|
@@ -236,7 +250,7 @@ async function main() {
|
|
|
236
250
|
return
|
|
237
251
|
}
|
|
238
252
|
|
|
239
|
-
const { workflowsModulePath, toolsModulePath, workflowName, args, input, replayMode = false, checkpoint = 0, history = [], agentState, toolMockConfig, aiMockConfig, promptMockConfig, userPromptMockConfig } = payload
|
|
253
|
+
const { workflowsModulePath, toolsModulePath, workflowName, args, input, replayMode = false, checkpoint = 0, history = [], agentState, toolMockConfig, aiMockConfig, promptMockConfig, userPromptMockConfig, strict } = payload
|
|
240
254
|
|
|
241
255
|
const { context, finalise } = startTraceSession()
|
|
242
256
|
setCurrentTrace(context.trace)
|
|
@@ -254,7 +268,7 @@ async function main() {
|
|
|
254
268
|
let wrappedTools: Record<string, (...a: unknown[]) => unknown> = {}
|
|
255
269
|
|
|
256
270
|
if (toolsModulePath) {
|
|
257
|
-
wrappedTools = await loadAndWrapTools(toolsModulePath, context.trace, toolMockConfig)
|
|
271
|
+
wrappedTools = await loadAndWrapTools(toolsModulePath, context.trace, toolMockConfig, strict)
|
|
258
272
|
for (const [name, fn] of Object.entries(wrappedTools)) {
|
|
259
273
|
originalValues[name] = globals[name]
|
|
260
274
|
globals[name] = fn
|
|
@@ -284,6 +298,7 @@ async function main() {
|
|
|
284
298
|
;(globalThis as any).__ELASTICDASH_PROMPT_CALL_COUNTERS__ = {}
|
|
285
299
|
;(globalThis as any).__ELASTICDASH_USER_PROMPT_MOCKS__ = userPromptMockConfig ?? {}
|
|
286
300
|
;(globalThis as any).__ELASTICDASH_USER_PROMPT_CALL_COUNTERS__ = {}
|
|
301
|
+
;(globalThis as any).__ELASTICDASH_STRICT__ = strict === true
|
|
287
302
|
|
|
288
303
|
await installDBAutoInterceptor()
|
|
289
304
|
installAIInterceptor()
|
|
@@ -350,6 +365,7 @@ async function main() {
|
|
|
350
365
|
delete (globalThis as any).__ELASTICDASH_PROMPT_CALL_COUNTERS__
|
|
351
366
|
delete (globalThis as any).__ELASTICDASH_USER_PROMPT_MOCKS__
|
|
352
367
|
delete (globalThis as any).__ELASTICDASH_USER_PROMPT_CALL_COUNTERS__
|
|
368
|
+
delete (globalThis as any).__ELASTICDASH_STRICT__
|
|
353
369
|
}
|
|
354
370
|
|
|
355
371
|
await recorder.flush()
|
package/src/workflow-runner.ts
CHANGED
|
@@ -47,6 +47,15 @@ export async function runWorkflow<T = unknown>(
|
|
|
47
47
|
const trace = recorder.toTrace()
|
|
48
48
|
await maybeCaptureTrace(trace.events, trace.traceId)
|
|
49
49
|
return { result, trace }
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// Attach the partial trace so the caller can include any events recorded
|
|
52
|
+
// before the throw (e.g. strict-mode tool calls that fail-closed).
|
|
53
|
+
try {
|
|
54
|
+
await recorder.flush()
|
|
55
|
+
const trace = recorder.toTrace()
|
|
56
|
+
;(e as { trace?: WorkflowTrace }).trace = trace
|
|
57
|
+
} catch { /* best-effort */ }
|
|
58
|
+
throw e
|
|
50
59
|
} finally {
|
|
51
60
|
if (interceptHttp) restoreFetch()
|
|
52
61
|
if (interceptSideEffects) {
|