elasticdash-sdk 0.2.0 → 0.2.6
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 +99 -3
- 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 +170 -16
- 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/db-auto.d.ts.map +1 -1
- package/dist/interceptors/db-auto.js +16 -8
- package/dist/interceptors/db-auto.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 +100 -4
- package/src/execution/tool-runner.ts +16 -7
- package/src/index.ts +4 -0
- package/src/interceptors/db-auto.ts +20 -8
- 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
package/src/index.ts
CHANGED
|
@@ -26,10 +26,14 @@ export { wrapAI } from './interceptors/workflow-ai.js'
|
|
|
26
26
|
export { edTool, defineTool, getRegisteredTool, getRegisteredTools, clearToolRegistry } from './tool-registry.js'
|
|
27
27
|
export type { RegisteredTool } from './tool-registry.js'
|
|
28
28
|
|
|
29
|
+
// Strict-mode error (thrown by resolveMock + worker Proxy when fail-closed)
|
|
30
|
+
export { ElasticdashStrictModeError } from './internals/errors.js'
|
|
31
|
+
|
|
29
32
|
// HTTP run context (ALS + global fallback for streaming frameworks)
|
|
30
33
|
export {
|
|
31
34
|
setHttpRunContext,
|
|
32
35
|
initHttpRunContext,
|
|
36
|
+
applyInboundMockConfig,
|
|
33
37
|
getHttpRunContext,
|
|
34
38
|
getHttpFrozenEvent,
|
|
35
39
|
getHttpPromptMock,
|
|
@@ -13,6 +13,22 @@ interface MethodPatch {
|
|
|
13
13
|
|
|
14
14
|
const appliedPatches: MethodPatch[] = []
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Bundler-opaque dynamic import for optional peer dependencies. Webpack /
|
|
18
|
+
* turbopack / rollup statically analyze `await import('<literal>')` and try
|
|
19
|
+
* to resolve the target at build time, which fails for consumers that don't
|
|
20
|
+
* have the optional peer installed (e.g. a Next.js app that doesn't use
|
|
21
|
+
* Postgres still gets "Module not found: Can't resolve 'pg'" because this
|
|
22
|
+
* file is reachable from its bundle). Routing the import through
|
|
23
|
+
* `new Function` hides the call from static analysis so resolution happens
|
|
24
|
+
* at runtime in Node, where missing modules throw and the caller's outer
|
|
25
|
+
* Promise.allSettled swallows the rejection — the SDK's intended behavior.
|
|
26
|
+
*/
|
|
27
|
+
const loadOptionalPeer = new Function(
|
|
28
|
+
'specifier',
|
|
29
|
+
'return import(specifier)',
|
|
30
|
+
) as (specifier: string) => Promise<unknown>
|
|
31
|
+
|
|
16
32
|
function toTraceArgs(input: unknown): Record<string, unknown> | undefined {
|
|
17
33
|
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
|
18
34
|
return input as Record<string, unknown>
|
|
@@ -177,8 +193,7 @@ function wrapProtoMethod(proto: object, method: string, eventName: string): void
|
|
|
177
193
|
}
|
|
178
194
|
|
|
179
195
|
async function tryPatchPg(): Promise<void> {
|
|
180
|
-
|
|
181
|
-
const pgMod = await import('pg') as Record<string, unknown>
|
|
196
|
+
const pgMod = (await loadOptionalPeer('pg')) as Record<string, unknown>
|
|
182
197
|
const pg = (pgMod.default as Record<string, unknown> | undefined) ?? pgMod
|
|
183
198
|
const Client = pg.Client as { prototype: object } | undefined
|
|
184
199
|
// Patch Client.prototype only — Pool.query delegates to Client internally
|
|
@@ -188,8 +203,7 @@ async function tryPatchPg(): Promise<void> {
|
|
|
188
203
|
}
|
|
189
204
|
|
|
190
205
|
async function tryPatchMysql2(): Promise<void> {
|
|
191
|
-
|
|
192
|
-
const mod = await import('mysql2/promise') as Record<string, unknown>
|
|
206
|
+
const mod = (await loadOptionalPeer('mysql2/promise')) as Record<string, unknown>
|
|
193
207
|
const mysql2 = (mod.default as Record<string, unknown> | undefined) ?? mod
|
|
194
208
|
const Connection = mysql2.Connection as { prototype: object } | undefined
|
|
195
209
|
if (Connection?.prototype) {
|
|
@@ -199,8 +213,7 @@ async function tryPatchMysql2(): Promise<void> {
|
|
|
199
213
|
}
|
|
200
214
|
|
|
201
215
|
async function tryPatchMongodb(): Promise<void> {
|
|
202
|
-
|
|
203
|
-
const mongMod = await import('mongodb') as Record<string, unknown>
|
|
216
|
+
const mongMod = (await loadOptionalPeer('mongodb')) as Record<string, unknown>
|
|
204
217
|
const Collection = (
|
|
205
218
|
mongMod.Collection ??
|
|
206
219
|
(mongMod.default as Record<string, unknown> | undefined)?.Collection
|
|
@@ -213,8 +226,7 @@ async function tryPatchMongodb(): Promise<void> {
|
|
|
213
226
|
}
|
|
214
227
|
|
|
215
228
|
async function tryPatchIoredis(): Promise<void> {
|
|
216
|
-
|
|
217
|
-
const mod = await import('ioredis') as Record<string, unknown>
|
|
229
|
+
const mod = (await loadOptionalPeer('ioredis')) as Record<string, unknown>
|
|
218
230
|
const Redis = (mod.default ?? mod) as { prototype: object } | undefined
|
|
219
231
|
if (Redis?.prototype) {
|
|
220
232
|
wrapProtoMethod(Redis.prototype, 'call', 'redis.call')
|
package/src/interceptors/http.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gzipSync } from 'node:zlib'
|
|
1
2
|
import { getCaptureContext } from '../capture/recorder.js'
|
|
2
3
|
import { getCurrentTrace } from '../trace-adapter/context.js'
|
|
3
4
|
import { getHttpRunContext, getHttpFrozenEvent, pushTelemetryEvent, tryAutoInitHttpContext, getObservabilityContext } from './telemetry-push.js'
|
|
@@ -271,6 +272,60 @@ async function executeLiveAndRecord(
|
|
|
271
272
|
return { response: res, event }
|
|
272
273
|
}
|
|
273
274
|
|
|
275
|
+
/**
|
|
276
|
+
* When the CLI subprocess seeds mock globals (via `--mock-config-file`),
|
|
277
|
+
* encode them as a base64 JSON string and return the header value. Any
|
|
278
|
+
* outbound fetch that travels into another Node process (e.g. an HTTP-mode
|
|
279
|
+
* workflow calling its own dev server) can carry the mock config along
|
|
280
|
+
* via the `x-elasticdash-mock-config` header. The receiver applies it
|
|
281
|
+
* with `applyInboundMockConfig(req)` and the mocks fire in its process.
|
|
282
|
+
*
|
|
283
|
+
* Returns undefined when no mock state is present.
|
|
284
|
+
*/
|
|
285
|
+
function encodeMockConfigHeader(): string | undefined {
|
|
286
|
+
const g = globalThis as Record<string, unknown>
|
|
287
|
+
const toolMocks = g['__ELASTICDASH_TOOL_MOCKS__'] as Record<string, unknown> | undefined
|
|
288
|
+
const aiMocks = g['__ELASTICDASH_AI_MOCKS__'] as Record<string, unknown> | undefined
|
|
289
|
+
const strict = g['__ELASTICDASH_STRICT__'] === true
|
|
290
|
+
if (!toolMocks && !aiMocks && !strict) return undefined
|
|
291
|
+
const payload: Record<string, unknown> = {}
|
|
292
|
+
if (toolMocks) payload.toolMockConfig = toolMocks
|
|
293
|
+
if (aiMocks) payload.aiMockConfig = aiMocks
|
|
294
|
+
if (strict) payload.strict = true
|
|
295
|
+
try {
|
|
296
|
+
const json = JSON.stringify(payload)
|
|
297
|
+
// Gzip + base64. Real mock configs with trace-derived outputs (full
|
|
298
|
+
// Pokémon details, AI response transcripts, etc.) easily exceed
|
|
299
|
+
// Node's default 8KB HTTP header limit when sent as plain base64.
|
|
300
|
+
// Gzipping shrinks typical configs by ~5-10x and keeps them well
|
|
301
|
+
// under any sane header cap.
|
|
302
|
+
//
|
|
303
|
+
// Magic prefix `gz1:` lets the inbound decoder pick the right path
|
|
304
|
+
// and stay backward-compatible with older SDKs that emit plain base64.
|
|
305
|
+
const gzipped = gzipSync(Buffer.from(json, 'utf-8'))
|
|
306
|
+
return 'gz1:' + gzipped.toString('base64')
|
|
307
|
+
} catch { return undefined }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Attach the mock-config header to an outbound RequestInit without mutating the caller's object. */
|
|
311
|
+
function attachMockConfigHeader(init: RequestInit | undefined, value: string): RequestInit {
|
|
312
|
+
const HEADER = 'x-elasticdash-mock-config'
|
|
313
|
+
const base: RequestInit = init ? { ...init } : {}
|
|
314
|
+
const existing = base.headers
|
|
315
|
+
if (existing instanceof Headers) {
|
|
316
|
+
const h = new Headers(existing)
|
|
317
|
+
h.set(HEADER, value)
|
|
318
|
+
base.headers = h
|
|
319
|
+
} else if (Array.isArray(existing)) {
|
|
320
|
+
base.headers = [...existing.filter(([k]) => k.toLowerCase() !== HEADER), [HEADER, value]]
|
|
321
|
+
} else if (existing && typeof existing === 'object') {
|
|
322
|
+
base.headers = { ...(existing as Record<string, string>), [HEADER]: value }
|
|
323
|
+
} else {
|
|
324
|
+
base.headers = { [HEADER]: value }
|
|
325
|
+
}
|
|
326
|
+
return base
|
|
327
|
+
}
|
|
328
|
+
|
|
274
329
|
export function interceptFetch(): void {
|
|
275
330
|
if (originalFetch) return // already installed
|
|
276
331
|
originalFetch = globalThis.fetch
|
|
@@ -281,7 +336,17 @@ export function interceptFetch(): void {
|
|
|
281
336
|
const httpCtx = getHttpRunContext()
|
|
282
337
|
const obsCtx = getObservabilityContext()
|
|
283
338
|
|
|
284
|
-
|
|
339
|
+
// Option-A passthrough: even when there is no SDK context active, if the
|
|
340
|
+
// CLI seeded mock globals we still attach the header so downstream Node
|
|
341
|
+
// processes (e.g. a Next.js dev server hit by an HTTP-mode workflow) can
|
|
342
|
+
// honor the mocks. This is the bridge that makes partial mocking work
|
|
343
|
+
// without users having to refactor HTTP-mode workflows into in-process
|
|
344
|
+
// ones.
|
|
345
|
+
const mockHeader = encodeMockConfigHeader()
|
|
346
|
+
if (!ctx && !httpCtx && !obsCtx) {
|
|
347
|
+
return originalFetch!(input, mockHeader ? attachMockConfigHeader(init, mockHeader) : init)
|
|
348
|
+
}
|
|
349
|
+
if (mockHeader) init = attachMockConfigHeader(init, mockHeader)
|
|
285
350
|
|
|
286
351
|
const url =
|
|
287
352
|
typeof input === 'string'
|
|
@@ -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.6) — 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) {
|