elasticdash-sdk 0.2.0

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 (349) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +775 -0
  3. package/dist/browser-ui.d.ts +43 -0
  4. package/dist/browser-ui.d.ts.map +1 -0
  5. package/dist/browser-ui.js +246 -0
  6. package/dist/browser-ui.js.map +1 -0
  7. package/dist/capture/event.d.ts +33 -0
  8. package/dist/capture/event.d.ts.map +1 -0
  9. package/dist/capture/event.js +2 -0
  10. package/dist/capture/event.js.map +1 -0
  11. package/dist/capture/index.d.ts +4 -0
  12. package/dist/capture/index.d.ts.map +1 -0
  13. package/dist/capture/index.js +4 -0
  14. package/dist/capture/index.js.map +1 -0
  15. package/dist/capture/recorder.d.ts +24 -0
  16. package/dist/capture/recorder.d.ts.map +1 -0
  17. package/dist/capture/recorder.js +46 -0
  18. package/dist/capture/recorder.js.map +1 -0
  19. package/dist/capture/replay.d.ts +20 -0
  20. package/dist/capture/replay.d.ts.map +1 -0
  21. package/dist/capture/replay.js +47 -0
  22. package/dist/capture/replay.js.map +1 -0
  23. package/dist/ci/api-client.d.ts +38 -0
  24. package/dist/ci/api-client.d.ts.map +1 -0
  25. package/dist/ci/api-client.js +96 -0
  26. package/dist/ci/api-client.js.map +1 -0
  27. package/dist/ci/benchmark.d.ts +33 -0
  28. package/dist/ci/benchmark.d.ts.map +1 -0
  29. package/dist/ci/benchmark.js +213 -0
  30. package/dist/ci/benchmark.js.map +1 -0
  31. package/dist/ci/ed-runner.d.ts +48 -0
  32. package/dist/ci/ed-runner.d.ts.map +1 -0
  33. package/dist/ci/ed-runner.js +260 -0
  34. package/dist/ci/ed-runner.js.map +1 -0
  35. package/dist/ci/executor.d.ts +13 -0
  36. package/dist/ci/executor.d.ts.map +1 -0
  37. package/dist/ci/executor.js +542 -0
  38. package/dist/ci/executor.js.map +1 -0
  39. package/dist/ci/git-info.d.ts +17 -0
  40. package/dist/ci/git-info.d.ts.map +1 -0
  41. package/dist/ci/git-info.js +102 -0
  42. package/dist/ci/git-info.js.map +1 -0
  43. package/dist/ci/index.d.ts +6 -0
  44. package/dist/ci/index.d.ts.map +1 -0
  45. package/dist/ci/index.js +4 -0
  46. package/dist/ci/index.js.map +1 -0
  47. package/dist/ci/measurement.d.ts +9 -0
  48. package/dist/ci/measurement.d.ts.map +1 -0
  49. package/dist/ci/measurement.js +15 -0
  50. package/dist/ci/measurement.js.map +1 -0
  51. package/dist/ci/replay.d.ts +31 -0
  52. package/dist/ci/replay.d.ts.map +1 -0
  53. package/dist/ci/replay.js +96 -0
  54. package/dist/ci/replay.js.map +1 -0
  55. package/dist/ci/reporters/default.d.ts +8 -0
  56. package/dist/ci/reporters/default.d.ts.map +1 -0
  57. package/dist/ci/reporters/default.js +46 -0
  58. package/dist/ci/reporters/default.js.map +1 -0
  59. package/dist/ci/reporters/index.d.ts +8 -0
  60. package/dist/ci/reporters/index.d.ts.map +1 -0
  61. package/dist/ci/reporters/index.js +14 -0
  62. package/dist/ci/reporters/index.js.map +1 -0
  63. package/dist/ci/reporters/json.d.ts +8 -0
  64. package/dist/ci/reporters/json.d.ts.map +1 -0
  65. package/dist/ci/reporters/json.js +14 -0
  66. package/dist/ci/reporters/json.js.map +1 -0
  67. package/dist/ci/reporters/junit.d.ts +8 -0
  68. package/dist/ci/reporters/junit.d.ts.map +1 -0
  69. package/dist/ci/reporters/junit.js +48 -0
  70. package/dist/ci/reporters/junit.js.map +1 -0
  71. package/dist/ci/runner.d.ts +3 -0
  72. package/dist/ci/runner.d.ts.map +1 -0
  73. package/dist/ci/runner.js +187 -0
  74. package/dist/ci/runner.js.map +1 -0
  75. package/dist/ci/test-discovery.d.ts +5 -0
  76. package/dist/ci/test-discovery.d.ts.map +1 -0
  77. package/dist/ci/test-discovery.js +11 -0
  78. package/dist/ci/test-discovery.js.map +1 -0
  79. package/dist/ci/test-loader.d.ts +19 -0
  80. package/dist/ci/test-loader.d.ts.map +1 -0
  81. package/dist/ci/test-loader.js +149 -0
  82. package/dist/ci/test-loader.js.map +1 -0
  83. package/dist/ci/test-registry.d.ts +42 -0
  84. package/dist/ci/test-registry.d.ts.map +1 -0
  85. package/dist/ci/test-registry.js +18 -0
  86. package/dist/ci/test-registry.js.map +1 -0
  87. package/dist/ci/trace-schema.d.ts +30 -0
  88. package/dist/ci/trace-schema.d.ts.map +1 -0
  89. package/dist/ci/trace-schema.js +66 -0
  90. package/dist/ci/trace-schema.js.map +1 -0
  91. package/dist/ci/trace-writer.d.ts +16 -0
  92. package/dist/ci/trace-writer.d.ts.map +1 -0
  93. package/dist/ci/trace-writer.js +108 -0
  94. package/dist/ci/trace-writer.js.map +1 -0
  95. package/dist/ci/types.d.ts +108 -0
  96. package/dist/ci/types.d.ts.map +1 -0
  97. package/dist/ci/types.js +3 -0
  98. package/dist/ci/types.js.map +1 -0
  99. package/dist/ci/upload-client.d.ts +74 -0
  100. package/dist/ci/upload-client.d.ts.map +1 -0
  101. package/dist/ci/upload-client.js +195 -0
  102. package/dist/ci/upload-client.js.map +1 -0
  103. package/dist/cli.d.ts +3 -0
  104. package/dist/cli.d.ts.map +1 -0
  105. package/dist/cli.js +716 -0
  106. package/dist/cli.js.map +1 -0
  107. package/dist/core/agent-state.d.ts +47 -0
  108. package/dist/core/agent-state.d.ts.map +1 -0
  109. package/dist/core/agent-state.js +137 -0
  110. package/dist/core/agent-state.js.map +1 -0
  111. package/dist/core/judge-utils.d.ts +22 -0
  112. package/dist/core/judge-utils.d.ts.map +1 -0
  113. package/dist/core/judge-utils.js +211 -0
  114. package/dist/core/judge-utils.js.map +1 -0
  115. package/dist/core/registry.d.ts +28 -0
  116. package/dist/core/registry.d.ts.map +1 -0
  117. package/dist/core/registry.js +52 -0
  118. package/dist/core/registry.js.map +1 -0
  119. package/dist/dashboard-server.d.ts +65 -0
  120. package/dist/dashboard-server.d.ts.map +1 -0
  121. package/dist/dashboard-server.js +3940 -0
  122. package/dist/dashboard-server.js.map +1 -0
  123. package/dist/execution/tool-runner.d.ts +26 -0
  124. package/dist/execution/tool-runner.d.ts.map +1 -0
  125. package/dist/execution/tool-runner.js +316 -0
  126. package/dist/execution/tool-runner.js.map +1 -0
  127. package/dist/html/dashboard.html +2218 -0
  128. package/dist/http.d.ts +14 -0
  129. package/dist/http.d.ts.map +1 -0
  130. package/dist/http.js +13 -0
  131. package/dist/http.js.map +1 -0
  132. package/dist/index.cjs +8102 -0
  133. package/dist/index.d.ts +61 -0
  134. package/dist/index.d.ts.map +1 -0
  135. package/dist/index.js +67 -0
  136. package/dist/index.js.map +1 -0
  137. package/dist/interceptors/ai-interceptor.d.ts +26 -0
  138. package/dist/interceptors/ai-interceptor.d.ts.map +1 -0
  139. package/dist/interceptors/ai-interceptor.js +756 -0
  140. package/dist/interceptors/ai-interceptor.js.map +1 -0
  141. package/dist/interceptors/db-auto.d.ts +8 -0
  142. package/dist/interceptors/db-auto.d.ts.map +1 -0
  143. package/dist/interceptors/db-auto.js +217 -0
  144. package/dist/interceptors/db-auto.js.map +1 -0
  145. package/dist/interceptors/db.d.ts +23 -0
  146. package/dist/interceptors/db.d.ts.map +1 -0
  147. package/dist/interceptors/db.js +137 -0
  148. package/dist/interceptors/db.js.map +1 -0
  149. package/dist/interceptors/http.d.ts +28 -0
  150. package/dist/interceptors/http.d.ts.map +1 -0
  151. package/dist/interceptors/http.js +356 -0
  152. package/dist/interceptors/http.js.map +1 -0
  153. package/dist/interceptors/side-effects.d.ts +7 -0
  154. package/dist/interceptors/side-effects.d.ts.map +1 -0
  155. package/dist/interceptors/side-effects.js +72 -0
  156. package/dist/interceptors/side-effects.js.map +1 -0
  157. package/dist/interceptors/telemetry-push.d.ts +142 -0
  158. package/dist/interceptors/telemetry-push.d.ts.map +1 -0
  159. package/dist/interceptors/telemetry-push.js +463 -0
  160. package/dist/interceptors/telemetry-push.js.map +1 -0
  161. package/dist/interceptors/tool.d.ts +2 -0
  162. package/dist/interceptors/tool.d.ts.map +1 -0
  163. package/dist/interceptors/tool.js +274 -0
  164. package/dist/interceptors/tool.js.map +1 -0
  165. package/dist/interceptors/workflow-ai.d.ts +5 -0
  166. package/dist/interceptors/workflow-ai.d.ts.map +1 -0
  167. package/dist/interceptors/workflow-ai.js +382 -0
  168. package/dist/interceptors/workflow-ai.js.map +1 -0
  169. package/dist/internals/conditional-recorder.d.ts +21 -0
  170. package/dist/internals/conditional-recorder.d.ts.map +1 -0
  171. package/dist/internals/conditional-recorder.js +54 -0
  172. package/dist/internals/conditional-recorder.js.map +1 -0
  173. package/dist/internals/mock-resolver.d.ts +146 -0
  174. package/dist/internals/mock-resolver.d.ts.map +1 -0
  175. package/dist/internals/mock-resolver.js +427 -0
  176. package/dist/internals/mock-resolver.js.map +1 -0
  177. package/dist/matchers/index.d.ts +96 -0
  178. package/dist/matchers/index.d.ts.map +1 -0
  179. package/dist/matchers/index.js +668 -0
  180. package/dist/matchers/index.js.map +1 -0
  181. package/dist/observability.d.ts +82 -0
  182. package/dist/observability.d.ts.map +1 -0
  183. package/dist/observability.js +471 -0
  184. package/dist/observability.js.map +1 -0
  185. package/dist/portal-executor.d.ts +30 -0
  186. package/dist/portal-executor.d.ts.map +1 -0
  187. package/dist/portal-executor.js +324 -0
  188. package/dist/portal-executor.js.map +1 -0
  189. package/dist/portal-server.d.ts +3 -0
  190. package/dist/portal-server.d.ts.map +1 -0
  191. package/dist/portal-server.js +279 -0
  192. package/dist/portal-server.js.map +1 -0
  193. package/dist/proxy/llm-capture.d.ts +14 -0
  194. package/dist/proxy/llm-capture.d.ts.map +1 -0
  195. package/dist/proxy/llm-capture.js +264 -0
  196. package/dist/proxy/llm-capture.js.map +1 -0
  197. package/dist/reporter.d.ts +3 -0
  198. package/dist/reporter.d.ts.map +1 -0
  199. package/dist/reporter.js +72 -0
  200. package/dist/reporter.js.map +1 -0
  201. package/dist/runWorkflowSubprocess.d.ts +14 -0
  202. package/dist/runWorkflowSubprocess.d.ts.map +1 -0
  203. package/dist/runWorkflowSubprocess.js +66 -0
  204. package/dist/runWorkflowSubprocess.js.map +1 -0
  205. package/dist/runner.d.ts +16 -0
  206. package/dist/runner.d.ts.map +1 -0
  207. package/dist/runner.js +138 -0
  208. package/dist/runner.js.map +1 -0
  209. package/dist/socket-connector.d.ts +22 -0
  210. package/dist/socket-connector.d.ts.map +1 -0
  211. package/dist/socket-connector.js +104 -0
  212. package/dist/socket-connector.js.map +1 -0
  213. package/dist/telemetry-batcher.d.ts +56 -0
  214. package/dist/telemetry-batcher.d.ts.map +1 -0
  215. package/dist/telemetry-batcher.js +143 -0
  216. package/dist/telemetry-batcher.js.map +1 -0
  217. package/dist/test-setup.d.ts +12 -0
  218. package/dist/test-setup.d.ts.map +1 -0
  219. package/dist/test-setup.js +13 -0
  220. package/dist/test-setup.js.map +1 -0
  221. package/dist/tool-registry.d.ts +31 -0
  222. package/dist/tool-registry.d.ts.map +1 -0
  223. package/dist/tool-registry.js +73 -0
  224. package/dist/tool-registry.js.map +1 -0
  225. package/dist/tool-runner-worker.d.ts +2 -0
  226. package/dist/tool-runner-worker.d.ts.map +1 -0
  227. package/dist/tool-runner-worker.js +215 -0
  228. package/dist/tool-runner-worker.js.map +1 -0
  229. package/dist/trace-adapter/context.d.ts +72 -0
  230. package/dist/trace-adapter/context.d.ts.map +1 -0
  231. package/dist/trace-adapter/context.js +80 -0
  232. package/dist/trace-adapter/context.js.map +1 -0
  233. package/dist/tracing.d.ts +2 -0
  234. package/dist/tracing.d.ts.map +1 -0
  235. package/dist/tracing.js +59 -0
  236. package/dist/tracing.js.map +1 -0
  237. package/dist/trigger-executor.d.ts +12 -0
  238. package/dist/trigger-executor.d.ts.map +1 -0
  239. package/dist/trigger-executor.js +130 -0
  240. package/dist/trigger-executor.js.map +1 -0
  241. package/dist/types/portal.d.ts +76 -0
  242. package/dist/types/portal.d.ts.map +1 -0
  243. package/dist/types/portal.js +2 -0
  244. package/dist/types/portal.js.map +1 -0
  245. package/dist/utils/debug.d.ts +3 -0
  246. package/dist/utils/debug.d.ts.map +1 -0
  247. package/dist/utils/debug.js +8 -0
  248. package/dist/utils/debug.js.map +1 -0
  249. package/dist/utils/license-error.d.ts +23 -0
  250. package/dist/utils/license-error.d.ts.map +1 -0
  251. package/dist/utils/license-error.js +42 -0
  252. package/dist/utils/license-error.js.map +1 -0
  253. package/dist/utils/redact.d.ts +7 -0
  254. package/dist/utils/redact.d.ts.map +1 -0
  255. package/dist/utils/redact.js +26 -0
  256. package/dist/utils/redact.js.map +1 -0
  257. package/dist/workflow-runner-worker.d.ts +2 -0
  258. package/dist/workflow-runner-worker.d.ts.map +1 -0
  259. package/dist/workflow-runner-worker.js +329 -0
  260. package/dist/workflow-runner-worker.js.map +1 -0
  261. package/dist/workflow-runner.d.ts +14 -0
  262. package/dist/workflow-runner.d.ts.map +1 -0
  263. package/dist/workflow-runner.js +34 -0
  264. package/dist/workflow-runner.js.map +1 -0
  265. package/docs/agent-coding-instructions.md +138 -0
  266. package/docs/agent-integration-guide.md +564 -0
  267. package/docs/agents.md +140 -0
  268. package/docs/dashboard.md +394 -0
  269. package/docs/deno.md +69 -0
  270. package/docs/instrumentation.md +424 -0
  271. package/docs/langfuse-trace-structure.md +145 -0
  272. package/docs/matchers.md +173 -0
  273. package/docs/observability_contract.md +192 -0
  274. package/docs/observability_mode.md +195 -0
  275. package/docs/quickstart.md +621 -0
  276. package/docs/security-compliance.md +566 -0
  277. package/docs/test-writing-guidelines.md +444 -0
  278. package/docs/tools.md +165 -0
  279. package/docs/workflow-modes.md +253 -0
  280. package/package.json +76 -0
  281. package/src/browser-ui.ts +281 -0
  282. package/src/capture/event.ts +30 -0
  283. package/src/capture/index.ts +3 -0
  284. package/src/capture/recorder.ts +62 -0
  285. package/src/capture/replay.ts +55 -0
  286. package/src/ci/api-client.ts +136 -0
  287. package/src/ci/benchmark.ts +257 -0
  288. package/src/ci/ed-runner.ts +351 -0
  289. package/src/ci/executor.ts +671 -0
  290. package/src/ci/git-info.ts +127 -0
  291. package/src/ci/index.ts +5 -0
  292. package/src/ci/measurement.ts +25 -0
  293. package/src/ci/replay.ts +127 -0
  294. package/src/ci/reporters/default.ts +50 -0
  295. package/src/ci/reporters/index.ts +21 -0
  296. package/src/ci/reporters/json.ts +18 -0
  297. package/src/ci/reporters/junit.ts +61 -0
  298. package/src/ci/runner.ts +208 -0
  299. package/src/ci/test-discovery.ts +16 -0
  300. package/src/ci/test-loader.ts +187 -0
  301. package/src/ci/test-registry.ts +62 -0
  302. package/src/ci/trace-schema.ts +96 -0
  303. package/src/ci/trace-writer.ts +107 -0
  304. package/src/ci/types.ts +115 -0
  305. package/src/ci/upload-client.ts +300 -0
  306. package/src/cli.ts +811 -0
  307. package/src/core/agent-state.ts +162 -0
  308. package/src/core/judge-utils.ts +232 -0
  309. package/src/core/registry.ts +92 -0
  310. package/src/dashboard-server.ts +2047 -0
  311. package/src/execution/tool-runner.ts +352 -0
  312. package/src/html/dashboard.html +2218 -0
  313. package/src/http.ts +13 -0
  314. package/src/index.ts +138 -0
  315. package/src/interceptors/ai-interceptor.ts +798 -0
  316. package/src/interceptors/db-auto.ts +243 -0
  317. package/src/interceptors/db.ts +156 -0
  318. package/src/interceptors/http.ts +393 -0
  319. package/src/interceptors/side-effects.ts +83 -0
  320. package/src/interceptors/telemetry-push.ts +537 -0
  321. package/src/interceptors/tool.ts +287 -0
  322. package/src/interceptors/workflow-ai.ts +419 -0
  323. package/src/internals/conditional-recorder.ts +63 -0
  324. package/src/internals/mock-resolver.ts +492 -0
  325. package/src/matchers/index.ts +824 -0
  326. package/src/observability.ts +501 -0
  327. package/src/portal-executor.ts +355 -0
  328. package/src/portal-server.ts +304 -0
  329. package/src/proxy/llm-capture.ts +301 -0
  330. package/src/reporter.ts +81 -0
  331. package/src/runWorkflowSubprocess.ts +74 -0
  332. package/src/runner.ts +178 -0
  333. package/src/socket-connector.ts +117 -0
  334. package/src/telemetry-batcher.ts +191 -0
  335. package/src/test-setup.ts +16 -0
  336. package/src/tool-registry.ts +94 -0
  337. package/src/tool-runner-worker.ts +244 -0
  338. package/src/trace-adapter/context.ts +156 -0
  339. package/src/tracing.ts +62 -0
  340. package/src/trigger-executor.ts +171 -0
  341. package/src/types/agent.d.ts +63 -0
  342. package/src/types/expect.d.ts +81 -0
  343. package/src/types/modules.d.ts +2 -0
  344. package/src/types/portal.ts +69 -0
  345. package/src/utils/debug.ts +8 -0
  346. package/src/utils/license-error.ts +43 -0
  347. package/src/utils/redact.ts +25 -0
  348. package/src/workflow-runner-worker.ts +386 -0
  349. package/src/workflow-runner.ts +58 -0
@@ -0,0 +1,2047 @@
1
+ import http from 'node:http'
2
+ import path from 'node:path'
3
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
4
+ import { spawn } from 'node:child_process'
5
+ import { pathToFileURL, fileURLToPath } from 'url'
6
+ import { randomUUID, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
7
+ import { callProviderLLM } from './matchers/index.js'
8
+ import { startTraceSession } from './trace-adapter/context.js'
9
+ import type { WorkflowTrace, WorkflowEvent } from './capture/event.js'
10
+ import type { AgentState, AgentPlan } from './types/agent.js'
11
+ import chokidar from 'chokidar';
12
+ import express from 'express';
13
+ import { Worker } from 'worker_threads';
14
+ const app = express();
15
+
16
+ export interface WorkflowInfo {
17
+ name: string
18
+ isAsync: boolean
19
+ signature: string
20
+ filePath: string
21
+ lineNumber?: number
22
+ sourceFile?: string
23
+ sourceModule?: string
24
+ sourceCode?: string
25
+ }
26
+
27
+ export interface ToolInfo {
28
+ name: string
29
+ isAsync: boolean
30
+ signature: string
31
+ filePath: string
32
+ lineNumber?: number
33
+ sourceCode?: string
34
+ }
35
+
36
+ export interface CodeIndex {
37
+ workflows: WorkflowInfo[]
38
+ tools: ToolInfo[]
39
+ }
40
+
41
+ export interface DashboardServerOptions {
42
+ port?: number
43
+ autoOpen?: boolean
44
+ }
45
+
46
+ export interface DashboardServer {
47
+ url: string
48
+ close(): Promise<void>
49
+ }
50
+
51
+ interface ParsedExport {
52
+ name: string
53
+ isAsync: boolean
54
+ signature: string
55
+ filePath: string
56
+ lineNumber?: number
57
+ sourceCode?: string
58
+ }
59
+
60
+ type SupportedProvider = 'openai' | 'claude' | 'gemini' | 'grok' | 'kimi'
61
+
62
+ interface DashboardObservation {
63
+ type?: string
64
+ name?: string
65
+ input?: unknown
66
+ output?: unknown
67
+ isFrozen?: boolean
68
+ model?: string
69
+ provider?: string
70
+ modelParameters?: {
71
+ temperature?: number
72
+ max_tokens?: number
73
+ }
74
+ startTime?: number
75
+ durationMs?: number
76
+ usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }
77
+ workflowEventId?: number
78
+ /** Agent task ID that produced this observation (if agent workflow) */
79
+ agentTaskId?: string
80
+ /** Zero-based agent task index that produced this observation */
81
+ agentTaskIndex?: number
82
+ }
83
+
84
+ interface RerunResult {
85
+ ok: boolean
86
+ currentOutput?: unknown
87
+ currentDurationMs?: number
88
+ currentUsage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }
89
+ error?: string
90
+ }
91
+
92
+ /** Per-tool mock configuration sent from the dashboard UI */
93
+ export interface ToolMockEntry {
94
+ /** 'live' = always call real tool, 'mock-all' = mock every call, 'mock-specific' = mock only listed call indices */
95
+ mode: 'live' | 'mock-all' | 'mock-specific'
96
+ /** When mode is 'mock-specific', which 1-based call indices to mock */
97
+ callIndices?: number[]
98
+ /** Mock data keyed by 1-based call index (or 0 for mock-all default) */
99
+ mockData?: Record<number, unknown>
100
+ }
101
+
102
+ export interface ToolMockConfig {
103
+ [toolName: string]: ToolMockEntry
104
+ }
105
+
106
+ /** Per-model AI mock configuration sent from the dashboard UI */
107
+ export interface AIMockEntry {
108
+ mode: 'live' | 'mock-all' | 'mock-specific'
109
+ callIndices?: number[]
110
+ mockData?: Record<number, unknown>
111
+ }
112
+
113
+ export interface AIMockConfig {
114
+ [modelName: string]: AIMockEntry
115
+ }
116
+
117
+ interface WorkflowValidationBody {
118
+ workflowName?: unknown
119
+ runCount?: unknown
120
+ sequential?: unknown
121
+ observations?: unknown
122
+ toolMockConfig?: unknown
123
+ aiMockConfig?: unknown
124
+ promptMockConfig?: unknown
125
+ userPromptMockConfig?: unknown
126
+ }
127
+
128
+ interface ValidationRunTrace {
129
+ runNumber: number
130
+ ok: boolean
131
+ observations: DashboardObservation[]
132
+ workflowTrace?: WorkflowTrace
133
+ error?: string
134
+ /** The return value of the workflow function (e.g. an AgentPlan for agent workflows) */
135
+ currentOutput?: unknown
136
+ snapshotId?: string
137
+ }
138
+
139
+ interface ValidateWorkflowResult {
140
+ ok: boolean
141
+ mode: 'parallel' | 'sequential'
142
+ runCount: number
143
+ traces: ValidationRunTrace[]
144
+ error?: string
145
+ }
146
+
147
+ // ─── Snapshot Encryption (opt-in via ELASTICDASH_SNAPSHOT_ENCRYPTION_KEY) ────
148
+
149
+ const SNAPSHOT_ENCRYPTION_KEY = process.env.ELASTICDASH_SNAPSHOT_ENCRYPTION_KEY
150
+ ? Buffer.from(process.env.ELASTICDASH_SNAPSHOT_ENCRYPTION_KEY, 'hex')
151
+ : null
152
+
153
+ function encryptSnapshot(data: string): string {
154
+ if (!SNAPSHOT_ENCRYPTION_KEY || SNAPSHOT_ENCRYPTION_KEY.length !== 32) return data
155
+ const iv = randomBytes(16)
156
+ const cipher = createCipheriv('aes-256-gcm', SNAPSHOT_ENCRYPTION_KEY, iv)
157
+ const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()])
158
+ const authTag = cipher.getAuthTag()
159
+ return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`
160
+ }
161
+
162
+ function decryptSnapshot(data: string): string {
163
+ if (!SNAPSHOT_ENCRYPTION_KEY || SNAPSHOT_ENCRYPTION_KEY.length !== 32) return data
164
+ const parts = data.split(':')
165
+ if (parts.length !== 3) return data // not encrypted, return as-is
166
+ const [ivHex, authTagHex, encryptedHex] = parts
167
+ try {
168
+ const iv = Buffer.from(ivHex, 'hex')
169
+ const authTag = Buffer.from(authTagHex, 'hex')
170
+ const encrypted = Buffer.from(encryptedHex, 'hex')
171
+ const decipher = createDecipheriv('aes-256-gcm', SNAPSHOT_ENCRYPTION_KEY, iv)
172
+ decipher.setAuthTag(authTag)
173
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
174
+ } catch {
175
+ return data // decryption failed, return raw (may be unencrypted legacy data)
176
+ }
177
+ }
178
+
179
+ function saveSnapshot(cwd: string, workflowTrace: WorkflowTrace): string {
180
+ const dir = path.join(cwd, '.temp', 'snapshots')
181
+ mkdirSync(dir, { recursive: true })
182
+ const id = workflowTrace.traceId
183
+ const content = encryptSnapshot(JSON.stringify(workflowTrace))
184
+ writeFileSync(path.join(dir, `${id}.json`), content, 'utf8')
185
+ return id
186
+ }
187
+
188
+ function loadSnapshot(cwd: string, snapshotId: string): WorkflowTrace | null {
189
+ try {
190
+ const file = path.join(cwd, '.temp', 'snapshots', `${snapshotId}.json`)
191
+ const raw = readFileSync(file, 'utf8')
192
+ return JSON.parse(decryptSnapshot(raw)) as WorkflowTrace
193
+ } catch {
194
+ return null
195
+ }
196
+ }
197
+
198
+ function isDenoProject(dir: string): boolean {
199
+ return existsSync(path.join(dir, 'deno.json')) || existsSync(path.join(dir, 'deno.jsonc'))
200
+ }
201
+
202
+ function resolveRuntimeModule(cwd: string, baseName: string): string | null {
203
+ for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
204
+ const candidate = path.join(cwd, `${baseName}${ext}`)
205
+ if (existsSync(candidate)) return candidate
206
+ }
207
+ return null
208
+ }
209
+
210
+ function parseSignatureParams(signature?: string): string[] {
211
+ if (!signature) return []
212
+ const trimmed = signature.trim()
213
+ if (!trimmed.startsWith('(') || !trimmed.endsWith(')')) return []
214
+ const body = trimmed.slice(1, -1).trim()
215
+ if (!body) return []
216
+
217
+ return body
218
+ .split(',')
219
+ .map(part => part.trim())
220
+ .filter(Boolean)
221
+ .map(part => part.replace(/^\.\.\./, '').split('=')[0].split(':')[0].replace(/\?/g, '').trim())
222
+ .filter(part => /^[$A-Z_][0-9A-Z_$]*$/i.test(part))
223
+ }
224
+
225
+ function normalizeMessageContent(content: unknown): string {
226
+ if (typeof content === 'string') return content
227
+ if (Array.isArray(content)) {
228
+ return content
229
+ .map((part) => {
230
+ if (typeof part === 'string') return part
231
+ if (part && typeof part === 'object' && typeof (part as any).text === 'string') return (part as any).text
232
+ try {
233
+ return JSON.stringify(part)
234
+ } catch {
235
+ return String(part)
236
+ }
237
+ })
238
+ .join('\n')
239
+ }
240
+ if (content && typeof content === 'object') {
241
+ if (typeof (content as any).text === 'string') return (content as any).text
242
+ try {
243
+ return JSON.stringify(content)
244
+ } catch {
245
+ return String(content)
246
+ }
247
+ }
248
+ return content == null ? '' : String(content)
249
+ }
250
+
251
+ function extractPromptFromGenerationInput(input: unknown): { prompt: string; systemPrompt?: string } {
252
+ if (typeof input === 'string') {
253
+ return { prompt: input }
254
+ }
255
+
256
+ const messages = Array.isArray(input)
257
+ ? input
258
+ : input && typeof input === 'object' && Array.isArray((input as any).messages)
259
+ ? (input as any).messages
260
+ : null
261
+
262
+ if (messages && messages.length > 0) {
263
+ const systemParts: string[] = []
264
+ const promptParts: string[] = []
265
+ for (const message of messages as Array<any>) {
266
+ const role = typeof message?.role === 'string' ? message.role : 'user'
267
+ const content = normalizeMessageContent(message?.content).trim()
268
+ if (!content) continue
269
+ if (role === 'system') {
270
+ systemParts.push(content)
271
+ } else {
272
+ promptParts.push(`${role}: ${content}`)
273
+ }
274
+ }
275
+ return {
276
+ prompt: promptParts.join('\n\n') || systemParts.join('\n\n') || JSON.stringify(input),
277
+ systemPrompt: systemParts.length > 0 ? systemParts.join('\n\n') : undefined,
278
+ }
279
+ }
280
+
281
+ if (input && typeof input === 'object' && typeof (input as any).prompt === 'string') {
282
+ return {
283
+ prompt: (input as any).prompt,
284
+ systemPrompt: typeof (input as any).systemPrompt === 'string' ? (input as any).systemPrompt : undefined,
285
+ }
286
+ }
287
+
288
+ try {
289
+ return { prompt: JSON.stringify(input) }
290
+ } catch {
291
+ return { prompt: String(input ?? '') }
292
+ }
293
+ }
294
+
295
+ function inferProvider(observation: DashboardObservation): SupportedProvider {
296
+ const provider = observation.provider?.toLowerCase()
297
+ if (provider === 'openai' || provider === 'claude' || provider === 'gemini' || provider === 'grok' || provider === 'kimi') {
298
+ return provider
299
+ }
300
+ const model = observation.model?.toLowerCase() ?? ''
301
+ if (model.includes('claude')) return 'claude'
302
+ if (model.includes('gemini')) return 'gemini'
303
+ if (model.includes('grok')) return 'grok'
304
+ if (model.includes('kimi')) return 'kimi'
305
+ return 'openai'
306
+ }
307
+
308
+ function buildToolArgs(input: unknown, tool?: ToolInfo): unknown[] {
309
+ if (input === undefined) return []
310
+ if (Array.isArray(input)) return input
311
+ if (input && typeof input === 'object') {
312
+ const argObject = input as Record<string, unknown>
313
+ const paramNames = parseSignatureParams(tool?.signature)
314
+ if (paramNames.length > 0 && paramNames.every(name => Object.prototype.hasOwnProperty.call(argObject, name))) {
315
+ return paramNames.map(name => argObject[name])
316
+ }
317
+ return [input]
318
+ }
319
+ return [input]
320
+ }
321
+
322
+ /**
323
+ * Flatten structured prompt mock config to simple Record<string, string> for HTTP mode.
324
+ * Accepts both the new structured format { mode, replacement } and legacy string values.
325
+ * Only entries with mode !== 'live' (or plain string entries) are included.
326
+ */
327
+ function flattenPromptMockConfig(raw: unknown): Record<string, string> {
328
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
329
+ const result: Record<string, string> = {}
330
+ for (const [key, val] of Object.entries(raw as Record<string, unknown>)) {
331
+ if (typeof val === 'string') {
332
+ result[key] = val
333
+ } else if (val && typeof val === 'object' && 'replacement' in val) {
334
+ const entry = val as { mode?: string; replacement?: string }
335
+ if (entry.mode !== 'live' && typeof entry.replacement === 'string') {
336
+ result[key] = entry.replacement
337
+ }
338
+ }
339
+ }
340
+ return result
341
+ }
342
+
343
+ function formatError(error: unknown): string {
344
+ if (error instanceof Error) return error.message
345
+ try {
346
+ return JSON.stringify(error)
347
+ } catch {
348
+ return String(error)
349
+ }
350
+ }
351
+
352
+ function runToolInSubprocess(
353
+ toolsModulePath: string,
354
+ toolName: string,
355
+ args: unknown[],
356
+ ): Promise<RerunResult> {
357
+ return new Promise((resolve) => {
358
+ const startMs = Date.now()
359
+ const workerScript = new URL('./tool-runner-worker.js', import.meta.url).pathname
360
+ const projectDir = path.dirname(toolsModulePath)
361
+ const denoProject = isDenoProject(projectDir)
362
+
363
+ // For Deno projects use `deno run --allow-all` so that https:// imports and
364
+ // TypeScript are handled natively. For Node projects keep the existing tsx path.
365
+ const nodeOptions = process.env.NODE_OPTIONS ?? ''
366
+ const tsxFlag = '--import tsx'
367
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim()
368
+ const childEnv = { ...process.env, NODE_OPTIONS: denoProject ? nodeOptions : childNodeOptions }
369
+
370
+ const runtime = denoProject ? 'deno' : process.execPath
371
+ const runtimeArgs = denoProject ? ['run', '--allow-all', workerScript] : [workerScript]
372
+
373
+ const child = spawn(runtime, runtimeArgs, {
374
+ env: childEnv,
375
+ cwd: projectDir,
376
+ stdio: ['pipe', 'pipe', 'pipe'],
377
+ })
378
+
379
+ const RESULT_PREFIX = '__ELASTICDASH_RESULT__:'
380
+ let resultLine = ''
381
+ let stderr = ''
382
+
383
+ child.stdout.on('data', (chunk) => {
384
+ const text = chunk.toString()
385
+ for (const line of text.split('\n')) {
386
+ if (line.startsWith(RESULT_PREFIX)) {
387
+ resultLine = line.slice(RESULT_PREFIX.length)
388
+ } else if (line) {
389
+ process.stdout.write(line + '\n')
390
+ }
391
+ }
392
+ })
393
+ child.stderr.on('data', (chunk) => {
394
+ stderr += chunk.toString()
395
+ process.stderr.write(chunk)
396
+ })
397
+
398
+ child.on('close', () => {
399
+ const currentDurationMs = Date.now() - startMs
400
+ if (resultLine) {
401
+ try {
402
+ resolve({ ...JSON.parse(resultLine), currentDurationMs })
403
+ return
404
+ } catch { /* fall through */ }
405
+ }
406
+ resolve({ ok: false, error: stderr.trim() || 'Tool subprocess produced no output.', currentDurationMs })
407
+ })
408
+
409
+ child.on('error', (err) => {
410
+ const hint = denoProject && (err as NodeJS.ErrnoException).code === 'ENOENT'
411
+ ? ' (Deno project detected — ensure "deno" is installed and available in PATH)'
412
+ : ''
413
+ resolve({ ok: false, error: `Failed to spawn tool subprocess: ${err.message}${hint}`, currentDurationMs: Date.now() - startMs })
414
+ })
415
+
416
+ // Always use absolute file URL for toolsModulePath
417
+ const payload = JSON.stringify({
418
+ toolsModulePath: pathToFileURL(toolsModulePath).pathname,
419
+ toolName,
420
+ args
421
+ })
422
+ child.stdin.write(payload)
423
+ child.stdin.end() // Always close stdin to avoid subprocess hang
424
+ })
425
+ }
426
+
427
+ interface WorkflowSubprocessResult {
428
+ ok: boolean
429
+ currentOutput?: unknown
430
+ steps?: unknown[]
431
+ llmSteps?: unknown[]
432
+ toolCalls?: unknown[]
433
+ customSteps?: unknown[]
434
+ workflowTrace?: WorkflowTrace
435
+ error?: string
436
+ }
437
+
438
+ function runWorkflowInSubprocess(
439
+ workflowsModulePath: string,
440
+ toolsModulePath: string | null,
441
+ workflowName: string,
442
+ args: unknown[],
443
+ input: unknown,
444
+ options?: {
445
+ replayMode?: boolean;
446
+ checkpoint?: number;
447
+ history?: WorkflowEvent[];
448
+ agentState?: AgentState;
449
+ toolMockConfig?: ToolMockConfig;
450
+ aiMockConfig?: AIMockConfig;
451
+ promptMockConfig?: Record<string, unknown>;
452
+ userPromptMockConfig?: Record<string, unknown>
453
+ },
454
+ ): Promise<WorkflowSubprocessResult> {
455
+ return new Promise((resolve) => {
456
+ const workerScript = new URL('./workflow-runner-worker.js', import.meta.url).pathname
457
+ const projectDir = path.dirname(workflowsModulePath)
458
+ const denoProject = isDenoProject(projectDir)
459
+
460
+ // For Deno projects use `deno run --allow-all` so that https:// imports and
461
+ // TypeScript are handled natively. For Node projects keep the existing tsx path.
462
+ const nodeOptions = process.env.NODE_OPTIONS ?? ''
463
+ const tsxFlag = '--import tsx'
464
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim()
465
+ const childEnv = { ...process.env, NODE_OPTIONS: denoProject ? nodeOptions : childNodeOptions }
466
+
467
+ const runtime = denoProject ? 'deno' : process.execPath
468
+ const runtimeArgs = denoProject ? ['run', '--allow-all', workerScript] : [workerScript]
469
+
470
+ const child = spawn(runtime, runtimeArgs, {
471
+ env: childEnv,
472
+ cwd: projectDir,
473
+ stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
474
+ })
475
+
476
+ let fd3Data = ''
477
+ let stderr = ''
478
+
479
+ // Line-buffer stdout so that large result JSON lines split across multiple
480
+ // data events are reassembled before processing.
481
+ const WORKFLOW_RESULT_PREFIX = '__ELASTICDASH_RESULT__:'
482
+ let stdoutBuf = ''
483
+ child.stdout.on('data', (chunk) => {
484
+ stdoutBuf += chunk.toString()
485
+ const lines = stdoutBuf.split('\n')
486
+ stdoutBuf = lines.pop() ?? '' // keep last (possibly incomplete) line
487
+ for (const line of lines) {
488
+ if (line.startsWith(WORKFLOW_RESULT_PREFIX)) {
489
+ // Stdout fallback channel (used by Deno when fd3 is unavailable)
490
+ fd3Data += line.slice(WORKFLOW_RESULT_PREFIX.length)
491
+ } else if (line) {
492
+ process.stdout.write(line + '\n')
493
+ }
494
+ }
495
+ })
496
+ child.stderr.on('data', (chunk) => {
497
+ stderr += chunk.toString()
498
+ process.stderr.write(chunk)
499
+ })
500
+ const fd3 = child.stdio[3] as import('stream').Readable | null
501
+ fd3?.on('data', (chunk: Buffer | string) => {
502
+ fd3Data += chunk.toString()
503
+ })
504
+
505
+ child.on('close', () => {
506
+ // Flush any remaining buffered stdout line (e.g. result with no trailing newline)
507
+ if (stdoutBuf.startsWith(WORKFLOW_RESULT_PREFIX)) {
508
+ fd3Data += stdoutBuf.slice(WORKFLOW_RESULT_PREFIX.length)
509
+ } else if (stdoutBuf) {
510
+ process.stdout.write(stdoutBuf + '\n')
511
+ }
512
+
513
+ if (fd3Data) {
514
+ try {
515
+ resolve(JSON.parse(fd3Data))
516
+ return
517
+ } catch { /* fall through */ }
518
+ }
519
+ resolve({ ok: false, error: stderr.trim() || 'Workflow subprocess produced no output.' })
520
+ })
521
+
522
+ child.on('error', (err) => {
523
+ const hint = denoProject && (err as NodeJS.ErrnoException).code === 'ENOENT'
524
+ ? ' (Deno project detected — ensure "deno" is installed and available in PATH)'
525
+ : ''
526
+ resolve({ ok: false, error: `Failed to spawn workflow subprocess: ${err.message}${hint}` })
527
+ })
528
+
529
+ // Always use absolute file URL for workflowsModulePath and toolsModulePath
530
+ const payload = JSON.stringify({
531
+ workflowsModulePath: pathToFileURL(workflowsModulePath).pathname,
532
+ toolsModulePath: toolsModulePath ? pathToFileURL(toolsModulePath).pathname : undefined,
533
+ workflowName,
534
+ args,
535
+ input,
536
+ ...(options?.replayMode !== undefined ? { replayMode: options.replayMode } : {}),
537
+ ...(options?.checkpoint !== undefined ? { checkpoint: options.checkpoint } : {}),
538
+ ...(options?.history !== undefined ? { history: options.history } : {}),
539
+ ...(options?.agentState !== undefined ? { agentState: options.agentState } : {}),
540
+ ...(options?.toolMockConfig !== undefined ? { toolMockConfig: options.toolMockConfig } : {}),
541
+ ...(options?.aiMockConfig !== undefined ? { aiMockConfig: options.aiMockConfig } : {}),
542
+ ...(options?.promptMockConfig !== undefined ? { promptMockConfig: options.promptMockConfig } : {}),
543
+ ...(options?.userPromptMockConfig !== undefined ? { userPromptMockConfig: options.userPromptMockConfig } : {}),
544
+ })
545
+ child.stdin.write(payload)
546
+ child.stdin.end() // Always close stdin to avoid subprocess hang
547
+ })
548
+ }
549
+
550
+ async function runToolObservation(cwd: string, observation: DashboardObservation, tools: ToolInfo[]): Promise<RerunResult> {
551
+ const toolName = observation.name
552
+ if (!toolName) {
553
+ return { ok: false, error: 'Missing tool name on observation.' }
554
+ }
555
+
556
+ const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools')
557
+ if (!toolsModulePath) {
558
+ return { ok: false, error: 'Cannot find ed_tools.ts/js in workspace root.' }
559
+ }
560
+
561
+ // Parse input if it's a JSON string (common in trace exports)
562
+ let parsedInput = observation.input
563
+ if (typeof parsedInput === 'string') {
564
+ try {
565
+ parsedInput = JSON.parse(parsedInput)
566
+ } catch {
567
+ // Not JSON, use as-is
568
+ }
569
+ }
570
+
571
+ const toolInfo = tools.find(tool => tool.name === toolName)
572
+ const args = buildToolArgs(parsedInput, toolInfo)
573
+
574
+ console.log('[elasticdash] Rerunning tool observation:', { toolName, input: observation.input })
575
+ console.log(`[elasticdash] Loading tools from ${toolsModulePath} (fresh subprocess)...`)
576
+
577
+ return runToolInSubprocess(toolsModulePath, toolName, args)
578
+ }
579
+
580
+ async function runGenerationObservation(observation: DashboardObservation): Promise<RerunResult> {
581
+ try {
582
+ const { prompt, systemPrompt } = extractPromptFromGenerationInput(observation.input)
583
+ if (!prompt.trim()) {
584
+ return { ok: false, error: 'Generation input is empty; cannot rerun.' }
585
+ }
586
+ const provider = inferProvider(observation)
587
+ const model = observation.model
588
+ const temperature = typeof observation.modelParameters?.temperature === 'number' ? observation.modelParameters.temperature : 0
589
+ const maxTokens = typeof observation.modelParameters?.max_tokens === 'number' ? observation.modelParameters.max_tokens : 512
590
+
591
+ const result = await callProviderLLM(
592
+ prompt,
593
+ { provider, model },
594
+ systemPrompt ?? 'You are a helpful assistant.',
595
+ maxTokens,
596
+ // temperature,
597
+ )
598
+
599
+ return { ok: true, currentOutput: result.content, currentDurationMs: result.durationMs, currentUsage: result.usage }
600
+ } catch (error) {
601
+ return { ok: false, error: `Generation rerun failed: ${formatError(error)}` }
602
+ }
603
+ }
604
+
605
+ async function rerunObservation(cwd: string, observation: DashboardObservation, tools: ToolInfo[]): Promise<RerunResult> {
606
+ const type = observation.type?.toUpperCase()
607
+ const name = observation.name ?? '(unknown)'
608
+ const isToolByName = name.startsWith('tool-') || name.startsWith('tool:')
609
+ if (type === 'TOOL' || isToolByName) {
610
+ observation.name = isToolByName ? name.slice(5) : name // Support both explicit type and name prefix for tool observations
611
+ return runToolObservation(cwd, observation, tools)
612
+ }
613
+ if (type === 'GENERATION') {
614
+ return runGenerationObservation(observation)
615
+ }
616
+ return { ok: false, error: `Unsupported observation type: ${observation.type ?? '(missing type)'}` }
617
+ }
618
+
619
+ function resolveWorkflowModule(cwd: string): string | null {
620
+ return resolveRuntimeModule(cwd, 'ed_workflows')
621
+ }
622
+
623
+ function normalizeRunCount(value: unknown): number {
624
+ const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10)
625
+ if (!Number.isFinite(parsed)) return 1
626
+ const floored = Math.floor(parsed)
627
+ if (floored < 1) return 1
628
+ if (floored > 50) return 50
629
+ return floored
630
+ }
631
+
632
+ function parseObservationInput(input: unknown): unknown {
633
+ if (typeof input !== 'string') return input
634
+ const trimmed = input.trim()
635
+ if (!trimmed) return input
636
+ try {
637
+ return JSON.parse(trimmed)
638
+ } catch {
639
+ return input
640
+ }
641
+ }
642
+
643
+ function normalizeWorkflowArgs(input: unknown): unknown[] {
644
+ const parsedInput = parseObservationInput(input)
645
+ if (parsedInput === undefined || parsedInput === null) return []
646
+ if (Array.isArray(parsedInput)) return parsedInput
647
+ return [parsedInput]
648
+ }
649
+
650
+ function resolveWorkflowArgsFromObservations(body: WorkflowValidationBody, workflowName: string): { args?: unknown[]; input?: unknown; error?: string } {
651
+ if (!Array.isArray(body.observations)) {
652
+ return { error: 'observations array is required for workflow validation input.' }
653
+ }
654
+
655
+ const matched = body.observations.find((item) => {
656
+ if (!item || typeof item !== 'object') return false
657
+ return typeof (item as DashboardObservation).name === 'string' && ((item as DashboardObservation).name ?? '').trim() === workflowName
658
+ }) as DashboardObservation | undefined
659
+
660
+ if (!matched) {
661
+ // No workflow-level observation found (e.g. trace was loaded from an external format that
662
+ // only contains child observations). Fall back to running the workflow with no arguments.
663
+ return { args: [], input: null }
664
+ }
665
+
666
+ return { args: normalizeWorkflowArgs(matched.input), input: matched.input }
667
+ }
668
+
669
+ function normalizeStartTime(value: unknown): number {
670
+ if (typeof value === 'number' && Number.isFinite(value) && value > 1) {
671
+ return value
672
+ }
673
+ return Date.now()
674
+ }
675
+
676
+ function toObservationFromStep(step: { type: string; data: Record<string, unknown>; timestamp?: number }): DashboardObservation {
677
+ if (step.type === 'llm') {
678
+ return {
679
+ type: 'GENERATION',
680
+ name: typeof step.data.provider === 'string' ? step.data.provider : 'llm',
681
+ provider: typeof step.data.provider === 'string' ? step.data.provider : undefined,
682
+ model: typeof step.data.model === 'string' ? step.data.model : undefined,
683
+ input: step.data.prompt,
684
+ output: step.data.completion,
685
+ startTime: normalizeStartTime(step.timestamp),
686
+ workflowEventId: typeof step.data.workflowEventId === 'number' ? step.data.workflowEventId : undefined,
687
+ }
688
+ }
689
+
690
+ if (step.type === 'tool') {
691
+ return {
692
+ type: 'TOOL',
693
+ name: typeof step.data.name === 'string' ? step.data.name : 'tool',
694
+ input: step.data.args,
695
+ output: step.data.result,
696
+ startTime: normalizeStartTime(step.timestamp),
697
+ workflowEventId: typeof step.data.workflowEventId === 'number' ? step.data.workflowEventId : undefined,
698
+ }
699
+ }
700
+
701
+ return {
702
+ type: 'SPAN',
703
+ name: typeof step.data.name === 'string' ? step.data.name : typeof step.data.kind === 'string' ? step.data.kind : 'custom',
704
+ input: step.data.payload ?? step.data.metadata,
705
+ output: step.data.result,
706
+ startTime: normalizeStartTime(step.timestamp),
707
+ }
708
+ }
709
+
710
+ function toObservationFromWorkflowEvent(event: WorkflowEvent): DashboardObservation {
711
+ const agentFields: Pick<DashboardObservation, 'agentTaskId' | 'agentTaskIndex'> = {}
712
+ if (event.agentTaskId !== undefined) agentFields.agentTaskId = event.agentTaskId
713
+ if (event.agentTaskIndex !== undefined) agentFields.agentTaskIndex = event.agentTaskIndex
714
+
715
+ if (event.type === 'ai') {
716
+ const inp = event.input as { provider?: string; model?: string; prompt?: string; messages?: unknown[] } | null
717
+ const out = event.output as Record<string, unknown> | null
718
+ const provider = inp?.provider ?? ''
719
+ // For streaming events, out is { streamed: true, completion } — extract text for fallback
720
+ let streamedCompletion: string | undefined
721
+ if (out?.streamed === true && typeof out.completion === 'string') {
722
+ streamedCompletion = out.completion
723
+ }
724
+ return {
725
+ type: 'GENERATION',
726
+ name: event.name || provider || 'llm',
727
+ provider: provider || undefined,
728
+ model: inp?.model ?? event.name,
729
+ input: inp?.messages ?? inp?.prompt,
730
+ output: streamedCompletion !== undefined ? streamedCompletion : out,
731
+ startTime: normalizeStartTime(event.timestamp),
732
+ durationMs: event.durationMs,
733
+ usage: event.usage,
734
+ workflowEventId: event.id,
735
+ ...agentFields,
736
+ }
737
+ }
738
+
739
+ if (event.type === 'tool') {
740
+ return {
741
+ type: 'TOOL',
742
+ name: event.name,
743
+ input: event.input,
744
+ output: event.output,
745
+ startTime: normalizeStartTime(event.timestamp),
746
+ durationMs: event.durationMs,
747
+ workflowEventId: event.id,
748
+ ...agentFields,
749
+ }
750
+ }
751
+
752
+ if (event.type === 'http') {
753
+ const inp = event.input as { url?: string; method?: string } | undefined
754
+ return {
755
+ type: 'HTTP',
756
+ name: inp?.url ?? 'http',
757
+ input: event.input,
758
+ output: event.output,
759
+ startTime: normalizeStartTime(event.timestamp),
760
+ durationMs: event.durationMs,
761
+ workflowEventId: event.id,
762
+ ...agentFields,
763
+ }
764
+ }
765
+ if (event.type === 'db') {
766
+ return {
767
+ type: 'DB',
768
+ name: event.name,
769
+ input: event.input,
770
+ output: event.output,
771
+ startTime: normalizeStartTime(event.timestamp),
772
+ durationMs: event.durationMs,
773
+ workflowEventId: event.id,
774
+ ...agentFields,
775
+ }
776
+ }
777
+ return {
778
+ type: 'SPAN',
779
+ name: event.name,
780
+ input: event.input,
781
+ output: event.output,
782
+ startTime: normalizeStartTime(event.timestamp),
783
+ durationMs: event.durationMs,
784
+ workflowEventId: event.id,
785
+ ...agentFields,
786
+ }
787
+ }
788
+
789
+ function buildValidationObservations(
790
+ workflowName: string,
791
+ workflowInput: unknown,
792
+ workflowOutput: unknown,
793
+ workflowError: string | undefined,
794
+ trace: ReturnType<typeof startTraceSession>['context']['trace'],
795
+ workflowTrace?: WorkflowTrace,
796
+ frozenEventIds?: Set<number>,
797
+ ): DashboardObservation[] {
798
+ const steps = trace.getSteps()
799
+ const workflowStartTime = steps.length > 0 ? steps[0].timestamp : Date.now()
800
+
801
+ const observations: DashboardObservation[] = [
802
+ {
803
+ type: 'SPAN',
804
+ name: workflowName,
805
+ input: workflowInput,
806
+ output: workflowError ? `Workflow run failed: ${workflowError}` : workflowOutput,
807
+ startTime: workflowStartTime,
808
+ },
809
+ ]
810
+
811
+ // If workflowTrace has ai/tool events, use those as the source of truth to avoid duplicates
812
+ const hasAiEvents = workflowTrace?.events.some(e => e.type === 'ai') ?? false
813
+ const hasToolEvents = workflowTrace?.events.some(e => e.type === 'tool') ?? false
814
+
815
+ let firstGenerationIndex = -1
816
+ for (const step of steps) {
817
+ if (hasAiEvents && step.type === 'llm') continue
818
+ if (hasToolEvents && step.type === 'tool') continue
819
+ const obs = toObservationFromStep({ type: step.type, data: step.data, timestamp: step.timestamp })
820
+
821
+ // Mark frozen if this step's workflowEventId is in the frozen set
822
+ if (obs.workflowEventId !== undefined && frozenEventIds?.has(obs.workflowEventId)) {
823
+ obs.isFrozen = true
824
+ }
825
+
826
+ observations.push(obs)
827
+
828
+ // Track the index of the first GENERATION observation
829
+ if (firstGenerationIndex === -1 && obs.type === 'GENERATION') {
830
+ firstGenerationIndex = observations.length - 1
831
+ }
832
+ }
833
+
834
+ // Append captured events from the workflow trace (ai, tool, http, db)
835
+ if (workflowTrace) {
836
+ for (const event of workflowTrace.events) {
837
+ if (event.type === 'ai' || event.type === 'tool' || event.type === 'http' || event.type === 'db') {
838
+ const obs = toObservationFromWorkflowEvent(event)
839
+ if (frozenEventIds?.has(event.id)) {
840
+ obs.isFrozen = true
841
+ }
842
+ observations.push(obs)
843
+ if (firstGenerationIndex === -1 && obs.type === 'GENERATION') {
844
+ firstGenerationIndex = observations.length - 1
845
+ }
846
+ }
847
+ }
848
+ }
849
+
850
+ // Compute total duration and aggregate token usage for the container observation
851
+ if (workflowTrace && workflowTrace.events.length > 0) {
852
+ const endTime = workflowTrace.events.reduce(
853
+ (max, e) => Math.max(max, e.timestamp + e.durationMs),
854
+ workflowStartTime,
855
+ )
856
+ observations[0].durationMs = endTime - workflowStartTime
857
+
858
+ let inputTokens = 0, outputTokens = 0, totalTokens = 0
859
+ for (const e of workflowTrace.events) {
860
+ if (e.type === 'ai' && e.usage) {
861
+ inputTokens += e.usage.inputTokens ?? 0
862
+ outputTokens += e.usage.outputTokens ?? 0
863
+ totalTokens += e.usage.totalTokens ?? 0
864
+ }
865
+ }
866
+ if (totalTokens > 0) {
867
+ observations[0].usage = { inputTokens, outputTokens, totalTokens }
868
+ }
869
+ }
870
+
871
+ // Sort all observations except the workflow entry (index 0) by startTime
872
+ const [workflowEntry, ...rest] = observations
873
+ rest.sort((a, b) => (a.startTime ?? 0) - (b.startTime ?? 0))
874
+ return [workflowEntry, ...rest]
875
+ }
876
+
877
+ async function validateWorkflowRuns(cwd: string, body: WorkflowValidationBody): Promise<ValidateWorkflowResult> {
878
+ const workflowName = typeof body.workflowName === 'string' ? body.workflowName.trim() : ''
879
+ if (!workflowName) {
880
+ return {
881
+ ok: false,
882
+ mode: 'parallel',
883
+ runCount: 0,
884
+ traces: [],
885
+ error: 'workflowName is required.',
886
+ }
887
+ }
888
+
889
+ const runCount = normalizeRunCount(body.runCount)
890
+ const sequential = body.sequential === true
891
+ const mode: 'parallel' | 'sequential' = sequential ? 'sequential' : 'parallel'
892
+ const resolvedInput = resolveWorkflowArgsFromObservations(body, workflowName)
893
+ if (resolvedInput.error) {
894
+ return {
895
+ ok: false,
896
+ mode,
897
+ runCount,
898
+ traces: [],
899
+ error: resolvedInput.error,
900
+ }
901
+ }
902
+ const workflowArgs = resolvedInput.args ?? []
903
+ const workflowInput = resolvedInput.input ?? null
904
+
905
+ // Parse tool mock config if provided
906
+ const toolMockConfig: ToolMockConfig | undefined =
907
+ body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
908
+ ? body.toolMockConfig as ToolMockConfig
909
+ : undefined
910
+
911
+ // Parse AI mock config if provided
912
+ const aiMockConfig: AIMockConfig | undefined =
913
+ body.aiMockConfig && typeof body.aiMockConfig === 'object' && !Array.isArray(body.aiMockConfig)
914
+ ? body.aiMockConfig as AIMockConfig
915
+ : undefined
916
+
917
+ // Parse prompt mock config if provided
918
+ const promptMockConfig: Record<string, unknown> | undefined =
919
+ body.promptMockConfig && typeof body.promptMockConfig === 'object' && !Array.isArray(body.promptMockConfig)
920
+ ? body.promptMockConfig as Record<string, unknown>
921
+ : undefined
922
+
923
+ // Parse user prompt mock config if provided
924
+ const userPromptMockConfig: Record<string, unknown> | undefined =
925
+ body.userPromptMockConfig && typeof body.userPromptMockConfig === 'object' && !Array.isArray(body.userPromptMockConfig)
926
+ ? body.userPromptMockConfig as Record<string, unknown>
927
+ : undefined
928
+
929
+ const workflowsModulePath = resolveWorkflowModule(cwd)
930
+ if (!workflowsModulePath) {
931
+ return {
932
+ ok: false,
933
+ mode,
934
+ runCount,
935
+ traces: [],
936
+ error: 'Cannot find ed_workflows.ts/js in workspace root.',
937
+ }
938
+ }
939
+
940
+ const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools') ?? null
941
+ const runs = Array.from({ length: runCount }, (_, i) => i + 1)
942
+
943
+ console.log(`[elasticdash] Running workflow "${workflowName}" ${runCount} time(s) in ${mode} mode via subprocess`)
944
+
945
+ async function runOne(runNumber: number): Promise<ValidationRunTrace> {
946
+ console.log(`[elasticdash] === Run ${runNumber}: Starting workflow "${workflowName}" ===`)
947
+ const result = await runWorkflowInSubprocess(
948
+ workflowsModulePath!,
949
+ toolsModulePath,
950
+ workflowName,
951
+ workflowArgs,
952
+ workflowInput,
953
+ (toolMockConfig || aiMockConfig || promptMockConfig || userPromptMockConfig) ? { ...(toolMockConfig ? { toolMockConfig } : {}), ...(aiMockConfig ? { aiMockConfig } : {}), ...(promptMockConfig ? { promptMockConfig } : {}), ...(userPromptMockConfig ? { userPromptMockConfig } : {}) } : undefined,
954
+ )
955
+ .catch(err => {
956
+ throw { ok: false, error: `Workflow subprocess failed: ${formatError(err)}` }
957
+ });
958
+
959
+ // Reconstruct a minimal TraceHandle from serialised trace arrays
960
+ const traceStub = {
961
+ getSteps: () => (result.steps ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getSteps'] extends () => infer R ? R : never,
962
+ getLLMSteps: () => (result.llmSteps ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getLLMSteps'] extends () => infer R ? R : never,
963
+ getToolCalls: () => (result.toolCalls ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getToolCalls'] extends () => infer R ? R : never,
964
+ getCustomSteps: () => (result.customSteps ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getCustomSteps'] extends () => infer R ? R : never,
965
+ recordLLMStep: () => {},
966
+ recordToolCall: () => {},
967
+ recordCustomStep: () => {},
968
+ }
969
+
970
+ if (!result.ok) {
971
+ console.error(`[elasticdash] Run ${runNumber}: Workflow failed:`, result.error)
972
+ return {
973
+ runNumber,
974
+ ok: false,
975
+ error: result.error,
976
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, result.error, traceStub, result.workflowTrace),
977
+ workflowTrace: result.workflowTrace,
978
+ currentOutput: result.currentOutput,
979
+ snapshotId: result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined,
980
+ }
981
+ }
982
+
983
+ console.log(`[elasticdash] Run ${runNumber}: Workflow completed successfully`)
984
+ return {
985
+ runNumber,
986
+ ok: true,
987
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, undefined, traceStub, result.workflowTrace),
988
+ workflowTrace: result.workflowTrace,
989
+ currentOutput: result.currentOutput,
990
+ snapshotId: result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined,
991
+ }
992
+ }
993
+
994
+ try {
995
+ let traces: ValidationRunTrace[]
996
+ if (sequential) {
997
+ traces = []
998
+ for (const runNumber of runs) {
999
+ traces.push(await runOne(runNumber))
1000
+ }
1001
+ } else {
1002
+ traces = await Promise.all(runs.map(runOne))
1003
+ }
1004
+
1005
+ console.log(`[elasticdash] Completed ${traces.length} workflow run(s). Success: ${traces.filter(t => t.ok).length}, Failed: ${traces.filter(t => !t.ok).length}`)
1006
+ return { ok: true, mode, runCount, traces }
1007
+ } catch (error) {
1008
+ console.error('[elasticdash] Workflow validation failed with exception:', error)
1009
+ return {
1010
+ ok: false,
1011
+ mode,
1012
+ runCount,
1013
+ traces: [],
1014
+ error: `Workflow validation failed: ${formatError(error)}`,
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
1020
+ return new Promise((resolve, reject) => {
1021
+ let raw = ''
1022
+ req.setEncoding('utf8')
1023
+ req.on('data', (chunk) => {
1024
+ raw += chunk
1025
+ if (raw.length > 2_000_000) {
1026
+ reject(new Error('Request body too large.'))
1027
+ }
1028
+ })
1029
+ req.on('end', () => {
1030
+ if (!raw.trim()) {
1031
+ resolve({})
1032
+ return
1033
+ }
1034
+ try {
1035
+ resolve(JSON.parse(raw))
1036
+ } catch {
1037
+ reject(new Error('Invalid JSON body.'))
1038
+ }
1039
+ })
1040
+ req.on('error', reject)
1041
+ })
1042
+ }
1043
+
1044
+ /**
1045
+ * Resolve a relative module specifier to an existing file path.
1046
+ * Tries .ts, .tsx, .js, .jsx extensions (TypeScript sources preferred).
1047
+ */
1048
+ function resolveModulePath(fromDir: string, specifier: string): string | null {
1049
+ if (!specifier.startsWith('.')) return null
1050
+ const exts = ['.ts', '.tsx', '.js', '.jsx', '']
1051
+ for (const ext of exts) {
1052
+ const candidate = path.resolve(fromDir, specifier + ext)
1053
+ if (existsSync(candidate)) return candidate
1054
+ }
1055
+ return null
1056
+ }
1057
+
1058
+ /** 1-based line number of a character index within source text */
1059
+ function lineAt(src: string, index: number): number {
1060
+ return src.slice(0, index).split('\n').length
1061
+ }
1062
+
1063
+ /**
1064
+ * Given source text, try to find the signature of a named export or declaration.
1065
+ * Returns { isAsync, signature, lineNumber?, sourceCode? }.
1066
+ */
1067
+ function findFunctionInSource(src: string, name: string): { isAsync: boolean; signature: string; lineNumber?: number; sourceCode?: string } {
1068
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1069
+ // export [async] function name(params)
1070
+ let m = src.match(new RegExp(`export\\s+(async\\s+)?function\\s+${escaped}\\s*(\\([^)]*\\))`))
1071
+ if (m) return { isAsync: !!m[1], signature: m[2], lineNumber: lineAt(src, m.index!), sourceCode: extractSource(src, m.index!) }
1072
+ // [async] function name(params) — non-exported, for re-export cases
1073
+ m = src.match(new RegExp(`(?:^|\\n)\\s*(?:async\\s+)?function\\s+${escaped}\\s*(\\([^)]*\\))`, 'm'))
1074
+ if (m) return {
1075
+ isAsync: new RegExp(`async\\s+function\\s+${escaped}`).test(src),
1076
+ signature: m[1],
1077
+ lineNumber: lineAt(src, m.index!),
1078
+ sourceCode: extractSource(src, m.index!),
1079
+ }
1080
+ // export const name = [async] (params) =>
1081
+ m = src.match(new RegExp(`export\\s+const\\s+${escaped}\\s*=\\s*(async\\s*)?(\\([^)]*\\))\\s*=>`))
1082
+ if (m) return { isAsync: !!m[1], signature: m[2], lineNumber: lineAt(src, m.index!) }
1083
+ // const name = [async] (params) =>
1084
+ m = src.match(new RegExp(`(?:^|\\n)\\s*const\\s+${escaped}\\s*=\\s*(async\\s*)?(\\([^)]*\\))\\s*=>`, 'm'))
1085
+ if (m) return { isAsync: !!m[1], signature: m[2], lineNumber: lineAt(src, m.index!) }
1086
+ return { isAsync: false, signature: '()' }
1087
+ }
1088
+
1089
+ /** Extract ~2000 chars of source starting at a matched index */
1090
+ function extractSource(src: string, index: number): string {
1091
+ const snippet = src.slice(index, index + 2000)
1092
+ return snippet.length < 2000 ? snippet : snippet + '\n// (truncated)'
1093
+ }
1094
+
1095
+ /**
1096
+ * Parse exported names from an ed_*.ts / ed_*.js source file without executing it.
1097
+ * Handles: direct function/const exports, named re-exports, and import+destructure exports.
1098
+ */
1099
+ function extractExportsFromSource(filePath: string): ParsedExport[] {
1100
+ let src: string
1101
+ try {
1102
+ src = readFileSync(filePath, 'utf8')
1103
+ } catch {
1104
+ return []
1105
+ }
1106
+ const dir = path.dirname(filePath)
1107
+ const results: ParsedExport[] = []
1108
+
1109
+ // 1. Direct: export [async] function name(params) { … }
1110
+ for (const m of src.matchAll(/export\s+(async\s+)?function\s+(\w+)\s*(\([^)]*\))/g)) {
1111
+ results.push({
1112
+ name: m[2],
1113
+ isAsync: !!m[1],
1114
+ signature: m[3],
1115
+ filePath,
1116
+ lineNumber: lineAt(src, m.index!),
1117
+ sourceCode: extractSource(src, m.index!),
1118
+ })
1119
+ }
1120
+
1121
+ // 2. Direct: export const name = [async] (params) => …
1122
+ for (const m of src.matchAll(/export\s+const\s+(\w+)\s*=\s*(async\s*)?\(([^)]*)\)\s*=>/g)) {
1123
+ results.push({
1124
+ name: m[1],
1125
+ isAsync: !!m[2],
1126
+ signature: `(${m[3]})`,
1127
+ filePath,
1128
+ lineNumber: lineAt(src, m.index!),
1129
+ sourceCode: extractSource(src, m.index!),
1130
+ })
1131
+ }
1132
+
1133
+ // 3. Named re-exports: export { X [as Y], … } from './module'
1134
+ for (const m of src.matchAll(/export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) {
1135
+ const modulePath = resolveModulePath(dir, m[2])
1136
+ let moduleSrc = ''
1137
+ try { if (modulePath) moduleSrc = readFileSync(modulePath, 'utf8') } catch { /* ignore */ }
1138
+
1139
+ for (const spec of m[1].split(',')) {
1140
+ const parts = spec.trim().split(/\s+as\s+/)
1141
+ const originalName = parts[0].trim()
1142
+ const exportedName = (parts[1] ?? parts[0]).trim()
1143
+ if (!exportedName || exportedName === 'default') continue
1144
+
1145
+ const info = moduleSrc ? findFunctionInSource(moduleSrc, originalName) : { isAsync: false, signature: '()' }
1146
+ results.push({
1147
+ name: exportedName,
1148
+ isAsync: info.isAsync,
1149
+ signature: info.signature,
1150
+ filePath: modulePath ?? filePath,
1151
+ lineNumber: info.lineNumber,
1152
+ sourceCode: info.sourceCode,
1153
+ })
1154
+ }
1155
+ }
1156
+
1157
+ // 4. Import + destructure: import { obj } from './m' + export const { a, b } = obj
1158
+ for (const imp of src.matchAll(/import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) {
1159
+ const importedNames = imp[1].split(',').map(s => {
1160
+ const parts = s.trim().split(/\s+as\s+/)
1161
+ return { original: parts[0].trim(), local: (parts[1] ?? parts[0]).trim() }
1162
+ }).filter(n => n.local)
1163
+
1164
+ const modulePath = resolveModulePath(dir, imp[2])
1165
+
1166
+ for (const { local } of importedNames) {
1167
+ // Look for: export const { a, b, c } = local
1168
+ const destructureRe = new RegExp(`export\\s+const\\s+\\{([^}]+)\\}\\s*=\\s*${local.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
1169
+ const dm = src.match(destructureRe)
1170
+ if (!dm) continue
1171
+
1172
+ let moduleSrc = ''
1173
+ try { if (modulePath) moduleSrc = readFileSync(modulePath, 'utf8') } catch { /* ignore */ }
1174
+
1175
+ for (const member of dm[1].split(',')) {
1176
+ const name = member.trim()
1177
+ if (!name) continue
1178
+ const info = moduleSrc ? findFunctionInSource(moduleSrc, name) : { isAsync: false, signature: '()' }
1179
+ results.push({
1180
+ name,
1181
+ isAsync: info.isAsync,
1182
+ signature: info.signature,
1183
+ filePath: modulePath ?? filePath,
1184
+ lineNumber: info.lineNumber,
1185
+ sourceCode: info.sourceCode,
1186
+ })
1187
+ }
1188
+ }
1189
+ }
1190
+
1191
+ return results
1192
+ }
1193
+
1194
+ /**
1195
+ * Scan for ed_tools.ts or ed_tools.js and extract exported functions
1196
+ */
1197
+ function scanTools(cwd: string): ToolInfo[] {
1198
+ for (const candidate of [path.join(cwd, 'ed_tools.ts'), path.join(cwd, 'ed_tools.js')]) {
1199
+ if (!existsSync(candidate)) continue
1200
+ const exports = extractExportsFromSource(candidate)
1201
+ if (exports.length > 0) {
1202
+ return exports.map(e => ({
1203
+ name: e.name,
1204
+ isAsync: e.isAsync,
1205
+ signature: e.signature,
1206
+ filePath: e.filePath,
1207
+ lineNumber: e.lineNumber,
1208
+ sourceCode: e.sourceCode,
1209
+ }))
1210
+ }
1211
+ }
1212
+ return []
1213
+ }
1214
+
1215
+ /**
1216
+ * Scan for ed_workflows.ts or ed_workflows.js and extract exported functions
1217
+ */
1218
+ function scanWorkflows(cwd: string): WorkflowInfo[] {
1219
+ for (const candidate of [path.join(cwd, 'ed_workflows.ts'), path.join(cwd, 'ed_workflows.js')]) {
1220
+ if (!existsSync(candidate)) continue
1221
+ const exports = extractExportsFromSource(candidate)
1222
+ if (exports.length > 0) {
1223
+ return exports.map(e => ({
1224
+ name: e.name,
1225
+ isAsync: e.isAsync,
1226
+ signature: e.signature,
1227
+ filePath: e.filePath,
1228
+ lineNumber: e.lineNumber,
1229
+ sourceFile: e.filePath,
1230
+ sourceCode: e.sourceCode,
1231
+ }))
1232
+ }
1233
+ }
1234
+ return []
1235
+ }
1236
+
1237
+ /**
1238
+ * Open URL in default browser (platform-aware)
1239
+ */
1240
+ function openBrowser(url: string): void {
1241
+ const platform = process.platform
1242
+
1243
+ if (platform === 'darwin') {
1244
+ spawn('open', [url], { detached: true, stdio: 'ignore' })
1245
+ } else if (platform === 'linux') {
1246
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' })
1247
+ } else if (platform === 'win32') {
1248
+ spawn('cmd', ['/c', 'start', url], { detached: true, stdio: 'ignore', shell: true })
1249
+ }
1250
+ }
1251
+
1252
+ /**
1253
+ * Get the dashboard HTML page
1254
+ *
1255
+ * HTML content is inlined at build time by scripts/inline-html.js
1256
+ * Edit src/html/dashboard.html to modify the dashboard UI
1257
+ */
1258
+ function getDashboardHtml(): string {
1259
+ /* DASHBOARD_HTML_START */
1260
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
1261
+ return readFileSync(path.join(__dirname, 'html', 'dashboard.html'), 'utf8')
1262
+ /* DASHBOARD_HTML_END */
1263
+ }
1264
+
1265
+ const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.turbo', 'build', 'coverage'])
1266
+ const SEARCH_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx'])
1267
+
1268
+ /**
1269
+ * Normalize text for fuzzy searching: collapse whitespace, handle common variations
1270
+ */
1271
+ function normalizeForSearch(text: string): string {
1272
+ return text
1273
+ .replace(/\s+/g, ' ') // Collapse whitespace
1274
+ .replace(/["'`]/g, '') // Remove quotes that might differ
1275
+ .trim()
1276
+ .toLowerCase()
1277
+ }
1278
+
1279
+ /**
1280
+ * Walk the project tree and find the first file+line containing `query`.
1281
+ * Returns { filePath, lineNumber } or null.
1282
+ * Now supports fuzzy matching with normalized text.
1283
+ */
1284
+ function searchInFiles(dir: string, query: string): { filePath: string; lineNumber: number } | null {
1285
+ let entries: string[]
1286
+ try { entries = readdirSync(dir) } catch { return null }
1287
+
1288
+ const normalizedQuery = normalizeForSearch(query)
1289
+ const exactQuery = query.trim()
1290
+
1291
+ for (const entry of entries) {
1292
+ if (SEARCH_SKIP_DIRS.has(entry)) continue
1293
+ const full = path.join(dir, entry)
1294
+ let stat
1295
+ try { stat = statSync(full) } catch { continue }
1296
+
1297
+ if (stat.isDirectory()) {
1298
+ const result = searchInFiles(full, query)
1299
+ if (result) return result
1300
+ } else if (SEARCH_EXTS.has(path.extname(entry))) {
1301
+ try {
1302
+ const content = readFileSync(full, 'utf8')
1303
+ const lines = content.split('\n')
1304
+
1305
+ // Try exact match first (faster)
1306
+ for (let i = 0; i < lines.length; i++) {
1307
+ if (lines[i].includes(exactQuery)) {
1308
+ return { filePath: full, lineNumber: i + 1 }
1309
+ }
1310
+ }
1311
+
1312
+ // Try normalized/fuzzy match
1313
+ const normalizedContent = normalizeForSearch(content)
1314
+ if (normalizedContent.includes(normalizedQuery)) {
1315
+ // Find which line it's on
1316
+ let charCount = 0
1317
+ for (let i = 0; i < lines.length; i++) {
1318
+ const normalizedLine = normalizeForSearch(lines[i])
1319
+ if (normalizedLine.includes(normalizedQuery)) {
1320
+ return { filePath: full, lineNumber: i + 1 }
1321
+ }
1322
+ charCount += lines[i].length + 1 // +1 for newline
1323
+ }
1324
+ }
1325
+ } catch { /* skip unreadable files */ }
1326
+ }
1327
+ }
1328
+ return null
1329
+ }
1330
+
1331
+ // ---------------------------------------------------------------------------
1332
+ // HTTP Workflow Mode — config types and helpers
1333
+ // ---------------------------------------------------------------------------
1334
+
1335
+ export interface HttpWorkflowConfig {
1336
+ mode: 'http'
1337
+ url: string
1338
+ method?: string
1339
+ headers?: Record<string, string>
1340
+ bodyTemplate?: Record<string, unknown>
1341
+ responseFormat?: 'vercel-ai-stream' | 'json'
1342
+ }
1343
+
1344
+ interface ElasticDashConfig {
1345
+ workflows?: Record<string, { mode?: string } & Partial<HttpWorkflowConfig>>
1346
+ }
1347
+
1348
+ /** Load elasticdash.config.ts via a tsx-enabled subprocess and return the parsed object. */
1349
+ async function loadElasticDashConfig(cwd: string): Promise<ElasticDashConfig> {
1350
+ const configPath = resolveRuntimeModule(cwd, 'elasticdash.config')
1351
+ if (!configPath) return {}
1352
+ return new Promise((resolve) => {
1353
+ const nodeOptions = process.env.NODE_OPTIONS ?? ''
1354
+ const tsxFlag = '--import tsx'
1355
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim()
1356
+ const childEnv = { ...process.env, NODE_OPTIONS: isDenoProject(cwd) ? nodeOptions : childNodeOptions }
1357
+ const configUrl = pathToFileURL(configPath).href
1358
+ const child = spawn(process.execPath, ['--input-type=module'], {
1359
+ env: childEnv,
1360
+ cwd,
1361
+ stdio: ['pipe', 'pipe', 'ignore'],
1362
+ })
1363
+ let output = ''
1364
+ child.stdout.on('data', (chunk: Buffer) => { output += chunk.toString() })
1365
+ child.on('close', () => {
1366
+ try { resolve(JSON.parse(output) as ElasticDashConfig) } catch { resolve({}) }
1367
+ })
1368
+ child.on('error', () => resolve({}))
1369
+ child.stdin.write(`import m from '${configUrl}'; process.stdout.write(JSON.stringify(m.default ?? m))`)
1370
+ child.stdin.end()
1371
+ })
1372
+ }
1373
+
1374
+ /** Resolve {{env.VAR}} and {{input.field}} placeholders in a value. */
1375
+ function resolveTemplateValue(value: unknown, input: Record<string, unknown>): unknown {
1376
+ if (typeof value === 'string') {
1377
+ // If the entire string is a single placeholder, return the raw value so arrays/objects
1378
+ // are not coerced to strings (e.g. messages: "{{input.messages}}" stays an array).
1379
+ const exactInput = value.match(/^\{\{input\.([^}]+)\}\}$/)
1380
+ if (exactInput) return input[exactInput[1]] ?? ''
1381
+ const exactEnv = value.match(/^\{\{env\.([^}]+)\}\}$/)
1382
+ if (exactEnv) return process.env[exactEnv[1]] ?? ''
1383
+ // Interpolated placeholder — coerce to string for embedding within a larger string
1384
+ return value.replace(/\{\{env\.([^}]+)\}\}/g, (_, k) => process.env[k] ?? '')
1385
+ .replace(/\{\{input\.([^}]+)\}\}/g, (_, k) => String(input[k] ?? ''))
1386
+ .replace(/\{\{timestamp\}\}/g, () => String(Date.now()))
1387
+ }
1388
+ if (Array.isArray(value)) return value.map(v => resolveTemplateValue(v, input))
1389
+ if (value && typeof value === 'object') {
1390
+ const out: Record<string, unknown> = {}
1391
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
1392
+ out[k] = resolveTemplateValue(v, input)
1393
+ }
1394
+ return out
1395
+ }
1396
+ return value
1397
+ }
1398
+
1399
+ interface HttpWorkflowRunOptions {
1400
+ workflowName: string
1401
+ workflowInput: unknown
1402
+ frozenEvents?: WorkflowEvent[]
1403
+ promptMocks?: Record<string, string>
1404
+ userPromptMocks?: Record<string, unknown>
1405
+ toolMockConfig?: ToolMockConfig
1406
+ aiMockConfig?: AIMockConfig
1407
+ pushedEvents: Map<string, WorkflowEvent[]>
1408
+ runConfigs: Map<string, { frozenEvents: WorkflowEvent[]; promptMocks: Record<string, string>; userPromptMocks?: Record<string, unknown>; toolMockConfig?: ToolMockConfig; aiMockConfig?: AIMockConfig }>
1409
+ config: HttpWorkflowConfig
1410
+ dashboardPort: number
1411
+ }
1412
+
1413
+ async function runHttpWorkflow(opts: HttpWorkflowRunOptions): Promise<WorkflowSubprocessResult> {
1414
+ const { workflowName, workflowInput, frozenEvents = [], promptMocks = {}, userPromptMocks, toolMockConfig, aiMockConfig, pushedEvents, runConfigs, config, dashboardPort } = opts
1415
+ const runId = randomUUID()
1416
+
1417
+ // Register run config so the user's server can fetch frozen events, prompt mocks, and output mocks
1418
+ pushedEvents.set(runId, [])
1419
+ runConfigs.set(runId, { frozenEvents, promptMocks, userPromptMocks, toolMockConfig, aiMockConfig })
1420
+
1421
+ try {
1422
+ const parsedInput = parseObservationInput(workflowInput)
1423
+ const inputObj = parsedInput && typeof parsedInput === 'object' && !Array.isArray(parsedInput) ? parsedInput as Record<string, unknown> : {}
1424
+ const method = config.method ?? 'POST'
1425
+ const resolvedHeaders: Record<string, string> = {}
1426
+ for (const [k, v] of Object.entries(config.headers ?? {})) {
1427
+ resolvedHeaders[k] = resolveTemplateValue(v, inputObj) as string
1428
+ }
1429
+ resolvedHeaders['x-elasticdash-run-id'] = runId
1430
+ resolvedHeaders['x-elasticdash-server'] = `http://localhost:${dashboardPort}`
1431
+
1432
+ const body = config.bodyTemplate
1433
+ ? JSON.stringify(resolveTemplateValue(config.bodyTemplate, inputObj))
1434
+ : undefined
1435
+ if (body && !resolvedHeaders['Content-Type']) {
1436
+ resolvedHeaders['Content-Type'] = 'application/json'
1437
+ }
1438
+
1439
+ console.log(`[elasticdash] HTTP workflow "${workflowName}" → ${method} ${config.url} (runId=${runId})`)
1440
+
1441
+ const response = await fetch(config.url, { method, headers: resolvedHeaders, body })
1442
+
1443
+ let currentOutput: unknown
1444
+ if (config.responseFormat === 'vercel-ai-stream' && response.body) {
1445
+ const decoder = new TextDecoder()
1446
+ const reader = response.body.getReader()
1447
+ let text = ''
1448
+ for (;;) {
1449
+ const { done, value } = await reader.read()
1450
+ if (done) break
1451
+ text += decoder.decode(value, { stream: true })
1452
+ }
1453
+ // Extract final text from Vercel AI stream data lines
1454
+ let finalText = ''
1455
+ for (const line of text.split('\n')) {
1456
+ if (!line.startsWith('0:')) continue
1457
+ try { finalText += JSON.parse(line.slice(2)) } catch { /* skip */ }
1458
+ }
1459
+ currentOutput = finalText || text
1460
+ } else {
1461
+ try { currentOutput = await response.clone().json() } catch { currentOutput = await response.text() }
1462
+ }
1463
+
1464
+ if (!response.ok) {
1465
+ return { ok: false, error: `HTTP ${response.status}: ${response.statusText}`, currentOutput }
1466
+ }
1467
+
1468
+ // Drain window: wait for in-flight push POSTs to arrive
1469
+ const drainMs = parseInt(process.env.ELASTICDASH_HTTP_DRAIN_MS ?? '300', 10)
1470
+ await new Promise(resolve => setTimeout(resolve, drainMs))
1471
+
1472
+ const events = (pushedEvents.get(runId) ?? []).sort((a, b) => a.timestamp - b.timestamp)
1473
+ console.log(`[elasticdash] runHttpWorkflow drain complete: ${events.length} events collected for runId=${runId}`)
1474
+ const workflowTrace: WorkflowTrace = { traceId: runId, events }
1475
+
1476
+ return { ok: true, currentOutput, workflowTrace, steps: [], llmSteps: [], toolCalls: [], customSteps: [] }
1477
+ } catch (error) {
1478
+ return { ok: false, error: `HTTP workflow failed: ${formatError(error)}` }
1479
+ } finally {
1480
+ pushedEvents.delete(runId)
1481
+ runConfigs.delete(runId)
1482
+ }
1483
+ }
1484
+
1485
+ /**
1486
+ * Start the dashboard server
1487
+ */
1488
+ export async function startDashboardServer(
1489
+ cwd: string,
1490
+ options: DashboardServerOptions = {}
1491
+ ): Promise<DashboardServer> {
1492
+ const port = options.port ?? 4573
1493
+ const autoOpen = options.autoOpen ?? true
1494
+
1495
+ // In-memory store for telemetry events pushed from HTTP workflow mode runs.
1496
+ // Maps runId -> accumulated WorkflowEvent[]
1497
+ const pushedEvents = new Map<string, WorkflowEvent[]>()
1498
+
1499
+ // Per-run config for HTTP workflow mode (frozen events + prompt mocks for replay).
1500
+ // Maps runId -> { frozenEvents, promptMocks }
1501
+ const runConfigs = new Map<string, { frozenEvents: WorkflowEvent[]; promptMocks: Record<string, string>; userPromptMocks?: Record<string, unknown>; toolMockConfig?: ToolMockConfig; aiMockConfig?: AIMockConfig }>()
1502
+
1503
+ // Scan workflows, tools, and config once at startup
1504
+ const workflows = scanWorkflows(cwd)
1505
+ const tools = scanTools(cwd)
1506
+ const codeIndex: CodeIndex = { workflows, tools }
1507
+ const elasticdashConfig = await loadElasticDashConfig(cwd)
1508
+
1509
+ console.log(`[elasticdash] Scanned: ${workflows.length} workflows, ${tools.length} tools`)
1510
+
1511
+ // Create HTTP server
1512
+ const server = http.createServer((req, res) => {
1513
+ // Disable socket inactivity timeout — workflow runs can take arbitrarily long
1514
+ req.socket.setTimeout(0)
1515
+ const url = new URL(req.url || '/', `http://${req.headers.host}`)
1516
+
1517
+ if (url.pathname === '/api/workflows') {
1518
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1519
+ res.end(JSON.stringify({ workflows }))
1520
+ } else if (url.pathname === '/api/repo-root') {
1521
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1522
+ res.end(JSON.stringify({ repoRoot: cwd }))
1523
+ } else if (url.pathname === '/api/code-index') {
1524
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1525
+ res.end(JSON.stringify(codeIndex))
1526
+ } else if (url.pathname === '/api/search-source') {
1527
+ const q = url.searchParams.get('q') || ''
1528
+ const result = q.length >= 8 ? searchInFiles(cwd, q) : null
1529
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1530
+ res.end(JSON.stringify(result ?? {}))
1531
+ } else if (url.pathname === '/api/rerun-observation' && req.method === 'POST') {
1532
+ ;(async () => {
1533
+ try {
1534
+ const body = (await readJsonBody(req)) as { observation?: DashboardObservation }
1535
+ if (!body?.observation || typeof body.observation !== 'object') {
1536
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1537
+ res.end(JSON.stringify({ ok: false, error: 'Request must include an observation object.' }))
1538
+ return
1539
+ }
1540
+
1541
+ const result = await rerunObservation(cwd, body.observation, tools)
1542
+ const statusCode = result.ok ? 200 : 400
1543
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' })
1544
+ res.end(JSON.stringify(result))
1545
+ } catch (error) {
1546
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1547
+ res.end(JSON.stringify({ ok: false, error: formatError(error) }))
1548
+ }
1549
+ })()
1550
+ } else if (url.pathname === '/api/validate-workflow' && req.method === 'POST') {
1551
+ ;(async () => {
1552
+ try {
1553
+ const body = (await readJsonBody(req)) as WorkflowValidationBody
1554
+ const workflowName = typeof body.workflowName === 'string' ? body.workflowName.trim() : ''
1555
+ const httpConfig = elasticdashConfig.workflows?.[workflowName]
1556
+ if (httpConfig?.mode === 'http') {
1557
+ // HTTP workflow mode — call user's dev server instead of subprocess
1558
+ const runCount = typeof body.runCount === 'number' ? Math.max(1, Math.min(50, body.runCount)) : 1
1559
+ const sequential = body.sequential === true
1560
+ const resolvedInput = resolveWorkflowArgsFromObservations(body, workflowName)
1561
+ const workflowInput = resolvedInput.input ?? null
1562
+ const traces: ValidationRunTrace[] = []
1563
+ const promptMocks: Record<string, string> = flattenPromptMockConfig(body.promptMockConfig)
1564
+ const valUserPromptMocks: Record<string, unknown> | undefined =
1565
+ body.userPromptMockConfig && typeof body.userPromptMockConfig === 'object' && !Array.isArray(body.userPromptMockConfig)
1566
+ ? body.userPromptMockConfig as Record<string, unknown>
1567
+ : undefined
1568
+ const valToolMockConfig: ToolMockConfig | undefined =
1569
+ body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
1570
+ ? body.toolMockConfig as ToolMockConfig
1571
+ : undefined
1572
+ const valAiMockConfig: AIMockConfig | undefined =
1573
+ body.aiMockConfig && typeof body.aiMockConfig === 'object' && !Array.isArray(body.aiMockConfig)
1574
+ ? body.aiMockConfig as AIMockConfig
1575
+ : undefined
1576
+ const runOne = async (runNumber: number): Promise<ValidationRunTrace> => {
1577
+ const result = await runHttpWorkflow({
1578
+ workflowName, workflowInput, pushedEvents, runConfigs,
1579
+ config: httpConfig as HttpWorkflowConfig, dashboardPort: port,
1580
+ promptMocks, userPromptMocks: valUserPromptMocks, toolMockConfig: valToolMockConfig, aiMockConfig: valAiMockConfig,
1581
+ })
1582
+ const traceStub = { getSteps: () => [], getLLMSteps: () => [], getToolCalls: () => [], getCustomSteps: () => [], recordLLMStep: () => {}, recordToolCall: () => {}, recordCustomStep: () => {} }
1583
+ return {
1584
+ runNumber, ok: result.ok, error: result.error,
1585
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, result.error, traceStub, result.workflowTrace),
1586
+ workflowTrace: result.workflowTrace,
1587
+ currentOutput: result.currentOutput,
1588
+ snapshotId: result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined,
1589
+ }
1590
+ }
1591
+ if (sequential) {
1592
+ for (let i = 1; i <= runCount; i++) traces.push(await runOne(i))
1593
+ } else {
1594
+ traces.push(...await Promise.all(Array.from({ length: runCount }, (_, i) => runOne(i + 1))))
1595
+ }
1596
+ const ok = traces.some(t => t.ok)
1597
+ res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' })
1598
+ res.end(JSON.stringify({ ok, mode: sequential ? 'sequential' : 'parallel', runCount, traces }))
1599
+ return
1600
+ }
1601
+ const result = await validateWorkflowRuns(cwd, body)
1602
+ const statusCode = result.ok ? 200 : 400
1603
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' })
1604
+ res.end(JSON.stringify(result))
1605
+ } catch (error) {
1606
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1607
+ res.end(JSON.stringify({ ok: false, error: formatError(error) }))
1608
+ }
1609
+ })()
1610
+ } else if (url.pathname === '/api/run-from-breakpoint' && req.method === 'POST') {
1611
+ ;(async () => {
1612
+ try {
1613
+ const body = (await readJsonBody(req)) as {
1614
+ workflowName?: unknown
1615
+ checkpoint?: unknown
1616
+ history?: unknown
1617
+ snapshotId?: unknown
1618
+ observations?: unknown
1619
+ toolMockConfig?: unknown
1620
+ aiMockConfig?: unknown
1621
+ promptMockConfig?: unknown
1622
+ userPromptMockConfig?: unknown
1623
+ }
1624
+ const workflowName = typeof body.workflowName === 'string' ? body.workflowName.trim() : ''
1625
+ if (!workflowName) {
1626
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1627
+ res.end(JSON.stringify({ ok: false, error: 'workflowName is required.' }))
1628
+ return
1629
+ }
1630
+ const checkpoint = typeof body.checkpoint === 'number' ? body.checkpoint : 0
1631
+ let history: WorkflowEvent[]
1632
+ if (typeof body.snapshotId === 'string') {
1633
+ const snap = loadSnapshot(cwd, body.snapshotId)
1634
+ history = snap ? snap.events : []
1635
+ } else {
1636
+ history = Array.isArray(body.history) ? (body.history as WorkflowEvent[]) : []
1637
+ }
1638
+
1639
+ const validationBody: WorkflowValidationBody = { workflowName, observations: body.observations }
1640
+ const resolvedInput = resolveWorkflowArgsFromObservations(validationBody, workflowName)
1641
+ if (resolvedInput.error) {
1642
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1643
+ res.end(JSON.stringify({ ok: false, error: resolvedInput.error }))
1644
+ return
1645
+ }
1646
+ const workflowInput = resolvedInput.input ?? null
1647
+
1648
+ const frozenEvents = history.filter((event) => (
1649
+ event.id <= checkpoint
1650
+ && (event.type === 'ai' || event.type === 'tool' || event.type === 'http' || event.type === 'db')
1651
+ ))
1652
+ const frozenEventIds = new Set(frozenEvents.map((e) => e.id))
1653
+
1654
+ const httpConfig = elasticdashConfig.workflows?.[workflowName]
1655
+ if (httpConfig?.mode === 'http') {
1656
+ // HTTP workflow mode — call user's dev server with frozen events + prompt mocks for step replay
1657
+ const bpPromptMocks: Record<string, string> = flattenPromptMockConfig(body.promptMockConfig)
1658
+ const bpUserPromptMocks: Record<string, unknown> | undefined =
1659
+ body.userPromptMockConfig && typeof body.userPromptMockConfig === 'object' && !Array.isArray(body.userPromptMockConfig)
1660
+ ? body.userPromptMockConfig as Record<string, unknown>
1661
+ : undefined
1662
+ const bpToolMockConfig: ToolMockConfig | undefined =
1663
+ body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
1664
+ ? body.toolMockConfig as ToolMockConfig
1665
+ : undefined
1666
+ const bpAiMockConfig: AIMockConfig | undefined =
1667
+ body.aiMockConfig && typeof body.aiMockConfig === 'object' && !Array.isArray(body.aiMockConfig)
1668
+ ? body.aiMockConfig as AIMockConfig
1669
+ : undefined
1670
+ console.log(`[elasticdash] Run from breakpoint (HTTP mode): workflow="${workflowName}" checkpoint=${checkpoint} frozen=${frozenEvents.length}`)
1671
+ const result = await runHttpWorkflow({
1672
+ workflowName, workflowInput, pushedEvents, runConfigs,
1673
+ config: httpConfig as HttpWorkflowConfig, dashboardPort: port,
1674
+ frozenEvents, promptMocks: bpPromptMocks, userPromptMocks: bpUserPromptMocks,
1675
+ toolMockConfig: bpToolMockConfig, aiMockConfig: bpAiMockConfig,
1676
+ })
1677
+ const traceStub = { getSteps: () => [], getLLMSteps: () => [], getToolCalls: () => [], getCustomSteps: () => [], recordLLMStep: () => {}, recordToolCall: () => {}, recordCustomStep: () => {} }
1678
+ const snapshotId = result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined
1679
+ const trace: ValidationRunTrace = {
1680
+ runNumber: 0,
1681
+ ok: result.ok,
1682
+ error: result.ok ? undefined : result.error,
1683
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, result.ok ? undefined : result.error, traceStub, result.workflowTrace, frozenEventIds),
1684
+ workflowTrace: result.workflowTrace,
1685
+ currentOutput: result.currentOutput,
1686
+ snapshotId,
1687
+ }
1688
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1689
+ res.end(JSON.stringify(trace))
1690
+ return
1691
+ }
1692
+
1693
+ const workflowsModulePath = resolveWorkflowModule(cwd)
1694
+ if (!workflowsModulePath) {
1695
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1696
+ res.end(JSON.stringify({ ok: false, error: 'Cannot find ed_workflows.ts/js in workspace root.' }))
1697
+ return
1698
+ }
1699
+
1700
+ const workflowArgs = resolvedInput.args ?? []
1701
+ const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools') ?? null
1702
+
1703
+ const toolMockConfig: ToolMockConfig | undefined =
1704
+ body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
1705
+ ? body.toolMockConfig as ToolMockConfig
1706
+ : undefined
1707
+
1708
+ const aiMockConfig: AIMockConfig | undefined =
1709
+ body.aiMockConfig && typeof body.aiMockConfig === 'object' && !Array.isArray(body.aiMockConfig)
1710
+ ? body.aiMockConfig as AIMockConfig
1711
+ : undefined
1712
+
1713
+ const promptMockConfig: Record<string, unknown> | undefined =
1714
+ body.promptMockConfig && typeof body.promptMockConfig === 'object' && !Array.isArray(body.promptMockConfig)
1715
+ ? body.promptMockConfig as Record<string, unknown>
1716
+ : undefined
1717
+
1718
+ const userPromptMockConfig: Record<string, unknown> | undefined =
1719
+ body.userPromptMockConfig && typeof body.userPromptMockConfig === 'object' && !Array.isArray(body.userPromptMockConfig)
1720
+ ? body.userPromptMockConfig as Record<string, unknown>
1721
+ : undefined
1722
+
1723
+ console.log(`[elasticdash] Run from breakpoint: workflow="${workflowName}" checkpoint=${checkpoint} historyLen=${history.length}`)
1724
+ const result = await runWorkflowInSubprocess(
1725
+ workflowsModulePath,
1726
+ toolsModulePath,
1727
+ workflowName,
1728
+ workflowArgs,
1729
+ workflowInput,
1730
+ { replayMode: true, checkpoint, history, ...(toolMockConfig ? { toolMockConfig } : {}), ...(aiMockConfig ? { aiMockConfig } : {}), ...(promptMockConfig ? { promptMockConfig } : {}), ...(userPromptMockConfig ? { userPromptMockConfig } : {}) },
1731
+ )
1732
+
1733
+ const traceStub = {
1734
+ getSteps: () => (result.steps ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getSteps'] extends () => infer R ? R : never,
1735
+ getLLMSteps: () => (result.llmSteps ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getLLMSteps'] extends () => infer R ? R : never,
1736
+ getToolCalls: () => (result.toolCalls ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getToolCalls'] extends () => infer R ? R : never,
1737
+ getCustomSteps: () => (result.customSteps ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getCustomSteps'] extends () => infer R ? R : never,
1738
+ recordLLMStep: () => {},
1739
+ recordToolCall: () => {},
1740
+ recordCustomStep: () => {},
1741
+ }
1742
+
1743
+ const snapshotId = result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined
1744
+ const trace: ValidationRunTrace = {
1745
+ runNumber: 0,
1746
+ ok: result.ok,
1747
+ error: result.ok ? undefined : result.error,
1748
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, result.ok ? undefined : result.error, traceStub, result.workflowTrace, frozenEventIds),
1749
+ workflowTrace: result.workflowTrace,
1750
+ currentOutput: result.currentOutput,
1751
+ snapshotId,
1752
+ }
1753
+
1754
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1755
+ res.end(JSON.stringify(trace))
1756
+ } catch (error) {
1757
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1758
+ res.end(JSON.stringify({ ok: false, error: formatError(error) }))
1759
+ }
1760
+ })()
1761
+ } else if (url.pathname === '/api/resume-agent-from-task' && req.method === 'POST') {
1762
+ ;(async () => {
1763
+ try {
1764
+ const body = (await readJsonBody(req)) as {
1765
+ workflowName?: unknown
1766
+ taskIndex?: unknown
1767
+ agentState?: unknown
1768
+ history?: unknown
1769
+ snapshotId?: unknown
1770
+ toolMockConfig?: unknown
1771
+ aiMockConfig?: unknown
1772
+ promptMockConfig?: unknown
1773
+ userPromptMockConfig?: unknown
1774
+ }
1775
+
1776
+ const workflowName = typeof body.workflowName === 'string' ? body.workflowName.trim() : ''
1777
+ if (!workflowName) {
1778
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1779
+ res.end(JSON.stringify({ ok: false, error: 'workflowName is required.' }))
1780
+ return
1781
+ }
1782
+
1783
+ const taskIndex = typeof body.taskIndex === 'number' ? body.taskIndex : 0
1784
+ let history: WorkflowEvent[]
1785
+ if (typeof body.snapshotId === 'string') {
1786
+ const snap = loadSnapshot(cwd, body.snapshotId)
1787
+ history = snap ? snap.events : []
1788
+ } else {
1789
+ history = Array.isArray(body.history) ? (body.history as WorkflowEvent[]) : []
1790
+ }
1791
+
1792
+ if (!body.agentState || typeof body.agentState !== 'object') {
1793
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1794
+ res.end(JSON.stringify({ ok: false, error: 'agentState is required.' }))
1795
+ return
1796
+ }
1797
+
1798
+ // Reconstruct AgentState: override resumeFromTaskIndex with the requested taskIndex
1799
+ const incomingState = body.agentState as AgentPlan & { plan?: AgentPlan; resumeFromTaskIndex?: number; trace?: WorkflowEvent[] }
1800
+ const agentState: AgentState = {
1801
+ plan: (incomingState.plan ?? incomingState) as AgentPlan,
1802
+ trace: incomingState.trace ?? history,
1803
+ resumeFromTaskIndex: taskIndex,
1804
+ }
1805
+
1806
+ const workflowsModulePath = resolveWorkflowModule(cwd)
1807
+ if (!workflowsModulePath) {
1808
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1809
+ res.end(JSON.stringify({ ok: false, error: 'Cannot find ed_workflows.ts/js in workspace root.' }))
1810
+ return
1811
+ }
1812
+
1813
+ const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools') ?? null
1814
+
1815
+ const toolMockConfig: ToolMockConfig | undefined =
1816
+ body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
1817
+ ? body.toolMockConfig as ToolMockConfig
1818
+ : undefined
1819
+
1820
+ const aiMockConfig: AIMockConfig | undefined =
1821
+ body.aiMockConfig && typeof body.aiMockConfig === 'object' && !Array.isArray(body.aiMockConfig)
1822
+ ? body.aiMockConfig as AIMockConfig
1823
+ : undefined
1824
+
1825
+ const promptMockConfig: Record<string, unknown> | undefined =
1826
+ body.promptMockConfig && typeof body.promptMockConfig === 'object' && !Array.isArray(body.promptMockConfig)
1827
+ ? body.promptMockConfig as Record<string, unknown>
1828
+ : undefined
1829
+
1830
+ const userPromptMockConfig: Record<string, unknown> | undefined =
1831
+ body.userPromptMockConfig && typeof body.userPromptMockConfig === 'object' && !Array.isArray(body.userPromptMockConfig)
1832
+ ? body.userPromptMockConfig as Record<string, unknown>
1833
+ : undefined
1834
+
1835
+ console.log(`[elasticdash] Resume agent from task: workflow="${workflowName}" taskIndex=${taskIndex}`)
1836
+
1837
+ const result = await runWorkflowInSubprocess(
1838
+ workflowsModulePath,
1839
+ toolsModulePath,
1840
+ workflowName,
1841
+ [],
1842
+ null,
1843
+ { replayMode: history.length > 0, checkpoint: 0, history, agentState, ...(toolMockConfig ? { toolMockConfig } : {}), ...(aiMockConfig ? { aiMockConfig } : {}), ...(promptMockConfig ? { promptMockConfig } : {}), ...(userPromptMockConfig ? { userPromptMockConfig } : {}) },
1844
+ )
1845
+
1846
+ const traceStub = {
1847
+ getSteps: () => (result.steps ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getSteps'] extends () => infer R ? R : never,
1848
+ getLLMSteps: () => (result.llmSteps ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getLLMSteps'] extends () => infer R ? R : never,
1849
+ getToolCalls: () => (result.toolCalls ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getToolCalls'] extends () => infer R ? R : never,
1850
+ getCustomSteps: () => (result.customSteps ?? []) as ReturnType<typeof import('./trace-adapter/context.js').createTraceHandle>['getCustomSteps'] extends () => infer R ? R : never,
1851
+ recordLLMStep: () => {},
1852
+ recordToolCall: () => {},
1853
+ recordCustomStep: () => {},
1854
+ }
1855
+
1856
+ const snapshotId = result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined
1857
+ const trace: ValidationRunTrace = {
1858
+ runNumber: 0,
1859
+ ok: result.ok,
1860
+ error: result.ok ? undefined : result.error,
1861
+ observations: buildValidationObservations(workflowName, null, result.currentOutput, result.ok ? undefined : result.error, traceStub, result.workflowTrace),
1862
+ workflowTrace: result.workflowTrace,
1863
+ currentOutput: result.currentOutput,
1864
+ snapshotId,
1865
+ }
1866
+
1867
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1868
+ res.end(JSON.stringify(trace))
1869
+ } catch (error) {
1870
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1871
+ res.end(JSON.stringify({ ok: false, error: formatError(error) }))
1872
+ }
1873
+ })()
1874
+ } else if (url.pathname === '/api/snapshots' && req.method === 'GET') {
1875
+ const id = url.searchParams.get('id')
1876
+ if (!id) {
1877
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1878
+ res.end(JSON.stringify({ ok: false, error: 'id is required.' }))
1879
+ } else {
1880
+ const snap = loadSnapshot(cwd, id)
1881
+ if (!snap) {
1882
+ res.writeHead(404, { 'Content-Type': 'application/json' })
1883
+ res.end(JSON.stringify({ ok: false, error: 'Snapshot not found.' }))
1884
+ } else {
1885
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1886
+ res.end(JSON.stringify(snap))
1887
+ }
1888
+ }
1889
+ } else if (url.pathname.startsWith('/api/run-configs/') && req.method === 'GET') {
1890
+ const runId = url.pathname.slice('/api/run-configs/'.length)
1891
+ const cfg = runConfigs.get(runId)
1892
+ res.writeHead(cfg ? 200 : 404, { 'Content-Type': 'application/json' })
1893
+ res.end(JSON.stringify({ frozenEvents: cfg?.frozenEvents ?? [], promptMocks: cfg?.promptMocks ?? {}, ...(cfg?.userPromptMocks ? { userPromptMocks: cfg.userPromptMocks } : {}), ...(cfg?.toolMockConfig ? { toolMockConfig: cfg.toolMockConfig } : {}), ...(cfg?.aiMockConfig ? { aiMockConfig: cfg.aiMockConfig } : {}) }))
1894
+ } else if (url.pathname === '/api/trace-events' && req.method === 'POST') {
1895
+ // Receive telemetry events pushed from wrapAI / wrapTool in HTTP workflow mode
1896
+ ;(async () => {
1897
+ try {
1898
+ const body = (await readJsonBody(req)) as { runId?: unknown; event?: unknown }
1899
+ if (typeof body.runId !== 'string' || !body.runId || !body.event || typeof body.event !== 'object') {
1900
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1901
+ res.end(JSON.stringify({ ok: false, error: 'runId (string) and event (object) are required.' }))
1902
+ return
1903
+ }
1904
+ const existing = pushedEvents.get(body.runId)
1905
+ if (!existing) {
1906
+ console.log(`[elasticdash] /api/trace-events: unknown runId=${body.runId}, known runIds=[${[...pushedEvents.keys()].join(',')}]`)
1907
+ res.writeHead(404, { 'Content-Type': 'application/json' })
1908
+ res.end(JSON.stringify({ ok: false, error: 'unknown runId' }))
1909
+ return
1910
+ }
1911
+ const evt = body.event as WorkflowEvent
1912
+ existing.push(evt)
1913
+ console.log(`[elasticdash] /api/trace-events: stored event type=${evt.type} name=${('name' in evt ? evt.name : '?')} runId=${body.runId} total=${existing.length}`)
1914
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1915
+ res.end(JSON.stringify({ ok: true }))
1916
+ } catch (error) {
1917
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1918
+ res.end(JSON.stringify({ ok: false, error: formatError(error) }))
1919
+ }
1920
+ })()
1921
+ } else {
1922
+ res.writeHead(200, { 'Content-Type': 'text/html' })
1923
+ res.end(getDashboardHtml())
1924
+ }
1925
+ })
1926
+
1927
+ // Start listening
1928
+ await new Promise<void>((resolve, reject) => {
1929
+ server.listen(port, () => resolve())
1930
+ server.on('error', reject)
1931
+ })
1932
+
1933
+ const snapshotsDir = path.join(cwd, '.temp', 'snapshots')
1934
+ function cleanupSnapshots() {
1935
+ try {
1936
+ if (existsSync(snapshotsDir)) rmSync(snapshotsDir, { recursive: true, force: true })
1937
+ } catch { /* best-effort */ }
1938
+ }
1939
+
1940
+ for (const sig of ['SIGINT', 'SIGTERM'] as const) {
1941
+ process.once(sig, () => {
1942
+ cleanupSnapshots()
1943
+ process.exit(0)
1944
+ })
1945
+ }
1946
+
1947
+ const url = `http://localhost:${port}`
1948
+
1949
+ // Auto-open browser
1950
+ if (autoOpen) {
1951
+ openBrowser(url)
1952
+ }
1953
+
1954
+ return {
1955
+ url,
1956
+ async close() {
1957
+ cleanupSnapshots()
1958
+ return new Promise<void>((resolve, reject) => {
1959
+ server.close((err) => {
1960
+ if (err) reject(err)
1961
+ else resolve()
1962
+ })
1963
+ })
1964
+ },
1965
+ }
1966
+ }
1967
+
1968
+ // Watch for changes in eb_* files
1969
+ const watcher = chokidar.watch('**/*', {
1970
+ ignored: /node_modules/,
1971
+ persistent: true
1972
+ });
1973
+
1974
+ watcher.on('ready', () => {
1975
+ console.log('File watcher is ready');
1976
+ });
1977
+
1978
+ watcher.on('error', (error) => {
1979
+ console.error('File watcher error:', error);
1980
+ });
1981
+
1982
+ // Throttle refetching to avoid excessive calls
1983
+ let refetchTimeout: NodeJS.Timeout | null = null;
1984
+ watcher.on('change', (path) => {
1985
+ if (refetchTimeout) clearTimeout(refetchTimeout);
1986
+ refetchTimeout = setTimeout(() => {
1987
+ console.log(`File ${path} has been changed`);
1988
+ refetchFunctions();
1989
+ }, 1000); // Throttle to 1 second
1990
+ });
1991
+
1992
+ async function refetchFunctions() {
1993
+ console.log('Refetching functions...');
1994
+
1995
+ // Clear the require cache for all files in the watched directory (ESM-compatible)
1996
+ const visited = new Set<string>();
1997
+
1998
+ async function clearCacheRecursively(url: string) {
1999
+ console.log(`Clearing cache for ${url}`);
2000
+ if (visited.has(url)) return; // Avoid infinite loops in circular dependencies
2001
+ visited.add(url);
2002
+
2003
+ try {
2004
+ const worker = new Worker('./runner.js', {
2005
+ workerData: { url },
2006
+ });
2007
+
2008
+ worker.on('message', (message) => {
2009
+ console.log(`Worker message: ${message}`);
2010
+ });
2011
+
2012
+ worker.on('error', (error) => {
2013
+ console.warn(`Worker error for ${url}:`, error);
2014
+ });
2015
+
2016
+ worker.on('exit', (code) => {
2017
+ if (code !== 0) {
2018
+ console.warn(`Worker stopped with exit code ${code}`);
2019
+ }
2020
+ });
2021
+
2022
+ await new Promise((resolve) => worker.on('exit', resolve));
2023
+ } catch (error) {
2024
+ console.warn(`Failed to clear cache for ${url}:`, error);
2025
+ }
2026
+ }
2027
+
2028
+ try {
2029
+ const watchedDirectory = 'path/to/watched/directory';
2030
+ const resolvedUrl = pathToFileURL(watchedDirectory).href;
2031
+ await clearCacheRecursively(resolvedUrl);
2032
+
2033
+ // Re-import and reload functions
2034
+ const updatedFunctions = await import(`${resolvedUrl}?v=${Date.now()}`);
2035
+ console.log('Functions reloaded:', Object.keys(updatedFunctions));
2036
+ } catch (error) {
2037
+ console.error('Error reloading functions:', error);
2038
+ }
2039
+ }
2040
+
2041
+ // Global map for updated AI inputs (used by dashboard UI)
2042
+ // The dashboard.html UI expects three buttons for editable AI input:
2043
+ // - Edit: Shows textarea for editing input (only for AI calls)
2044
+ // - Save: Saves the updated input from textarea
2045
+ // - Reset: Removes the updated input and restores original
2046
+ // These buttons are rendered in the HTML string and handled by window.enableInputEditing, window.saveUpdatedInput, window.resetInput.
2047
+ export const updatedInputs: Map<number, string> = new Map();