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.
Files changed (49) hide show
  1. package/dist/cli.js +86 -1
  2. package/dist/cli.js.map +1 -1
  3. package/dist/execution/tool-runner.d.ts.map +1 -1
  4. package/dist/execution/tool-runner.js +16 -7
  5. package/dist/execution/tool-runner.js.map +1 -1
  6. package/dist/index.cjs +161 -11
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/interceptors/http.d.ts.map +1 -1
  12. package/dist/interceptors/http.js +75 -2
  13. package/dist/interceptors/http.js.map +1 -1
  14. package/dist/interceptors/telemetry-push.d.ts +22 -0
  15. package/dist/interceptors/telemetry-push.d.ts.map +1 -1
  16. package/dist/interceptors/telemetry-push.js +81 -0
  17. package/dist/interceptors/telemetry-push.js.map +1 -1
  18. package/dist/interceptors/tool.d.ts.map +1 -1
  19. package/dist/interceptors/tool.js +35 -1
  20. package/dist/interceptors/tool.js.map +1 -1
  21. package/dist/internals/errors.d.ts +10 -0
  22. package/dist/internals/errors.d.ts.map +1 -0
  23. package/dist/internals/errors.js +14 -0
  24. package/dist/internals/errors.js.map +1 -0
  25. package/dist/internals/mock-resolver.d.ts +5 -0
  26. package/dist/internals/mock-resolver.d.ts.map +1 -1
  27. package/dist/internals/mock-resolver.js +26 -3
  28. package/dist/internals/mock-resolver.js.map +1 -1
  29. package/dist/observability.d.ts.map +1 -1
  30. package/dist/observability.js +9 -0
  31. package/dist/observability.js.map +1 -1
  32. package/dist/workflow-runner-worker.js +16 -3
  33. package/dist/workflow-runner-worker.js.map +1 -1
  34. package/dist/workflow-runner.d.ts.map +1 -1
  35. package/dist/workflow-runner.js +11 -0
  36. package/dist/workflow-runner.js.map +1 -1
  37. package/docs/partial-mocking.md +191 -0
  38. package/package.json +1 -1
  39. package/src/cli.ts +87 -2
  40. package/src/execution/tool-runner.ts +16 -7
  41. package/src/index.ts +4 -0
  42. package/src/interceptors/http.ts +66 -1
  43. package/src/interceptors/telemetry-push.ts +80 -0
  44. package/src/interceptors/tool.ts +33 -1
  45. package/src/internals/errors.ts +14 -0
  46. package/src/internals/mock-resolver.ts +25 -3
  47. package/src/observability.ts +10 -0
  48. package/src/workflow-runner-worker.ts +18 -2
  49. 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)
@@ -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) return { mocked: false }
82
+ if (!mocks) {
83
+ if (strict) throw new ElasticdashStrictModeError(toolName)
84
+ return { mocked: false }
85
+ }
75
86
 
76
87
  const entry = mocks[toolName]
77
- if (!entry || entry.mode === 'live') return { mocked: false }
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
 
@@ -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()
@@ -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) {