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,3940 @@
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 } from 'url';
6
+ import { randomUUID, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
7
+ import { callProviderLLM } from './matchers/index.js';
8
+ import chokidar from 'chokidar';
9
+ import express from 'express';
10
+ import { Worker } from 'worker_threads';
11
+ const app = express();
12
+ // ─── Snapshot Encryption (opt-in via ELASTICDASH_SNAPSHOT_ENCRYPTION_KEY) ────
13
+ const SNAPSHOT_ENCRYPTION_KEY = process.env.ELASTICDASH_SNAPSHOT_ENCRYPTION_KEY
14
+ ? Buffer.from(process.env.ELASTICDASH_SNAPSHOT_ENCRYPTION_KEY, 'hex')
15
+ : null;
16
+ function encryptSnapshot(data) {
17
+ if (!SNAPSHOT_ENCRYPTION_KEY || SNAPSHOT_ENCRYPTION_KEY.length !== 32)
18
+ return data;
19
+ const iv = randomBytes(16);
20
+ const cipher = createCipheriv('aes-256-gcm', SNAPSHOT_ENCRYPTION_KEY, iv);
21
+ const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
22
+ const authTag = cipher.getAuthTag();
23
+ return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
24
+ }
25
+ function decryptSnapshot(data) {
26
+ if (!SNAPSHOT_ENCRYPTION_KEY || SNAPSHOT_ENCRYPTION_KEY.length !== 32)
27
+ return data;
28
+ const parts = data.split(':');
29
+ if (parts.length !== 3)
30
+ return data; // not encrypted, return as-is
31
+ const [ivHex, authTagHex, encryptedHex] = parts;
32
+ try {
33
+ const iv = Buffer.from(ivHex, 'hex');
34
+ const authTag = Buffer.from(authTagHex, 'hex');
35
+ const encrypted = Buffer.from(encryptedHex, 'hex');
36
+ const decipher = createDecipheriv('aes-256-gcm', SNAPSHOT_ENCRYPTION_KEY, iv);
37
+ decipher.setAuthTag(authTag);
38
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
39
+ }
40
+ catch {
41
+ return data; // decryption failed, return raw (may be unencrypted legacy data)
42
+ }
43
+ }
44
+ function saveSnapshot(cwd, workflowTrace) {
45
+ const dir = path.join(cwd, '.temp', 'snapshots');
46
+ mkdirSync(dir, { recursive: true });
47
+ const id = workflowTrace.traceId;
48
+ const content = encryptSnapshot(JSON.stringify(workflowTrace));
49
+ writeFileSync(path.join(dir, `${id}.json`), content, 'utf8');
50
+ return id;
51
+ }
52
+ function loadSnapshot(cwd, snapshotId) {
53
+ try {
54
+ const file = path.join(cwd, '.temp', 'snapshots', `${snapshotId}.json`);
55
+ const raw = readFileSync(file, 'utf8');
56
+ return JSON.parse(decryptSnapshot(raw));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ function isDenoProject(dir) {
63
+ return existsSync(path.join(dir, 'deno.json')) || existsSync(path.join(dir, 'deno.jsonc'));
64
+ }
65
+ function resolveRuntimeModule(cwd, baseName) {
66
+ for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
67
+ const candidate = path.join(cwd, `${baseName}${ext}`);
68
+ if (existsSync(candidate))
69
+ return candidate;
70
+ }
71
+ return null;
72
+ }
73
+ function parseSignatureParams(signature) {
74
+ if (!signature)
75
+ return [];
76
+ const trimmed = signature.trim();
77
+ if (!trimmed.startsWith('(') || !trimmed.endsWith(')'))
78
+ return [];
79
+ const body = trimmed.slice(1, -1).trim();
80
+ if (!body)
81
+ return [];
82
+ return body
83
+ .split(',')
84
+ .map(part => part.trim())
85
+ .filter(Boolean)
86
+ .map(part => part.replace(/^\.\.\./, '').split('=')[0].split(':')[0].replace(/\?/g, '').trim())
87
+ .filter(part => /^[$A-Z_][0-9A-Z_$]*$/i.test(part));
88
+ }
89
+ function normalizeMessageContent(content) {
90
+ if (typeof content === 'string')
91
+ return content;
92
+ if (Array.isArray(content)) {
93
+ return content
94
+ .map((part) => {
95
+ if (typeof part === 'string')
96
+ return part;
97
+ if (part && typeof part === 'object' && typeof part.text === 'string')
98
+ return part.text;
99
+ try {
100
+ return JSON.stringify(part);
101
+ }
102
+ catch {
103
+ return String(part);
104
+ }
105
+ })
106
+ .join('\n');
107
+ }
108
+ if (content && typeof content === 'object') {
109
+ if (typeof content.text === 'string')
110
+ return content.text;
111
+ try {
112
+ return JSON.stringify(content);
113
+ }
114
+ catch {
115
+ return String(content);
116
+ }
117
+ }
118
+ return content == null ? '' : String(content);
119
+ }
120
+ function extractPromptFromGenerationInput(input) {
121
+ if (typeof input === 'string') {
122
+ return { prompt: input };
123
+ }
124
+ const messages = Array.isArray(input)
125
+ ? input
126
+ : input && typeof input === 'object' && Array.isArray(input.messages)
127
+ ? input.messages
128
+ : null;
129
+ if (messages && messages.length > 0) {
130
+ const systemParts = [];
131
+ const promptParts = [];
132
+ for (const message of messages) {
133
+ const role = typeof message?.role === 'string' ? message.role : 'user';
134
+ const content = normalizeMessageContent(message?.content).trim();
135
+ if (!content)
136
+ continue;
137
+ if (role === 'system') {
138
+ systemParts.push(content);
139
+ }
140
+ else {
141
+ promptParts.push(`${role}: ${content}`);
142
+ }
143
+ }
144
+ return {
145
+ prompt: promptParts.join('\n\n') || systemParts.join('\n\n') || JSON.stringify(input),
146
+ systemPrompt: systemParts.length > 0 ? systemParts.join('\n\n') : undefined,
147
+ };
148
+ }
149
+ if (input && typeof input === 'object' && typeof input.prompt === 'string') {
150
+ return {
151
+ prompt: input.prompt,
152
+ systemPrompt: typeof input.systemPrompt === 'string' ? input.systemPrompt : undefined,
153
+ };
154
+ }
155
+ try {
156
+ return { prompt: JSON.stringify(input) };
157
+ }
158
+ catch {
159
+ return { prompt: String(input ?? '') };
160
+ }
161
+ }
162
+ function inferProvider(observation) {
163
+ const provider = observation.provider?.toLowerCase();
164
+ if (provider === 'openai' || provider === 'claude' || provider === 'gemini' || provider === 'grok' || provider === 'kimi') {
165
+ return provider;
166
+ }
167
+ const model = observation.model?.toLowerCase() ?? '';
168
+ if (model.includes('claude'))
169
+ return 'claude';
170
+ if (model.includes('gemini'))
171
+ return 'gemini';
172
+ if (model.includes('grok'))
173
+ return 'grok';
174
+ if (model.includes('kimi'))
175
+ return 'kimi';
176
+ return 'openai';
177
+ }
178
+ function buildToolArgs(input, tool) {
179
+ if (input === undefined)
180
+ return [];
181
+ if (Array.isArray(input))
182
+ return input;
183
+ if (input && typeof input === 'object') {
184
+ const argObject = input;
185
+ const paramNames = parseSignatureParams(tool?.signature);
186
+ if (paramNames.length > 0 && paramNames.every(name => Object.prototype.hasOwnProperty.call(argObject, name))) {
187
+ return paramNames.map(name => argObject[name]);
188
+ }
189
+ return [input];
190
+ }
191
+ return [input];
192
+ }
193
+ /**
194
+ * Flatten structured prompt mock config to simple Record<string, string> for HTTP mode.
195
+ * Accepts both the new structured format { mode, replacement } and legacy string values.
196
+ * Only entries with mode !== 'live' (or plain string entries) are included.
197
+ */
198
+ function flattenPromptMockConfig(raw) {
199
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
200
+ return {};
201
+ const result = {};
202
+ for (const [key, val] of Object.entries(raw)) {
203
+ if (typeof val === 'string') {
204
+ result[key] = val;
205
+ }
206
+ else if (val && typeof val === 'object' && 'replacement' in val) {
207
+ const entry = val;
208
+ if (entry.mode !== 'live' && typeof entry.replacement === 'string') {
209
+ result[key] = entry.replacement;
210
+ }
211
+ }
212
+ }
213
+ return result;
214
+ }
215
+ function formatError(error) {
216
+ if (error instanceof Error)
217
+ return error.message;
218
+ try {
219
+ return JSON.stringify(error);
220
+ }
221
+ catch {
222
+ return String(error);
223
+ }
224
+ }
225
+ function runToolInSubprocess(toolsModulePath, toolName, args) {
226
+ return new Promise((resolve) => {
227
+ const startMs = Date.now();
228
+ const workerScript = new URL('./tool-runner-worker.js', import.meta.url).pathname;
229
+ const projectDir = path.dirname(toolsModulePath);
230
+ const denoProject = isDenoProject(projectDir);
231
+ // For Deno projects use `deno run --allow-all` so that https:// imports and
232
+ // TypeScript are handled natively. For Node projects keep the existing tsx path.
233
+ const nodeOptions = process.env.NODE_OPTIONS ?? '';
234
+ const tsxFlag = '--import tsx';
235
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim();
236
+ const childEnv = { ...process.env, NODE_OPTIONS: denoProject ? nodeOptions : childNodeOptions };
237
+ const runtime = denoProject ? 'deno' : process.execPath;
238
+ const runtimeArgs = denoProject ? ['run', '--allow-all', workerScript] : [workerScript];
239
+ const child = spawn(runtime, runtimeArgs, {
240
+ env: childEnv,
241
+ cwd: projectDir,
242
+ stdio: ['pipe', 'pipe', 'pipe'],
243
+ });
244
+ const RESULT_PREFIX = '__ELASTICDASH_RESULT__:';
245
+ let resultLine = '';
246
+ let stderr = '';
247
+ child.stdout.on('data', (chunk) => {
248
+ const text = chunk.toString();
249
+ for (const line of text.split('\n')) {
250
+ if (line.startsWith(RESULT_PREFIX)) {
251
+ resultLine = line.slice(RESULT_PREFIX.length);
252
+ }
253
+ else if (line) {
254
+ process.stdout.write(line + '\n');
255
+ }
256
+ }
257
+ });
258
+ child.stderr.on('data', (chunk) => {
259
+ stderr += chunk.toString();
260
+ process.stderr.write(chunk);
261
+ });
262
+ child.on('close', () => {
263
+ const currentDurationMs = Date.now() - startMs;
264
+ if (resultLine) {
265
+ try {
266
+ resolve({ ...JSON.parse(resultLine), currentDurationMs });
267
+ return;
268
+ }
269
+ catch { /* fall through */ }
270
+ }
271
+ resolve({ ok: false, error: stderr.trim() || 'Tool subprocess produced no output.', currentDurationMs });
272
+ });
273
+ child.on('error', (err) => {
274
+ const hint = denoProject && err.code === 'ENOENT'
275
+ ? ' (Deno project detected — ensure "deno" is installed and available in PATH)'
276
+ : '';
277
+ resolve({ ok: false, error: `Failed to spawn tool subprocess: ${err.message}${hint}`, currentDurationMs: Date.now() - startMs });
278
+ });
279
+ // Always use absolute file URL for toolsModulePath
280
+ const payload = JSON.stringify({
281
+ toolsModulePath: pathToFileURL(toolsModulePath).pathname,
282
+ toolName,
283
+ args
284
+ });
285
+ child.stdin.write(payload);
286
+ child.stdin.end(); // Always close stdin to avoid subprocess hang
287
+ });
288
+ }
289
+ function runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, args, input, options) {
290
+ return new Promise((resolve) => {
291
+ const workerScript = new URL('./workflow-runner-worker.js', import.meta.url).pathname;
292
+ const projectDir = path.dirname(workflowsModulePath);
293
+ const denoProject = isDenoProject(projectDir);
294
+ // For Deno projects use `deno run --allow-all` so that https:// imports and
295
+ // TypeScript are handled natively. For Node projects keep the existing tsx path.
296
+ const nodeOptions = process.env.NODE_OPTIONS ?? '';
297
+ const tsxFlag = '--import tsx';
298
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim();
299
+ const childEnv = { ...process.env, NODE_OPTIONS: denoProject ? nodeOptions : childNodeOptions };
300
+ const runtime = denoProject ? 'deno' : process.execPath;
301
+ const runtimeArgs = denoProject ? ['run', '--allow-all', workerScript] : [workerScript];
302
+ const child = spawn(runtime, runtimeArgs, {
303
+ env: childEnv,
304
+ cwd: projectDir,
305
+ stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
306
+ });
307
+ let fd3Data = '';
308
+ let stderr = '';
309
+ // Line-buffer stdout so that large result JSON lines split across multiple
310
+ // data events are reassembled before processing.
311
+ const WORKFLOW_RESULT_PREFIX = '__ELASTICDASH_RESULT__:';
312
+ let stdoutBuf = '';
313
+ child.stdout.on('data', (chunk) => {
314
+ stdoutBuf += chunk.toString();
315
+ const lines = stdoutBuf.split('\n');
316
+ stdoutBuf = lines.pop() ?? ''; // keep last (possibly incomplete) line
317
+ for (const line of lines) {
318
+ if (line.startsWith(WORKFLOW_RESULT_PREFIX)) {
319
+ // Stdout fallback channel (used by Deno when fd3 is unavailable)
320
+ fd3Data += line.slice(WORKFLOW_RESULT_PREFIX.length);
321
+ }
322
+ else if (line) {
323
+ process.stdout.write(line + '\n');
324
+ }
325
+ }
326
+ });
327
+ child.stderr.on('data', (chunk) => {
328
+ stderr += chunk.toString();
329
+ process.stderr.write(chunk);
330
+ });
331
+ const fd3 = child.stdio[3];
332
+ fd3?.on('data', (chunk) => {
333
+ fd3Data += chunk.toString();
334
+ });
335
+ child.on('close', () => {
336
+ // Flush any remaining buffered stdout line (e.g. result with no trailing newline)
337
+ if (stdoutBuf.startsWith(WORKFLOW_RESULT_PREFIX)) {
338
+ fd3Data += stdoutBuf.slice(WORKFLOW_RESULT_PREFIX.length);
339
+ }
340
+ else if (stdoutBuf) {
341
+ process.stdout.write(stdoutBuf + '\n');
342
+ }
343
+ if (fd3Data) {
344
+ try {
345
+ resolve(JSON.parse(fd3Data));
346
+ return;
347
+ }
348
+ catch { /* fall through */ }
349
+ }
350
+ resolve({ ok: false, error: stderr.trim() || 'Workflow subprocess produced no output.' });
351
+ });
352
+ child.on('error', (err) => {
353
+ const hint = denoProject && err.code === 'ENOENT'
354
+ ? ' (Deno project detected — ensure "deno" is installed and available in PATH)'
355
+ : '';
356
+ resolve({ ok: false, error: `Failed to spawn workflow subprocess: ${err.message}${hint}` });
357
+ });
358
+ // Always use absolute file URL for workflowsModulePath and toolsModulePath
359
+ const payload = JSON.stringify({
360
+ workflowsModulePath: pathToFileURL(workflowsModulePath).pathname,
361
+ toolsModulePath: toolsModulePath ? pathToFileURL(toolsModulePath).pathname : undefined,
362
+ workflowName,
363
+ args,
364
+ input,
365
+ ...(options?.replayMode !== undefined ? { replayMode: options.replayMode } : {}),
366
+ ...(options?.checkpoint !== undefined ? { checkpoint: options.checkpoint } : {}),
367
+ ...(options?.history !== undefined ? { history: options.history } : {}),
368
+ ...(options?.agentState !== undefined ? { agentState: options.agentState } : {}),
369
+ ...(options?.toolMockConfig !== undefined ? { toolMockConfig: options.toolMockConfig } : {}),
370
+ ...(options?.aiMockConfig !== undefined ? { aiMockConfig: options.aiMockConfig } : {}),
371
+ ...(options?.promptMockConfig !== undefined ? { promptMockConfig: options.promptMockConfig } : {}),
372
+ ...(options?.userPromptMockConfig !== undefined ? { userPromptMockConfig: options.userPromptMockConfig } : {}),
373
+ });
374
+ child.stdin.write(payload);
375
+ child.stdin.end(); // Always close stdin to avoid subprocess hang
376
+ });
377
+ }
378
+ async function runToolObservation(cwd, observation, tools) {
379
+ const toolName = observation.name;
380
+ if (!toolName) {
381
+ return { ok: false, error: 'Missing tool name on observation.' };
382
+ }
383
+ const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools');
384
+ if (!toolsModulePath) {
385
+ return { ok: false, error: 'Cannot find ed_tools.ts/js in workspace root.' };
386
+ }
387
+ // Parse input if it's a JSON string (common in trace exports)
388
+ let parsedInput = observation.input;
389
+ if (typeof parsedInput === 'string') {
390
+ try {
391
+ parsedInput = JSON.parse(parsedInput);
392
+ }
393
+ catch {
394
+ // Not JSON, use as-is
395
+ }
396
+ }
397
+ const toolInfo = tools.find(tool => tool.name === toolName);
398
+ const args = buildToolArgs(parsedInput, toolInfo);
399
+ console.log('[elasticdash] Rerunning tool observation:', { toolName, input: observation.input });
400
+ console.log(`[elasticdash] Loading tools from ${toolsModulePath} (fresh subprocess)...`);
401
+ return runToolInSubprocess(toolsModulePath, toolName, args);
402
+ }
403
+ async function runGenerationObservation(observation) {
404
+ try {
405
+ const { prompt, systemPrompt } = extractPromptFromGenerationInput(observation.input);
406
+ if (!prompt.trim()) {
407
+ return { ok: false, error: 'Generation input is empty; cannot rerun.' };
408
+ }
409
+ const provider = inferProvider(observation);
410
+ const model = observation.model;
411
+ const temperature = typeof observation.modelParameters?.temperature === 'number' ? observation.modelParameters.temperature : 0;
412
+ const maxTokens = typeof observation.modelParameters?.max_tokens === 'number' ? observation.modelParameters.max_tokens : 512;
413
+ const result = await callProviderLLM(prompt, { provider, model }, systemPrompt ?? 'You are a helpful assistant.', maxTokens);
414
+ return { ok: true, currentOutput: result.content, currentDurationMs: result.durationMs, currentUsage: result.usage };
415
+ }
416
+ catch (error) {
417
+ return { ok: false, error: `Generation rerun failed: ${formatError(error)}` };
418
+ }
419
+ }
420
+ async function rerunObservation(cwd, observation, tools) {
421
+ const type = observation.type?.toUpperCase();
422
+ const name = observation.name ?? '(unknown)';
423
+ const isToolByName = name.startsWith('tool-') || name.startsWith('tool:');
424
+ if (type === 'TOOL' || isToolByName) {
425
+ observation.name = isToolByName ? name.slice(5) : name; // Support both explicit type and name prefix for tool observations
426
+ return runToolObservation(cwd, observation, tools);
427
+ }
428
+ if (type === 'GENERATION') {
429
+ return runGenerationObservation(observation);
430
+ }
431
+ return { ok: false, error: `Unsupported observation type: ${observation.type ?? '(missing type)'}` };
432
+ }
433
+ function resolveWorkflowModule(cwd) {
434
+ return resolveRuntimeModule(cwd, 'ed_workflows');
435
+ }
436
+ function normalizeRunCount(value) {
437
+ const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
438
+ if (!Number.isFinite(parsed))
439
+ return 1;
440
+ const floored = Math.floor(parsed);
441
+ if (floored < 1)
442
+ return 1;
443
+ if (floored > 50)
444
+ return 50;
445
+ return floored;
446
+ }
447
+ function parseObservationInput(input) {
448
+ if (typeof input !== 'string')
449
+ return input;
450
+ const trimmed = input.trim();
451
+ if (!trimmed)
452
+ return input;
453
+ try {
454
+ return JSON.parse(trimmed);
455
+ }
456
+ catch {
457
+ return input;
458
+ }
459
+ }
460
+ function normalizeWorkflowArgs(input) {
461
+ const parsedInput = parseObservationInput(input);
462
+ if (parsedInput === undefined || parsedInput === null)
463
+ return [];
464
+ if (Array.isArray(parsedInput))
465
+ return parsedInput;
466
+ return [parsedInput];
467
+ }
468
+ function resolveWorkflowArgsFromObservations(body, workflowName) {
469
+ if (!Array.isArray(body.observations)) {
470
+ return { error: 'observations array is required for workflow validation input.' };
471
+ }
472
+ const matched = body.observations.find((item) => {
473
+ if (!item || typeof item !== 'object')
474
+ return false;
475
+ return typeof item.name === 'string' && (item.name ?? '').trim() === workflowName;
476
+ });
477
+ if (!matched) {
478
+ // No workflow-level observation found (e.g. trace was loaded from an external format that
479
+ // only contains child observations). Fall back to running the workflow with no arguments.
480
+ return { args: [], input: null };
481
+ }
482
+ return { args: normalizeWorkflowArgs(matched.input), input: matched.input };
483
+ }
484
+ function normalizeStartTime(value) {
485
+ if (typeof value === 'number' && Number.isFinite(value) && value > 1) {
486
+ return value;
487
+ }
488
+ return Date.now();
489
+ }
490
+ function toObservationFromStep(step) {
491
+ if (step.type === 'llm') {
492
+ return {
493
+ type: 'GENERATION',
494
+ name: typeof step.data.provider === 'string' ? step.data.provider : 'llm',
495
+ provider: typeof step.data.provider === 'string' ? step.data.provider : undefined,
496
+ model: typeof step.data.model === 'string' ? step.data.model : undefined,
497
+ input: step.data.prompt,
498
+ output: step.data.completion,
499
+ startTime: normalizeStartTime(step.timestamp),
500
+ workflowEventId: typeof step.data.workflowEventId === 'number' ? step.data.workflowEventId : undefined,
501
+ };
502
+ }
503
+ if (step.type === 'tool') {
504
+ return {
505
+ type: 'TOOL',
506
+ name: typeof step.data.name === 'string' ? step.data.name : 'tool',
507
+ input: step.data.args,
508
+ output: step.data.result,
509
+ startTime: normalizeStartTime(step.timestamp),
510
+ workflowEventId: typeof step.data.workflowEventId === 'number' ? step.data.workflowEventId : undefined,
511
+ };
512
+ }
513
+ return {
514
+ type: 'SPAN',
515
+ name: typeof step.data.name === 'string' ? step.data.name : typeof step.data.kind === 'string' ? step.data.kind : 'custom',
516
+ input: step.data.payload ?? step.data.metadata,
517
+ output: step.data.result,
518
+ startTime: normalizeStartTime(step.timestamp),
519
+ };
520
+ }
521
+ function toObservationFromWorkflowEvent(event) {
522
+ const agentFields = {};
523
+ if (event.agentTaskId !== undefined)
524
+ agentFields.agentTaskId = event.agentTaskId;
525
+ if (event.agentTaskIndex !== undefined)
526
+ agentFields.agentTaskIndex = event.agentTaskIndex;
527
+ if (event.type === 'ai') {
528
+ const inp = event.input;
529
+ const out = event.output;
530
+ const provider = inp?.provider ?? '';
531
+ // For streaming events, out is { streamed: true, completion } — extract text for fallback
532
+ let streamedCompletion;
533
+ if (out?.streamed === true && typeof out.completion === 'string') {
534
+ streamedCompletion = out.completion;
535
+ }
536
+ return {
537
+ type: 'GENERATION',
538
+ name: event.name || provider || 'llm',
539
+ provider: provider || undefined,
540
+ model: inp?.model ?? event.name,
541
+ input: inp?.messages ?? inp?.prompt,
542
+ output: streamedCompletion !== undefined ? streamedCompletion : out,
543
+ startTime: normalizeStartTime(event.timestamp),
544
+ durationMs: event.durationMs,
545
+ usage: event.usage,
546
+ workflowEventId: event.id,
547
+ ...agentFields,
548
+ };
549
+ }
550
+ if (event.type === 'tool') {
551
+ return {
552
+ type: 'TOOL',
553
+ name: event.name,
554
+ input: event.input,
555
+ output: event.output,
556
+ startTime: normalizeStartTime(event.timestamp),
557
+ durationMs: event.durationMs,
558
+ workflowEventId: event.id,
559
+ ...agentFields,
560
+ };
561
+ }
562
+ if (event.type === 'http') {
563
+ const inp = event.input;
564
+ return {
565
+ type: 'HTTP',
566
+ name: inp?.url ?? 'http',
567
+ input: event.input,
568
+ output: event.output,
569
+ startTime: normalizeStartTime(event.timestamp),
570
+ durationMs: event.durationMs,
571
+ workflowEventId: event.id,
572
+ ...agentFields,
573
+ };
574
+ }
575
+ if (event.type === 'db') {
576
+ return {
577
+ type: 'DB',
578
+ name: event.name,
579
+ input: event.input,
580
+ output: event.output,
581
+ startTime: normalizeStartTime(event.timestamp),
582
+ durationMs: event.durationMs,
583
+ workflowEventId: event.id,
584
+ ...agentFields,
585
+ };
586
+ }
587
+ return {
588
+ type: 'SPAN',
589
+ name: event.name,
590
+ input: event.input,
591
+ output: event.output,
592
+ startTime: normalizeStartTime(event.timestamp),
593
+ durationMs: event.durationMs,
594
+ workflowEventId: event.id,
595
+ ...agentFields,
596
+ };
597
+ }
598
+ function buildValidationObservations(workflowName, workflowInput, workflowOutput, workflowError, trace, workflowTrace, frozenEventIds) {
599
+ const steps = trace.getSteps();
600
+ const workflowStartTime = steps.length > 0 ? steps[0].timestamp : Date.now();
601
+ const observations = [
602
+ {
603
+ type: 'SPAN',
604
+ name: workflowName,
605
+ input: workflowInput,
606
+ output: workflowError ? `Workflow run failed: ${workflowError}` : workflowOutput,
607
+ startTime: workflowStartTime,
608
+ },
609
+ ];
610
+ // If workflowTrace has ai/tool events, use those as the source of truth to avoid duplicates
611
+ const hasAiEvents = workflowTrace?.events.some(e => e.type === 'ai') ?? false;
612
+ const hasToolEvents = workflowTrace?.events.some(e => e.type === 'tool') ?? false;
613
+ let firstGenerationIndex = -1;
614
+ for (const step of steps) {
615
+ if (hasAiEvents && step.type === 'llm')
616
+ continue;
617
+ if (hasToolEvents && step.type === 'tool')
618
+ continue;
619
+ const obs = toObservationFromStep({ type: step.type, data: step.data, timestamp: step.timestamp });
620
+ // Mark frozen if this step's workflowEventId is in the frozen set
621
+ if (obs.workflowEventId !== undefined && frozenEventIds?.has(obs.workflowEventId)) {
622
+ obs.isFrozen = true;
623
+ }
624
+ observations.push(obs);
625
+ // Track the index of the first GENERATION observation
626
+ if (firstGenerationIndex === -1 && obs.type === 'GENERATION') {
627
+ firstGenerationIndex = observations.length - 1;
628
+ }
629
+ }
630
+ // Append captured events from the workflow trace (ai, tool, http, db)
631
+ if (workflowTrace) {
632
+ for (const event of workflowTrace.events) {
633
+ if (event.type === 'ai' || event.type === 'tool' || event.type === 'http' || event.type === 'db') {
634
+ const obs = toObservationFromWorkflowEvent(event);
635
+ if (frozenEventIds?.has(event.id)) {
636
+ obs.isFrozen = true;
637
+ }
638
+ observations.push(obs);
639
+ if (firstGenerationIndex === -1 && obs.type === 'GENERATION') {
640
+ firstGenerationIndex = observations.length - 1;
641
+ }
642
+ }
643
+ }
644
+ }
645
+ // Compute total duration and aggregate token usage for the container observation
646
+ if (workflowTrace && workflowTrace.events.length > 0) {
647
+ const endTime = workflowTrace.events.reduce((max, e) => Math.max(max, e.timestamp + e.durationMs), workflowStartTime);
648
+ observations[0].durationMs = endTime - workflowStartTime;
649
+ let inputTokens = 0, outputTokens = 0, totalTokens = 0;
650
+ for (const e of workflowTrace.events) {
651
+ if (e.type === 'ai' && e.usage) {
652
+ inputTokens += e.usage.inputTokens ?? 0;
653
+ outputTokens += e.usage.outputTokens ?? 0;
654
+ totalTokens += e.usage.totalTokens ?? 0;
655
+ }
656
+ }
657
+ if (totalTokens > 0) {
658
+ observations[0].usage = { inputTokens, outputTokens, totalTokens };
659
+ }
660
+ }
661
+ // Sort all observations except the workflow entry (index 0) by startTime
662
+ const [workflowEntry, ...rest] = observations;
663
+ rest.sort((a, b) => (a.startTime ?? 0) - (b.startTime ?? 0));
664
+ return [workflowEntry, ...rest];
665
+ }
666
+ async function validateWorkflowRuns(cwd, body) {
667
+ const workflowName = typeof body.workflowName === 'string' ? body.workflowName.trim() : '';
668
+ if (!workflowName) {
669
+ return {
670
+ ok: false,
671
+ mode: 'parallel',
672
+ runCount: 0,
673
+ traces: [],
674
+ error: 'workflowName is required.',
675
+ };
676
+ }
677
+ const runCount = normalizeRunCount(body.runCount);
678
+ const sequential = body.sequential === true;
679
+ const mode = sequential ? 'sequential' : 'parallel';
680
+ const resolvedInput = resolveWorkflowArgsFromObservations(body, workflowName);
681
+ if (resolvedInput.error) {
682
+ return {
683
+ ok: false,
684
+ mode,
685
+ runCount,
686
+ traces: [],
687
+ error: resolvedInput.error,
688
+ };
689
+ }
690
+ const workflowArgs = resolvedInput.args ?? [];
691
+ const workflowInput = resolvedInput.input ?? null;
692
+ // Parse tool mock config if provided
693
+ const toolMockConfig = body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
694
+ ? body.toolMockConfig
695
+ : undefined;
696
+ // Parse AI mock config if provided
697
+ const aiMockConfig = body.aiMockConfig && typeof body.aiMockConfig === 'object' && !Array.isArray(body.aiMockConfig)
698
+ ? body.aiMockConfig
699
+ : undefined;
700
+ // Parse prompt mock config if provided
701
+ const promptMockConfig = body.promptMockConfig && typeof body.promptMockConfig === 'object' && !Array.isArray(body.promptMockConfig)
702
+ ? body.promptMockConfig
703
+ : undefined;
704
+ // Parse user prompt mock config if provided
705
+ const userPromptMockConfig = body.userPromptMockConfig && typeof body.userPromptMockConfig === 'object' && !Array.isArray(body.userPromptMockConfig)
706
+ ? body.userPromptMockConfig
707
+ : undefined;
708
+ const workflowsModulePath = resolveWorkflowModule(cwd);
709
+ if (!workflowsModulePath) {
710
+ return {
711
+ ok: false,
712
+ mode,
713
+ runCount,
714
+ traces: [],
715
+ error: 'Cannot find ed_workflows.ts/js in workspace root.',
716
+ };
717
+ }
718
+ const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools') ?? null;
719
+ const runs = Array.from({ length: runCount }, (_, i) => i + 1);
720
+ console.log(`[elasticdash] Running workflow "${workflowName}" ${runCount} time(s) in ${mode} mode via subprocess`);
721
+ async function runOne(runNumber) {
722
+ console.log(`[elasticdash] === Run ${runNumber}: Starting workflow "${workflowName}" ===`);
723
+ const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, workflowArgs, workflowInput, (toolMockConfig || aiMockConfig || promptMockConfig || userPromptMockConfig) ? { ...(toolMockConfig ? { toolMockConfig } : {}), ...(aiMockConfig ? { aiMockConfig } : {}), ...(promptMockConfig ? { promptMockConfig } : {}), ...(userPromptMockConfig ? { userPromptMockConfig } : {}) } : undefined)
724
+ .catch(err => {
725
+ throw { ok: false, error: `Workflow subprocess failed: ${formatError(err)}` };
726
+ });
727
+ // Reconstruct a minimal TraceHandle from serialised trace arrays
728
+ const traceStub = {
729
+ getSteps: () => (result.steps ?? []),
730
+ getLLMSteps: () => (result.llmSteps ?? []),
731
+ getToolCalls: () => (result.toolCalls ?? []),
732
+ getCustomSteps: () => (result.customSteps ?? []),
733
+ recordLLMStep: () => { },
734
+ recordToolCall: () => { },
735
+ recordCustomStep: () => { },
736
+ };
737
+ if (!result.ok) {
738
+ console.error(`[elasticdash] Run ${runNumber}: Workflow failed:`, result.error);
739
+ return {
740
+ runNumber,
741
+ ok: false,
742
+ error: result.error,
743
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, result.error, traceStub, result.workflowTrace),
744
+ workflowTrace: result.workflowTrace,
745
+ currentOutput: result.currentOutput,
746
+ snapshotId: result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined,
747
+ };
748
+ }
749
+ console.log(`[elasticdash] Run ${runNumber}: Workflow completed successfully`);
750
+ return {
751
+ runNumber,
752
+ ok: true,
753
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, undefined, traceStub, result.workflowTrace),
754
+ workflowTrace: result.workflowTrace,
755
+ currentOutput: result.currentOutput,
756
+ snapshotId: result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined,
757
+ };
758
+ }
759
+ try {
760
+ let traces;
761
+ if (sequential) {
762
+ traces = [];
763
+ for (const runNumber of runs) {
764
+ traces.push(await runOne(runNumber));
765
+ }
766
+ }
767
+ else {
768
+ traces = await Promise.all(runs.map(runOne));
769
+ }
770
+ console.log(`[elasticdash] Completed ${traces.length} workflow run(s). Success: ${traces.filter(t => t.ok).length}, Failed: ${traces.filter(t => !t.ok).length}`);
771
+ return { ok: true, mode, runCount, traces };
772
+ }
773
+ catch (error) {
774
+ console.error('[elasticdash] Workflow validation failed with exception:', error);
775
+ return {
776
+ ok: false,
777
+ mode,
778
+ runCount,
779
+ traces: [],
780
+ error: `Workflow validation failed: ${formatError(error)}`,
781
+ };
782
+ }
783
+ }
784
+ function readJsonBody(req) {
785
+ return new Promise((resolve, reject) => {
786
+ let raw = '';
787
+ req.setEncoding('utf8');
788
+ req.on('data', (chunk) => {
789
+ raw += chunk;
790
+ if (raw.length > 2_000_000) {
791
+ reject(new Error('Request body too large.'));
792
+ }
793
+ });
794
+ req.on('end', () => {
795
+ if (!raw.trim()) {
796
+ resolve({});
797
+ return;
798
+ }
799
+ try {
800
+ resolve(JSON.parse(raw));
801
+ }
802
+ catch {
803
+ reject(new Error('Invalid JSON body.'));
804
+ }
805
+ });
806
+ req.on('error', reject);
807
+ });
808
+ }
809
+ /**
810
+ * Resolve a relative module specifier to an existing file path.
811
+ * Tries .ts, .tsx, .js, .jsx extensions (TypeScript sources preferred).
812
+ */
813
+ function resolveModulePath(fromDir, specifier) {
814
+ if (!specifier.startsWith('.'))
815
+ return null;
816
+ const exts = ['.ts', '.tsx', '.js', '.jsx', ''];
817
+ for (const ext of exts) {
818
+ const candidate = path.resolve(fromDir, specifier + ext);
819
+ if (existsSync(candidate))
820
+ return candidate;
821
+ }
822
+ return null;
823
+ }
824
+ /** 1-based line number of a character index within source text */
825
+ function lineAt(src, index) {
826
+ return src.slice(0, index).split('\n').length;
827
+ }
828
+ /**
829
+ * Given source text, try to find the signature of a named export or declaration.
830
+ * Returns { isAsync, signature, lineNumber?, sourceCode? }.
831
+ */
832
+ function findFunctionInSource(src, name) {
833
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
834
+ // export [async] function name(params)
835
+ let m = src.match(new RegExp(`export\\s+(async\\s+)?function\\s+${escaped}\\s*(\\([^)]*\\))`));
836
+ if (m)
837
+ return { isAsync: !!m[1], signature: m[2], lineNumber: lineAt(src, m.index), sourceCode: extractSource(src, m.index) };
838
+ // [async] function name(params) — non-exported, for re-export cases
839
+ m = src.match(new RegExp(`(?:^|\\n)\\s*(?:async\\s+)?function\\s+${escaped}\\s*(\\([^)]*\\))`, 'm'));
840
+ if (m)
841
+ return {
842
+ isAsync: new RegExp(`async\\s+function\\s+${escaped}`).test(src),
843
+ signature: m[1],
844
+ lineNumber: lineAt(src, m.index),
845
+ sourceCode: extractSource(src, m.index),
846
+ };
847
+ // export const name = [async] (params) =>
848
+ m = src.match(new RegExp(`export\\s+const\\s+${escaped}\\s*=\\s*(async\\s*)?(\\([^)]*\\))\\s*=>`));
849
+ if (m)
850
+ return { isAsync: !!m[1], signature: m[2], lineNumber: lineAt(src, m.index) };
851
+ // const name = [async] (params) =>
852
+ m = src.match(new RegExp(`(?:^|\\n)\\s*const\\s+${escaped}\\s*=\\s*(async\\s*)?(\\([^)]*\\))\\s*=>`, 'm'));
853
+ if (m)
854
+ return { isAsync: !!m[1], signature: m[2], lineNumber: lineAt(src, m.index) };
855
+ return { isAsync: false, signature: '()' };
856
+ }
857
+ /** Extract ~2000 chars of source starting at a matched index */
858
+ function extractSource(src, index) {
859
+ const snippet = src.slice(index, index + 2000);
860
+ return snippet.length < 2000 ? snippet : snippet + '\n// (truncated)';
861
+ }
862
+ /**
863
+ * Parse exported names from an ed_*.ts / ed_*.js source file without executing it.
864
+ * Handles: direct function/const exports, named re-exports, and import+destructure exports.
865
+ */
866
+ function extractExportsFromSource(filePath) {
867
+ let src;
868
+ try {
869
+ src = readFileSync(filePath, 'utf8');
870
+ }
871
+ catch {
872
+ return [];
873
+ }
874
+ const dir = path.dirname(filePath);
875
+ const results = [];
876
+ // 1. Direct: export [async] function name(params) { … }
877
+ for (const m of src.matchAll(/export\s+(async\s+)?function\s+(\w+)\s*(\([^)]*\))/g)) {
878
+ results.push({
879
+ name: m[2],
880
+ isAsync: !!m[1],
881
+ signature: m[3],
882
+ filePath,
883
+ lineNumber: lineAt(src, m.index),
884
+ sourceCode: extractSource(src, m.index),
885
+ });
886
+ }
887
+ // 2. Direct: export const name = [async] (params) => …
888
+ for (const m of src.matchAll(/export\s+const\s+(\w+)\s*=\s*(async\s*)?\(([^)]*)\)\s*=>/g)) {
889
+ results.push({
890
+ name: m[1],
891
+ isAsync: !!m[2],
892
+ signature: `(${m[3]})`,
893
+ filePath,
894
+ lineNumber: lineAt(src, m.index),
895
+ sourceCode: extractSource(src, m.index),
896
+ });
897
+ }
898
+ // 3. Named re-exports: export { X [as Y], … } from './module'
899
+ for (const m of src.matchAll(/export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) {
900
+ const modulePath = resolveModulePath(dir, m[2]);
901
+ let moduleSrc = '';
902
+ try {
903
+ if (modulePath)
904
+ moduleSrc = readFileSync(modulePath, 'utf8');
905
+ }
906
+ catch { /* ignore */ }
907
+ for (const spec of m[1].split(',')) {
908
+ const parts = spec.trim().split(/\s+as\s+/);
909
+ const originalName = parts[0].trim();
910
+ const exportedName = (parts[1] ?? parts[0]).trim();
911
+ if (!exportedName || exportedName === 'default')
912
+ continue;
913
+ const info = moduleSrc ? findFunctionInSource(moduleSrc, originalName) : { isAsync: false, signature: '()' };
914
+ results.push({
915
+ name: exportedName,
916
+ isAsync: info.isAsync,
917
+ signature: info.signature,
918
+ filePath: modulePath ?? filePath,
919
+ lineNumber: info.lineNumber,
920
+ sourceCode: info.sourceCode,
921
+ });
922
+ }
923
+ }
924
+ // 4. Import + destructure: import { obj } from './m' + export const { a, b } = obj
925
+ for (const imp of src.matchAll(/import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) {
926
+ const importedNames = imp[1].split(',').map(s => {
927
+ const parts = s.trim().split(/\s+as\s+/);
928
+ return { original: parts[0].trim(), local: (parts[1] ?? parts[0]).trim() };
929
+ }).filter(n => n.local);
930
+ const modulePath = resolveModulePath(dir, imp[2]);
931
+ for (const { local } of importedNames) {
932
+ // Look for: export const { a, b, c } = local
933
+ const destructureRe = new RegExp(`export\\s+const\\s+\\{([^}]+)\\}\\s*=\\s*${local.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
934
+ const dm = src.match(destructureRe);
935
+ if (!dm)
936
+ continue;
937
+ let moduleSrc = '';
938
+ try {
939
+ if (modulePath)
940
+ moduleSrc = readFileSync(modulePath, 'utf8');
941
+ }
942
+ catch { /* ignore */ }
943
+ for (const member of dm[1].split(',')) {
944
+ const name = member.trim();
945
+ if (!name)
946
+ continue;
947
+ const info = moduleSrc ? findFunctionInSource(moduleSrc, name) : { isAsync: false, signature: '()' };
948
+ results.push({
949
+ name,
950
+ isAsync: info.isAsync,
951
+ signature: info.signature,
952
+ filePath: modulePath ?? filePath,
953
+ lineNumber: info.lineNumber,
954
+ sourceCode: info.sourceCode,
955
+ });
956
+ }
957
+ }
958
+ }
959
+ return results;
960
+ }
961
+ /**
962
+ * Scan for ed_tools.ts or ed_tools.js and extract exported functions
963
+ */
964
+ function scanTools(cwd) {
965
+ for (const candidate of [path.join(cwd, 'ed_tools.ts'), path.join(cwd, 'ed_tools.js')]) {
966
+ if (!existsSync(candidate))
967
+ continue;
968
+ const exports = extractExportsFromSource(candidate);
969
+ if (exports.length > 0) {
970
+ return exports.map(e => ({
971
+ name: e.name,
972
+ isAsync: e.isAsync,
973
+ signature: e.signature,
974
+ filePath: e.filePath,
975
+ lineNumber: e.lineNumber,
976
+ sourceCode: e.sourceCode,
977
+ }));
978
+ }
979
+ }
980
+ return [];
981
+ }
982
+ /**
983
+ * Scan for ed_workflows.ts or ed_workflows.js and extract exported functions
984
+ */
985
+ function scanWorkflows(cwd) {
986
+ for (const candidate of [path.join(cwd, 'ed_workflows.ts'), path.join(cwd, 'ed_workflows.js')]) {
987
+ if (!existsSync(candidate))
988
+ continue;
989
+ const exports = extractExportsFromSource(candidate);
990
+ if (exports.length > 0) {
991
+ return exports.map(e => ({
992
+ name: e.name,
993
+ isAsync: e.isAsync,
994
+ signature: e.signature,
995
+ filePath: e.filePath,
996
+ lineNumber: e.lineNumber,
997
+ sourceFile: e.filePath,
998
+ sourceCode: e.sourceCode,
999
+ }));
1000
+ }
1001
+ }
1002
+ return [];
1003
+ }
1004
+ /**
1005
+ * Open URL in default browser (platform-aware)
1006
+ */
1007
+ function openBrowser(url) {
1008
+ const platform = process.platform;
1009
+ if (platform === 'darwin') {
1010
+ spawn('open', [url], { detached: true, stdio: 'ignore' });
1011
+ }
1012
+ else if (platform === 'linux') {
1013
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
1014
+ }
1015
+ else if (platform === 'win32') {
1016
+ spawn('cmd', ['/c', 'start', url], { detached: true, stdio: 'ignore', shell: true });
1017
+ }
1018
+ }
1019
+ /**
1020
+ * Get the dashboard HTML page
1021
+ *
1022
+ * HTML content is inlined at build time by scripts/inline-html.js
1023
+ * Edit src/html/dashboard.html to modify the dashboard UI
1024
+ */
1025
+ function getDashboardHtml() {
1026
+ /* DASHBOARD_HTML_START */
1027
+ return `<!DOCTYPE html>
1028
+ <html lang="en">
1029
+ <head>
1030
+ <meta charset="UTF-8">
1031
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1032
+ <title>ElasticDash Dashboard</title>
1033
+ <style>
1034
+ /* Help icon tooltip */
1035
+ .help-icon-wrap { position: relative; display: inline-flex; align-items: center; }
1036
+ .help-icon {
1037
+ display: inline-flex; align-items: center; justify-content: center;
1038
+ width: 16px; height: 16px; border-radius: 50%;
1039
+ background: #e0e0e0; color: #666; font-size: 11px; font-weight: bold;
1040
+ cursor: help; vertical-align: middle; margin-left: 4px;
1041
+ user-select: none;
1042
+ }
1043
+ .help-tooltip {
1044
+ display: none; position: absolute; left: 50%; bottom: calc(100% + 8px);
1045
+ transform: translateX(-50%);
1046
+ background: #333; color: #fff; font-size: 12px; font-weight: 400;
1047
+ line-height: 1.4; padding: 8px 12px; border-radius: 6px;
1048
+ width: 280px; white-space: normal; z-index: 10000;
1049
+ box-shadow: 0 2px 8px rgba(0,0,0,0.18);
1050
+ pointer-events: auto;
1051
+ }
1052
+ .help-tooltip::after {
1053
+ content: ''; position: absolute; top: 100%; left: 50%;
1054
+ transform: translateX(-50%);
1055
+ border: 6px solid transparent; border-top-color: #333;
1056
+ }
1057
+ .help-icon-wrap:hover .help-tooltip { display: block; }
1058
+ /* Ensure first cell in observation-table never overflows parent */
1059
+ .observation-table td:first-child {
1060
+ max-width: 120px;
1061
+ overflow: auto;
1062
+ white-space: nowrap;
1063
+ }
1064
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1065
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
1066
+ .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
1067
+ header { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
1068
+ h1 { font-size: 28px; margin-bottom: 8px; color: #1a1a1a; }
1069
+ .subtitle { font-size: 14px; color: #666; margin-bottom: 16px; }
1070
+ .search-box { display: flex; gap: 10px; }
1071
+ input[type="text"] { flex: 1; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
1072
+ input[type="text"]:focus { outline: none; border-color: #0066cc; box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); }
1073
+ .result-count { padding: 10px 12px; background: #f0f0f0; border-radius: 6px; font-size: 14px; color: #666; }
1074
+ .workflows-list { background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; max-height: 65vh; display: flex; flex-direction: column; }
1075
+ .workflows-table { width: 100%; border-collapse: collapse; }
1076
+ .workflows-table thead { background: #f5f5f5; position: sticky; top: 0; z-index: 10; }
1077
+ .workflows-table th { padding: 12px 16px; text-align: left; font-weight: 600; font-size: 13px; color: #333; border-bottom: 2px solid #ddd; }
1078
+ .workflows-table td { padding: 12px 16px; border-bottom: 1px solid #eee; }
1079
+ .workflows-table tbody tr { cursor: pointer; transition: background-color 0.2s; }
1080
+ .workflows-table tbody tr:hover { background-color: #f9f9f9; }
1081
+ .workflow-name-cell { font-family: Monaco, monospace; font-weight: 600; color: #0066cc; }
1082
+ .workflow-path-cell { font-family: Monaco, monospace; font-size: 12px; color: #666; }
1083
+ .async-badge { display: inline-block; background: #e8f3ff; color: #0066cc; padding: 2px 8px; border-radius: 4px; font-size: 11px; margin-left: 8px; }
1084
+ .modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center; }
1085
+ .modal.open { display: flex; }
1086
+ .modal-content { background: white; border-radius: 12px; width: 92%; max-width: 1100px; max-height: 90vh; overflow-y: auto; padding: 30px; }
1087
+ .modal-header { display: flex; justify-content: space-between; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #eee; }
1088
+ .modal-title { font-size: 20px; font-weight: 600; }
1089
+ .modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #999; }
1090
+ .upload-area { border: 2px dashed #ddd; border-radius: 8px; padding: 30px; text-align: center; cursor: pointer; background: #fafafa; overflow: hidden; height: 520px; position: relative; }
1091
+ .upload-area:hover { border-color: #0066cc; background: #f0f7ff; }
1092
+ .upload-area > div {
1093
+ position: absolute;
1094
+ top: 50%;
1095
+ left: 50%;
1096
+ transform: translate(-50%, -50%);
1097
+ }
1098
+ .upload-icon { font-size: 32px; margin-bottom: 12px; }
1099
+ input[type="file"] { display: none; }
1100
+ .upload-status { margin-top: 20px; padding: 12px; border-radius: 6px; display: none; }
1101
+ .upload-status.success { display: block; background: #e8f5e9; color: #2e7d32; }
1102
+ .upload-status.error { display: block; background: #ffebee; color: #c62828; }
1103
+ .hidden { display: none !important; }
1104
+ .trace-viewer { display: none; margin-top: 20px; }
1105
+ .trace-viewer.visible { display: block; }
1106
+ .trace-layout { display: grid; grid-template-columns: 40% calc(60% - 16px); gap: 16px; overflow: auto; height: 520px; }
1107
+ .trace-layout.step-5 { display: grid; grid-template-columns: calc(30% - 16px) calc(30% - 16px) 40%; gap: 16px; overflow: auto; height: 520px; }
1108
+ .trace-layout.step-4 { display: grid; grid-template-columns: calc(30% - 16px) calc(30% - 16px) 40%; gap: 16px; overflow: auto; height: 520px; }
1109
+ .trace-left, .trace-right { background: #f9f9f9; border-radius: 8px; padding: 14px; border: 1px solid #eee; }
1110
+ .trace-section-title { font-size: 14px; font-weight: 600; margin-bottom: 10px; }
1111
+ .observation-table-wrap { max-height: 460px; overflow: auto; background: white; border-radius: 6px; border: 1px solid #eee; }
1112
+ .observation-table { width: 100%; border-collapse: collapse; }
1113
+ .observation-table thead { background: #f5f5f5; position: sticky; top: 0; z-index: 1; }
1114
+ .observation-table th { text-align: left; font-size: 12px; font-weight: 600; color: #555; padding: 10px 12px; border-bottom: 1px solid #e8e8e8; }
1115
+ .observation-table td { font-size: 13px; padding: 10px 12px; border-bottom: 1px solid #f0f0f0; }
1116
+ .observation-table tbody tr { cursor: pointer; }
1117
+ .observation-table tbody tr:hover { background: #f7fbff; }
1118
+ .observation-table tbody tr.selected { background: #e8f3ff; }
1119
+ .obs-type { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; background: #e6e6e6; color: #333; }
1120
+ .obs-type.tool { background: #e8f7ef; color: #1f7a44; }
1121
+ .obs-type.ai { background: #e8f1ff; color: #1f5fbf; }
1122
+ .run-from-bp-btn { font-size: 11px; padding: 2px 8px; border: 1px solid #bbb; border-radius: 4px; background: #f5f5f5; color: #333; cursor: pointer; white-space: nowrap; }
1123
+ .run-from-bp-btn:hover { background: #e0edff; border-color: #5a8fd8; color: #1f5fbf; }
1124
+ .run-from-bp-btn:disabled { opacity: 0.6; cursor: default; }
1125
+ .resume-agent-btn { font-size: 11px; padding: 2px 8px; border: 1px solid #b8a0d8; border-radius: 4px; background: #f3eeff; color: #5a2d9c; cursor: pointer; white-space: nowrap; margin-left: 6px; }
1126
+ .resume-agent-btn:hover { background: #e6d8ff; border-color: #7c52b8; color: #3d1f7a; }
1127
+ .resume-agent-btn:disabled { opacity: 0.6; cursor: default; }
1128
+ .agent-task-badge { display: inline-block; padding: 1px 6px; border-radius: 8px; font-size: 10px; background: #f0e8ff; color: #6a2fb0; border: 1px solid #d4b8f0; margin-left: 6px; font-weight: 600; }
1129
+ .agent-task-row { background: #f7f0ff; border-left: 3px solid #8560c6; }
1130
+ .frozen-row { background: #eef7ff; border-left: 3px solid #8cbcf5; }
1131
+ .observation-table tbody tr.selected.frozen-row { background: #dcebff; }
1132
+ .frozen-tag { display: inline-block; padding: 1px 6px; border-radius: 8px; font-size: 10px; background: #dcecff; color: #1f5fbf; border: 1px solid #a9c8f3; margin-left: 6px; font-weight: 600; }
1133
+ .detail-sections { display: flex; flex-direction: column; gap: 12px; height: 486.5px; overflow-y: auto; }
1134
+ .detail-section { background: white; border: 1px solid #eee; border-radius: 6px; padding: 10px; }
1135
+ .detail-title { font-size: 12px; font-weight: 600; margin-bottom: 8px; color: #555; text-transform: uppercase; letter-spacing: 0.02em; }
1136
+ .detail-pre { margin: 0; font-family: Monaco, monospace; font-size: 12px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; background: #fafafa; border-radius: 4px; padding: 10px; border: 1px solid #f0f0f0; min-height: 56px; max-height: 340px; overflow-y: auto; }
1137
+ .modal-footer { display: flex; margin-top: 24px; padding-top: 20px; border-top: 1px solid #eee; gap: 12px; justify-content: space-between; }
1138
+ .btn { padding: 10px 20px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; border: none; }
1139
+ .btn-secondary { background: #f0f0f0; color: #333; }
1140
+ .btn-secondary:hover { background: #e0e0e0; }
1141
+ .btn-primary { background: #0066cc; color: white; }
1142
+ .btn-primary:hover { background: #0052a3; }
1143
+ .btn:disabled { opacity: 0.6; cursor: not-allowed; }
1144
+ .btn-primary:disabled:hover { background: #0066cc; }
1145
+ .btn-secondary:disabled:hover { background: #f0f0f0; }
1146
+ .obs-checkbox { width: 18px; height: 18px; cursor: pointer; }
1147
+ .rerun-status { display: inline-block; margin-left: 8px; font-size: 11px; font-weight: 600; }
1148
+ .rerun-status.running { color: #666; }
1149
+ .rerun-status.success { color: #1f7a44; }
1150
+ .rerun-status.error { color: #c62828; }
1151
+ @media (max-width: 900px) {
1152
+ .trace-layout { grid-template-columns: 1fr; }
1153
+ }
1154
+ </style>
1155
+ <script>
1156
+ const updatedInputs = new Map();
1157
+ </script>
1158
+ </head>
1159
+ <body>
1160
+ <div class="container">
1161
+ <header>
1162
+ <h1>Workflow Functions</h1>
1163
+ <div class="subtitle">Select a workflow to debug with trace analysis</div>
1164
+ <div class="search-box">
1165
+ <input type="text" id="searchInput" placeholder="Search by name or path..." autocomplete="off">
1166
+ <div class="result-count"><span id="resultCount">0</span> workflows</div>
1167
+ </div>
1168
+ </header>
1169
+ <div class="workflows-list">
1170
+ <table class="workflows-table">
1171
+ <thead><tr><th style="width: 35%">Function Name</th><th>File Path</th></tr></thead>
1172
+ <tbody id="workflowsTableBody"><tr><td colspan="2" style="text-align: center; padding: 40px;">Loading...</td></tr></tbody>
1173
+ </table>
1174
+ </div>
1175
+ </div>
1176
+ <div id="traceModal" class="modal">
1177
+ <div class="modal-content">
1178
+ <div class="modal-header">
1179
+ <h2 class="modal-title">Import Trace for Analysis</h2>
1180
+ <button class="modal-close" id="closeModal">&times;</button>
1181
+ </div>
1182
+ <div id="uploadArea" class="upload-area">
1183
+ <div>Drag and Drop <br />or <br />Click to Upload</div>
1184
+ <input type="file" id="traceFile" accept=".json" />
1185
+ </div>
1186
+ <div id="uploadStatus" class="upload-status"></div>
1187
+ <div id="traceViewer" class="trace-viewer">
1188
+ <div class="trace-layout">
1189
+ <div class="trace-left">
1190
+ <div class="trace-section-title">Observations</div>
1191
+ <div class="observation-table-wrap">
1192
+ <table class="observation-table">
1193
+ <thead id="observationTableHead"><tr><th style="width: 40px;">Check</th><th>Name</th><th>Type</th><th style="width:80px;">Duration</th></tr></thead>
1194
+ <tbody id="observationTableBody"></tbody>
1195
+ </table>
1196
+ </div>
1197
+ </div>
1198
+ <div class="trace-right">
1199
+ <div id="observationDetail"></div>
1200
+ </div>
1201
+ </div>
1202
+ </div>
1203
+ <div id="modalFooter" class="modal-footer">
1204
+ <button class="btn btn-secondary" id="changeTraceBtn">Change Trace File</button>
1205
+ <button class="btn btn-primary" id="nextBtn">Next</button>
1206
+ </div>
1207
+ </div>
1208
+ </div>
1209
+ <script>
1210
+ console.log("[Dashboard] Script starting...");
1211
+ let allWorkflows = [], codeIndex = {workflows: [], tools: []}, selectedWorkflow = null;
1212
+ let currentObservations = [], selectedObservationIndex = -1;
1213
+ let rerunHistory = new Map();
1214
+ let rerunInFlight = new Set();
1215
+ let step4SelectedRun = -1;
1216
+ let step5RunTraces = [];
1217
+ let repoRoot = ''; // Will be fetched from API
1218
+ try {
1219
+ const _saved = localStorage.getItem('ed_step5RunTraces');
1220
+ if (_saved) step5RunTraces = JSON.parse(_saved);
1221
+ } catch {}
1222
+ let step5RunMeta = { loading: false, error: '', runCount: 0, sequential: false };
1223
+ let step5RerunInFlight = false;
1224
+
1225
+ function computeDurationMs(obs) {
1226
+ if (obs.durationMs != null) return obs.durationMs;
1227
+ if (obs.latency != null && obs.latency > 0) return Math.round(obs.latency * 1000);
1228
+ if (obs.startTime && obs.endTime) {
1229
+ const diff = new Date(obs.endTime).getTime() - new Date(obs.startTime).getTime();
1230
+ if (Number.isFinite(diff) && diff >= 0) return diff;
1231
+ }
1232
+ if (obs.latency != null) return 0;
1233
+ return null;
1234
+ }
1235
+
1236
+ function formatDuration(ms) {
1237
+ if (ms == null) return '—';
1238
+ if (ms < 1000) return ms + ' ms';
1239
+ return (ms / 1000).toFixed(2) + ' s';
1240
+ }
1241
+
1242
+ function extractUsage(obs) {
1243
+ if (obs && (obs.usage && (obs.usage.inputTokens != null || obs.usage.outputTokens != null))) {
1244
+ return obs.usage;
1245
+ }
1246
+ if (obs && (obs.usageDetails && (obs.usageDetails.input != null || obs.usageDetails.output != null))) {
1247
+ return { inputTokens: obs.usageDetails.input, outputTokens: obs.usageDetails.output, totalTokens: obs.usageDetails.total };
1248
+ }
1249
+ if (obs && (obs.inputUsage != null || obs.outputUsage != null)) {
1250
+ return { inputTokens: obs.inputUsage, outputTokens: obs.outputUsage, totalTokens: obs.totalUsage };
1251
+ }
1252
+ return null;
1253
+ }
1254
+
1255
+ function renderUsage(obs) {
1256
+ const u = extractUsage(obs);
1257
+ if (!u || !(u.inputTokens > 0 || u.outputTokens > 0 || u.totalTokens > 0)) return '';
1258
+ const lines = [];
1259
+ if (u.inputTokens != null) lines.push('Input tokens: ' + u.inputTokens);
1260
+ if (u.outputTokens != null) lines.push('Output tokens: ' + u.outputTokens);
1261
+ if (u.totalTokens != null) lines.push('Total tokens: ' + u.totalTokens);
1262
+ if (!lines.length) return '';
1263
+ return \`<div class="detail-section"><div class="detail-title">Usage</div><pre class="detail-pre">\${lines.join('\\n')}</pre></div>\`;
1264
+ }
1265
+
1266
+ function persistTraces() {
1267
+ try {
1268
+ // Store compact version — strip bulky workflowTrace.events (snapshot is on server)
1269
+ const compact = step5RunTraces.map(function(t) {
1270
+ const { workflowTrace, ...rest } = t;
1271
+ return rest;
1272
+ });
1273
+ localStorage.setItem('ed_step5RunTraces', JSON.stringify(compact));
1274
+ } catch {}
1275
+ }
1276
+ const tbody = document.getElementById("workflowsTableBody");
1277
+ const countEl = document.getElementById("resultCount");
1278
+ const modal = document.getElementById("traceModal");
1279
+ const uploadArea = document.getElementById("uploadArea");
1280
+ const fileInput = document.getElementById("traceFile");
1281
+ const modalFooter = document.getElementById("modalFooter");
1282
+ const uploadStatus = document.getElementById("uploadStatus");
1283
+ const traceViewer = document.getElementById("traceViewer");
1284
+ let observationTableBody = document.getElementById("observationTableBody");
1285
+ let observationDetail = document.getElementById("observationDetail");
1286
+ const modalTitle = document.querySelector(".modal-title");
1287
+ console.log("[Dashboard] DOM elements loaded, tbody:", tbody);
1288
+
1289
+ let currentStep = 0; // 0=upload, 3=mark, 4=verify, 5=validate
1290
+ let checkedObservations = new Set();
1291
+
1292
+ document.getElementById("closeModal").onclick = () => {
1293
+ modal.classList.remove("open");
1294
+ resetTraceModal();
1295
+ };
1296
+ modal.onclick = (e) => {
1297
+ if (e.target === modal) {
1298
+ modal.classList.remove("open");
1299
+ resetTraceModal();
1300
+ }
1301
+ };
1302
+
1303
+ document.getElementById("changeTraceBtn").onclick = () => {
1304
+ if (currentStep === 3) {
1305
+ resetTraceModal();
1306
+ } else if (currentStep === 4) {
1307
+ // Go back to Step 3
1308
+ currentStep = 3;
1309
+ checkedObservations.clear(); // Clear to allow reselecting different steps
1310
+ updateModalTitle();
1311
+ updateFooterButtons();
1312
+ renderObservationTable();
1313
+ // Auto-select first observation
1314
+ if (currentObservations.length > 0) {
1315
+ selectObservation(0);
1316
+ }
1317
+ } else if (currentStep === 5) {
1318
+ // Go back to Step 3 (Still Failing)
1319
+ currentStep = 3;
1320
+ checkedObservations.clear(); // Clear to allow reselecting different steps
1321
+ updateModalTitle();
1322
+ updateFooterButtons();
1323
+ renderObservationTable();
1324
+ // Auto-select first observation
1325
+ if (currentObservations.length > 0) {
1326
+ selectObservation(0);
1327
+ }
1328
+ }
1329
+ };
1330
+
1331
+ document.getElementById("nextBtn").onclick = () => {
1332
+ if (currentStep < 3) {
1333
+ alert("Please upload a trace file to continue");
1334
+ return;
1335
+ }
1336
+ if (currentStep === 3) {
1337
+ // Validate that at least one checkbox is checked
1338
+ if (checkedObservations.size === 0) {
1339
+ alert("Please select at least one step to mark as broken");
1340
+ return;
1341
+ }
1342
+ // Move to Step 4
1343
+ currentStep = 4;
1344
+ updateModalTitle();
1345
+ updateFooterButtons();
1346
+ renderObservationTable();
1347
+ // Auto-select first checked observation
1348
+ const checkedArray = Array.from(checkedObservations);
1349
+ if (checkedArray.length > 0) {
1350
+ window.step4SelectObservation(checkedArray[0]);
1351
+ }
1352
+ } else if (currentStep === 4) {
1353
+ // Show prompt-update confirmation (if needed), then live validation dialog
1354
+ window.openPromptConfirmation(() => window.openLiveValidationDialog());
1355
+ return;
1356
+ } else if (currentStep === 5) {
1357
+ modal.classList.remove("open");
1358
+ resetTraceModal();
1359
+ customFooter.remove();
1360
+ }
1361
+ };
1362
+
1363
+ // ---- Tool Mock Config State ----
1364
+ window._toolMockConfig = {}; // { toolName: { mode: 'live'|'mock-all'|'mock-specific', callIndices: [], mockData: {} } }
1365
+
1366
+ // ---- Prompt Mock Config State ----
1367
+ // { [originalSystemPrompt]: { mode, replacement, callIndices? } }
1368
+ window._promptMockConfig = {};
1369
+
1370
+ function getToolsFromTrace() {
1371
+ // Extract unique tool names and their call details from the uploaded trace observations
1372
+ const toolCalls = {};
1373
+ currentObservations.forEach(function(obs, i) {
1374
+ const isToolByType = obs.type === 'TOOL';
1375
+ const isToolByName = typeof obs.name === 'string' && (obs.name.startsWith('tool-') || obs.name.startsWith('tool:'));
1376
+ if (!isToolByType && !isToolByName) return;
1377
+ const name = isToolByName && obs.type !== 'TOOL'
1378
+ ? obs.name.slice(5)
1379
+ : (obs.name || '(unknown)');
1380
+ if (!toolCalls[name]) toolCalls[name] = [];
1381
+ toolCalls[name].push({ index: toolCalls[name].length + 1, obsIndex: i, input: obs.input, output: obs.output });
1382
+ });
1383
+ return toolCalls;
1384
+ }
1385
+
1386
+ function getAllRegisteredTools() {
1387
+ // From codeIndex.tools (fetched at page load from /api/code-index)
1388
+ return (codeIndex.tools || []).map(function(t) { return t.name; });
1389
+ }
1390
+
1391
+ function buildToolMockConfigFromUI() {
1392
+ const config = {};
1393
+ const rows = document.querySelectorAll('.tool-mock-row');
1394
+ rows.forEach(function(row) {
1395
+ const toolName = row.dataset.toolName;
1396
+ const modeSelect = row.querySelector('.tool-mock-mode');
1397
+ const mode = modeSelect ? modeSelect.value : 'live';
1398
+ if (mode === 'live') return;
1399
+ const entry = { mode: mode };
1400
+ if (mode === 'mock-specific') {
1401
+ const checkboxes = row.querySelectorAll('.tool-call-checkbox:checked');
1402
+ entry.callIndices = Array.from(checkboxes).map(function(cb) { return parseInt(cb.value, 10); });
1403
+ if (entry.callIndices.length === 0) return; // No calls selected, treat as live
1404
+ }
1405
+ // Collect mock data
1406
+ entry.mockData = {};
1407
+ const dataInputs = row.querySelectorAll('.tool-mock-data-input');
1408
+ dataInputs.forEach(function(inp) {
1409
+ const callIdx = parseInt(inp.dataset.callIdx, 10);
1410
+ if (!inp.value.trim()) return;
1411
+ try { entry.mockData[callIdx] = JSON.parse(inp.value); }
1412
+ catch(e) { entry.mockData[callIdx] = inp.value; }
1413
+ });
1414
+ config[toolName] = entry;
1415
+ });
1416
+ return config;
1417
+ }
1418
+
1419
+ function cleanValue(value) {
1420
+ if (typeof value === "string") {
1421
+ value = value.replaceAll('\\\\"', '');
1422
+ // remove surrounding quotes if they exist
1423
+ if (value.startsWith('"') && value.endsWith('"')) {
1424
+ return value.slice(1, -1);
1425
+ }
1426
+ return value;
1427
+ }
1428
+
1429
+ if (Array.isArray(value)) {
1430
+ return value.map(cleanValue);
1431
+ }
1432
+
1433
+ if (typeof value === "object" && value !== null) {
1434
+ const result = {};
1435
+ for (const key in value) {
1436
+ result[key] = cleanValue(value[key]);
1437
+ }
1438
+ return result;
1439
+ }
1440
+
1441
+ return value;
1442
+ }
1443
+
1444
+ function convert(input) {
1445
+ const parsed = JSON.parse(input);
1446
+ return cleanValue(parsed);
1447
+ }
1448
+
1449
+ function renderToolMockSection(showAll) {
1450
+ const traceTools = getToolsFromTrace();
1451
+ const allToolNames = getAllRegisteredTools();
1452
+ const traceToolNames = Object.keys(traceTools);
1453
+ const toolNames = showAll
1454
+ ? Array.from(new Set([...traceToolNames, ...allToolNames]))
1455
+ : traceToolNames;
1456
+
1457
+ if (toolNames.length === 0) {
1458
+ return '<div style="color:#999;font-size:13px;padding:6px 0;">No tools detected.</div>';
1459
+ }
1460
+
1461
+ let html = '<div style="border:1px solid #e0e0e0;border-radius:6px;height:100%;display:flex;flex-direction:column;overflow:hidden;">';
1462
+ html += '<table style="width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;">';
1463
+ html += '<thead><tr style="background:#f5f5f5;">';
1464
+ html += '<th style="width:30%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Tool</th>';
1465
+ html += '<th style="width:15%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Calls</th>';
1466
+ html += '<th style="width:20%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Mode</th>';
1467
+ html += '<th style="width:35%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Details</th>';
1468
+ html += '</tr></thead></table>';
1469
+ html += '<div style="flex:1;overflow-y:auto;"><table style="width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;"><tbody>';
1470
+
1471
+ toolNames.forEach(function(name) {
1472
+ const calls = traceTools[name] || [];
1473
+ const inTrace = traceToolNames.includes(name);
1474
+ const existing = window._toolMockConfig[name] || { mode: 'live' };
1475
+ const nameStyle = inTrace ? '' : 'color:#999;';
1476
+
1477
+ html += '<tr class="tool-mock-row" data-tool-name="' + esc(name) + '" style="border-bottom:1px solid #f0f0f0;">';
1478
+ html += '<td style="width:30%;padding:6px 10px;font-family:Monaco,monospace;overflow:hidden;text-overflow:ellipsis;' + nameStyle + '">' + esc(name) + (inTrace ? '' : ' <span style="font-size:10px;color:#aaa;">(not in trace)</span>') + '</td>';
1479
+ html += '<td style="width:15%;padding:6px 10px;">' + calls.length + '</td>';
1480
+ html += '<td style="width:20%;padding:6px 10px;">';
1481
+ html += '<select class="tool-mock-mode" style="font-size:12px;padding:2px 4px;width: 100%;" onchange="window.onToolMockModeChange(\\'' + esc(name) + '\\', this.value)">';
1482
+ html += '<option value="live"' + (existing.mode === 'live' ? ' selected' : '') + '>Live</option>';
1483
+ html += '<option value="mock-all"' + (existing.mode === 'mock-all' ? ' selected' : '') + '>Mock All Calls</option>';
1484
+ if (calls.length > 0) {
1485
+ html += '<option value="mock-specific"' + (existing.mode === 'mock-specific' ? ' selected' : '') + '>Mock Specific Calls</option>';
1486
+ }
1487
+ html += '</select>';
1488
+ html += '</td>';
1489
+
1490
+ // Details column: per-call checkboxes + mock data inputs
1491
+ html += '<td style="width:35%;padding:6px 10px;">';
1492
+ if (existing.mode === 'mock-all') {
1493
+ let defaultData = (existing.mockData && existing.mockData[0] !== undefined) ? JSON.stringify(existing.mockData[0]) : (calls.length > 0 ? JSON.stringify(calls[0].output) : '');
1494
+ defaultData = convert(defaultData);
1495
+ html += '<div style="font-size:11px;color:#555;margin-bottom:4px;">Mock data (JSON):</div>';
1496
+ html += '<textarea class="tool-mock-data-input" data-call-idx="0" style="width:100%;font-size:11px;font-family:Monaco,monospace;padding:4px;border:1px solid #ddd;border-radius:4px;min-height:32px;resize:vertical;" placeholder="Return value for all calls">' + esc(defaultData) + '</textarea>';
1497
+ } else if (existing.mode === 'mock-specific' && calls.length > 0) {
1498
+ html += '<div style="font-size:11px;color:#555;margin-bottom:4px;">Select calls to mock:</div>';
1499
+ calls.forEach(function(call) {
1500
+ const isChecked = existing.callIndices && existing.callIndices.includes(call.index);
1501
+ const inputPreview = typeof call.input === 'string' ? call.input.slice(0, 40) : JSON.stringify(call.input || '').slice(0, 40);
1502
+ let mockVal = (existing.mockData && existing.mockData[call.index] !== undefined) ? JSON.stringify(existing.mockData[call.index]) : JSON.stringify(call.output);
1503
+ mockVal = convert(mockVal);
1504
+ html += '<div style="margin-bottom:6px;padding:4px;background:#fafafa;border-radius:4px;border:1px solid #eee;">';
1505
+ html += '<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;">';
1506
+ html += '<input type="checkbox" class="tool-call-checkbox" value="' + call.index + '"' + (isChecked ? ' checked' : '') + ' onchange="window.onToolCallCheckChange(\\'' + esc(name) + '\\',' + call.index + ',this.checked)">';
1507
+ html += '<span>Call #' + call.index + '</span>';
1508
+ html += '<span style="color:#888;font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + esc(inputPreview) + '</span>';
1509
+ html += '</label>';
1510
+ if (isChecked) {
1511
+ html += '<textarea class="tool-mock-data-input" data-call-idx="' + call.index + '" style="width:100%;font-size:11px;font-family:Monaco,monospace;padding:4px;border:1px solid #ddd;border-radius:4px;min-height:28px;resize:vertical;margin-top:4px;" placeholder="Mock return value (JSON)">' + esc(mockVal) + '</textarea>';
1512
+ }
1513
+ html += '</div>';
1514
+ });
1515
+ } else {
1516
+ html += '<span style="color:#aaa;font-size:11px;">—</span>';
1517
+ }
1518
+ html += '</td>';
1519
+ html += '</tr>';
1520
+ });
1521
+
1522
+ html += '</tbody></table></div></div>';
1523
+ return html;
1524
+ }
1525
+
1526
+ // ---- Prompt Mock Helpers ----
1527
+
1528
+ /** Extract the system prompt string from an LLM call input object or JSON string. */
1529
+ function extractSystemPromptFromInput(input) {
1530
+ // Input may arrive as a JSON-encoded string (e.g. from Langfuse traces).
1531
+ // Unwrap until we get a non-string value.
1532
+ while (typeof input === 'string') {
1533
+ try { input = JSON.parse(input); } catch(e) { break; }
1534
+ }
1535
+ if (!input || typeof input !== 'object') return null;
1536
+ // Anthropic style: { system: "...", messages: [...] }
1537
+ if (typeof input.system === 'string') return input.system;
1538
+ // Custom wrapAI callers: { systemPrompt: "...", messages: [...] }
1539
+ if (typeof input.systemPrompt === 'string' && input.systemPrompt.length > 0) return input.systemPrompt;
1540
+ // OpenAI / plain array: messages with role === "system"
1541
+ var msgs = Array.isArray(input.messages) ? input.messages : (Array.isArray(input) ? input : null);
1542
+ if (msgs) {
1543
+ for (var i = 0; i < msgs.length; i++) {
1544
+ var m = msgs[i];
1545
+ if (m && typeof m === 'object' && m.role === 'system' && typeof m.content === 'string') {
1546
+ return m.content;
1547
+ }
1548
+ }
1549
+ }
1550
+ return null;
1551
+ }
1552
+
1553
+ /**
1554
+ * Returns an array of unique system prompts observed across all GENERATION observations.
1555
+ * Each entry: { systemPrompt, modelName, calls: [{obsIndex, callNumber}], count, rowIndex }
1556
+ * callNumber is the 1-indexed occurrence of this prompt across GENERATION observations.
1557
+ */
1558
+ function getSystemPromptsFromTrace() {
1559
+ var seen = []; // [{ systemPrompt, modelName, calls, count }]
1560
+ var seenMap = {}; // systemPrompt -> index in seen
1561
+ var callCounters = {}; // systemPrompt -> running count
1562
+ currentObservations.forEach(function(obs, obsIndex) {
1563
+ if (obs.type !== 'GENERATION') return;
1564
+ var sp = extractSystemPromptFromInput(obs.input);
1565
+ if (!sp) return;
1566
+ callCounters[sp] = (callCounters[sp] || 0) + 1;
1567
+ var callNumber = callCounters[sp];
1568
+ if (seenMap[sp] === undefined) {
1569
+ seenMap[sp] = seen.length;
1570
+ seen.push({ systemPrompt: sp, modelName: obs.model || obs.name || '(unknown)', calls: [], count: 0 });
1571
+ }
1572
+ seen[seenMap[sp]].calls.push({ obsIndex: obsIndex, callNumber: callNumber });
1573
+ seen[seenMap[sp]].count++;
1574
+ });
1575
+ return seen.map(function(e, i) { return Object.assign({}, e, { rowIndex: i }); });
1576
+ }
1577
+
1578
+ function renderPromptMockSection() {
1579
+ const prompts = getSystemPromptsFromTrace();
1580
+ if (prompts.length === 0) {
1581
+ return '<div style="color:#999;font-size:13px;padding:6px 0;">No system prompts detected in trace. Only AI calls with a system prompt can be mocked here.</div>';
1582
+ }
1583
+
1584
+ let html = '<div style="border:1px solid #e0e0e0;border-radius:6px;height:100%;display:flex;flex-direction:column;overflow:hidden;">';
1585
+ html += '<table style="width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;">';
1586
+ html += '<thead><tr style="background:#f5f5f5;">';
1587
+ html += '<th style="width:25%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">System Prompt</th>';
1588
+ html += '<th style="width:10%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Calls</th>';
1589
+ html += '<th style="width:20%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Mode</th>';
1590
+ html += '<th style="width:45%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Replacement</th>';
1591
+ html += '</tr></thead></table>';
1592
+ html += '<div style="flex:1;overflow-y:auto;"><table style="width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;"><tbody>';
1593
+
1594
+ prompts.forEach(function(row) {
1595
+ const key = row.systemPrompt;
1596
+ const existing = window._promptMockConfig[key] || { mode: 'live', replacement: '' };
1597
+ const preview = key.length > 60 ? key.slice(0, 60) + '…' : key;
1598
+
1599
+ html += '<tr class="prompt-mock-row" data-row-index="' + row.rowIndex + '" style="border-bottom:1px solid #f0f0f0;vertical-align:top;">';
1600
+
1601
+ // System prompt preview column
1602
+ html += '<td style="width:25%;padding:6px 10px;overflow:hidden;text-overflow:ellipsis;"><div style="font-size:11px;color:#555;font-family:Monaco,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + esc(key) + '">' + esc(preview) + '</div></td>';
1603
+
1604
+ // Calls count column
1605
+ html += '<td style="width:10%;padding:6px 10px;color:#555;white-space:nowrap;">' + row.count + 'x</td>';
1606
+
1607
+ // Mode column
1608
+ html += '<td style="width:20%;padding:6px 10px;white-space:nowrap;">';
1609
+ html += '<select class="prompt-mock-mode" style="font-size:12px;padding:2px 4px;width: 100%;" onchange="window.onSystemPromptMockModeChange(' + row.rowIndex + ', this.value)">';
1610
+ html += '<option value="live"' + (existing.mode === 'live' ? ' selected' : '') + '>Live</option>';
1611
+ html += '<option value="replace-all"' + (existing.mode === 'replace-all' ? ' selected' : '') + '>Replace All</option>';
1612
+ if (row.count > 1) {
1613
+ html += '<option value="replace-specific"' + (existing.mode === 'replace-specific' ? ' selected' : '') + '>Replace Specific</option>';
1614
+ }
1615
+ html += '</select>';
1616
+ html += '</td>';
1617
+
1618
+ // Replacement column
1619
+ html += '<td style="width:45%;padding:6px 10px;">';
1620
+ if (existing.mode === 'replace-all') {
1621
+ html += '<textarea class="prompt-mock-input" data-row-index="' + row.rowIndex + '" style="width:100%;box-sizing:border-box;font-size:11px;font-family:Monaco,monospace;padding:4px;border:1px solid #ddd;border-radius:4px;min-height:48px;resize:vertical;" oninput="window.onPromptMockInput(' + row.rowIndex + ', this.value)">' + esc(existing.replacement || key) + '</textarea>';
1622
+ } else if (existing.mode === 'replace-specific') {
1623
+ html += '<div style="font-size:11px;color:#555;margin-bottom:4px;">Select calls to replace:</div>';
1624
+ row.calls.forEach(function(call) {
1625
+ const isChecked = existing.callIndices && existing.callIndices.indexOf(call.callNumber) !== -1;
1626
+ html += '<div style="margin-bottom:4px;padding:4px;background:#fafafa;border-radius:4px;border:1px solid #eee;">';
1627
+ html += '<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;">';
1628
+ html += '<input type="checkbox" class="prompt-call-checkbox" value="' + call.callNumber + '"' + (isChecked ? ' checked' : '') + ' onchange="window.onSystemPromptCallCheckChange(' + row.rowIndex + ',' + call.callNumber + ',this.checked)">';
1629
+ html += '<span>Call #' + call.callNumber + '</span>';
1630
+ html += '</label>';
1631
+ html += '</div>';
1632
+ });
1633
+ html += '<textarea class="prompt-mock-input" data-row-index="' + row.rowIndex + '" style="width:100%;box-sizing:border-box;font-size:11px;font-family:Monaco,monospace;padding:4px;border:1px solid #ddd;border-radius:4px;min-height:48px;resize:vertical;margin-top:4px;" oninput="window.onPromptMockInput(' + row.rowIndex + ', this.value)">' + esc(existing.replacement || key) + '</textarea>';
1634
+ } else {
1635
+ html += '<span style="color:#aaa;font-size:11px;">—</span>';
1636
+ }
1637
+ html += '</td>';
1638
+
1639
+ html += '</tr>';
1640
+ });
1641
+
1642
+ html += '</tbody></table></div></div>';
1643
+ return html;
1644
+ }
1645
+
1646
+ function buildPromptMockConfigFromUI() {
1647
+ const config = {};
1648
+ Object.keys(window._promptMockConfig).forEach(function(key) {
1649
+ const entry = window._promptMockConfig[key];
1650
+ if (!entry || entry.mode === 'live') return;
1651
+ config[key] = { mode: entry.mode, replacement: entry.replacement || '' };
1652
+ if (entry.mode === 'replace-specific' && entry.callIndices) {
1653
+ config[key].callIndices = entry.callIndices;
1654
+ }
1655
+ });
1656
+ return config;
1657
+ }
1658
+
1659
+ window.onSystemPromptMockModeChange = function(rowIndex, mode) {
1660
+ const prompts = getSystemPromptsFromTrace();
1661
+ const row = prompts[rowIndex];
1662
+ if (!row) return;
1663
+ const key = row.systemPrompt;
1664
+ if (mode === 'live') {
1665
+ delete window._promptMockConfig[key];
1666
+ } else {
1667
+ if (!window._promptMockConfig[key]) {
1668
+ window._promptMockConfig[key] = { mode: mode, replacement: row.systemPrompt };
1669
+ } else {
1670
+ window._promptMockConfig[key].mode = mode;
1671
+ }
1672
+ if (mode === 'replace-specific' && !window._promptMockConfig[key].callIndices) {
1673
+ window._promptMockConfig[key].callIndices = [];
1674
+ }
1675
+ }
1676
+ const container = document.getElementById('promptMockContainer');
1677
+ if (container) container.innerHTML = renderPromptMockSection();
1678
+ };
1679
+
1680
+ window.onSystemPromptCallCheckChange = function(rowIndex, callNumber, checked) {
1681
+ const prompts = getSystemPromptsFromTrace();
1682
+ const row = prompts[rowIndex];
1683
+ if (!row) return;
1684
+ const key = row.systemPrompt;
1685
+ if (!window._promptMockConfig[key]) return;
1686
+ var indices = window._promptMockConfig[key].callIndices || [];
1687
+ if (checked) {
1688
+ if (indices.indexOf(callNumber) === -1) indices.push(callNumber);
1689
+ } else {
1690
+ var pos = indices.indexOf(callNumber);
1691
+ if (pos >= 0) indices.splice(pos, 1);
1692
+ }
1693
+ window._promptMockConfig[key].callIndices = indices;
1694
+ };
1695
+
1696
+ window.switchMockTab = function(tabName) {
1697
+ const tabs = {
1698
+ tool: document.getElementById('mockTabTool'),
1699
+ prompt: document.getElementById('mockTabPrompt'),
1700
+ userPrompt: document.getElementById('mockTabUserPrompt'),
1701
+ };
1702
+ const contents = {
1703
+ tool: document.getElementById('mockTabToolContent'),
1704
+ prompt: document.getElementById('mockTabPromptContent'),
1705
+ userPrompt: document.getElementById('mockTabUserPromptContent'),
1706
+ };
1707
+ const toolExtra = document.getElementById('mockTabToolExtra');
1708
+ Object.keys(tabs).forEach(function(key) {
1709
+ const isActive = key === tabName;
1710
+ if (tabs[key]) { tabs[key].style.borderBottomColor = isActive ? '#4f46e5' : 'transparent'; tabs[key].style.color = isActive ? '#4f46e5' : '#888'; }
1711
+ if (contents[key]) contents[key].style.display = isActive ? '' : 'none';
1712
+ });
1713
+ if (toolExtra) toolExtra.style.display = tabName === 'tool' ? 'flex' : 'none';
1714
+ };
1715
+
1716
+ window.onPromptMockInput = function(rowIndex, value) {
1717
+ const prompts = getSystemPromptsFromTrace();
1718
+ const row = prompts[rowIndex];
1719
+ if (!row) return;
1720
+ if (window._promptMockConfig[row.systemPrompt]) {
1721
+ window._promptMockConfig[row.systemPrompt].replacement = value;
1722
+ }
1723
+ };
1724
+
1725
+ window.onToolMockModeChange = function(toolName, mode) {
1726
+ if (!window._toolMockConfig[toolName]) window._toolMockConfig[toolName] = { mode: 'live' };
1727
+ // Save current mock data before switching
1728
+ window._toolMockConfig[toolName] = { ...window._toolMockConfig[toolName], mode: mode };
1729
+ if (mode === 'mock-specific' && !window._toolMockConfig[toolName].callIndices) {
1730
+ window._toolMockConfig[toolName].callIndices = [];
1731
+ }
1732
+ // Re-render tool mock section
1733
+ const showAll = document.getElementById('showAllToolsToggle');
1734
+ const container = document.getElementById('toolMockContainer');
1735
+ if (container) container.innerHTML = renderToolMockSection(showAll && showAll.checked);
1736
+ };
1737
+
1738
+ window.onToolCallCheckChange = function(toolName, callIdx, checked) {
1739
+ if (!window._toolMockConfig[toolName]) window._toolMockConfig[toolName] = { mode: 'mock-specific', callIndices: [] };
1740
+ const indices = window._toolMockConfig[toolName].callIndices || [];
1741
+ if (checked && !indices.includes(callIdx)) {
1742
+ indices.push(callIdx);
1743
+ } else if (!checked) {
1744
+ const pos = indices.indexOf(callIdx);
1745
+ if (pos >= 0) indices.splice(pos, 1);
1746
+ }
1747
+ window._toolMockConfig[toolName].callIndices = indices;
1748
+ const showAll = document.getElementById('showAllToolsToggle');
1749
+ const container = document.getElementById('toolMockContainer');
1750
+ if (container) container.innerHTML = renderToolMockSection(showAll && showAll.checked);
1751
+ };
1752
+
1753
+ // ---- User Prompt Mock Helpers ----
1754
+
1755
+ /** Extract all user-role message content strings from an LLM call input. */
1756
+ function extractUserPromptsFromInput(input) {
1757
+ // Unwrap JSON-encoded strings until we get a non-string value.
1758
+ while (typeof input === 'string') {
1759
+ try { input = JSON.parse(input); } catch(e) { break; }
1760
+ }
1761
+ if (!input || typeof input !== 'object') return [];
1762
+ var msgs = Array.isArray(input.messages) ? input.messages : (Array.isArray(input) ? input : null);
1763
+ if (!msgs) return [];
1764
+ var results = [];
1765
+ for (var i = 0; i < msgs.length; i++) {
1766
+ var m = msgs[i];
1767
+ if (m && typeof m === 'object' && m.role === 'user' && typeof m.content === 'string') {
1768
+ results.push(m.content);
1769
+ }
1770
+ }
1771
+ return results;
1772
+ }
1773
+
1774
+ /**
1775
+ * Returns an array of unique user prompt texts observed across all GENERATION observations.
1776
+ * Each entry: { userPrompt, modelName, calls: [{obsIndex, callNumber}], count, rowIndex }
1777
+ * callNumber is the 1-indexed occurrence of this text across GENERATION observations.
1778
+ */
1779
+ function getUserPromptsFromTrace() {
1780
+ var seen = []; // [{ userPrompt, modelName, calls, count }]
1781
+ var seenMap = {}; // userPrompt -> index in seen
1782
+ var callCounters = {}; // userPrompt -> running count
1783
+ currentObservations.forEach(function(obs, obsIndex) {
1784
+ if (obs.type !== 'GENERATION') return;
1785
+ var prompts = extractUserPromptsFromInput(obs.input);
1786
+ var uniqueInCall = [];
1787
+ prompts.forEach(function(p) { if (uniqueInCall.indexOf(p) === -1) uniqueInCall.push(p); });
1788
+ uniqueInCall.forEach(function(p) {
1789
+ callCounters[p] = (callCounters[p] || 0) + 1;
1790
+ var callNumber = callCounters[p];
1791
+ if (seenMap[p] === undefined) {
1792
+ seenMap[p] = seen.length;
1793
+ seen.push({ userPrompt: p, modelName: obs.model || obs.name || '(unknown)', calls: [], count: 0 });
1794
+ }
1795
+ seen[seenMap[p]].calls.push({ obsIndex: obsIndex, callNumber: callNumber });
1796
+ seen[seenMap[p]].count++;
1797
+ });
1798
+ });
1799
+ return seen.map(function(e, i) { return Object.assign({}, e, { rowIndex: i }); });
1800
+ }
1801
+
1802
+ function renderUserPromptMockSection() {
1803
+ const prompts = getUserPromptsFromTrace();
1804
+ if (prompts.length === 0) {
1805
+ return '<div style="color:#999;font-size:13px;padding:6px 0;">No user prompts detected in trace. Only AI calls with user-role messages can be mocked here.</div>';
1806
+ }
1807
+
1808
+ let html = '<div style="border:1px solid #e0e0e0;border-radius:6px;height:100%;display:flex;flex-direction:column;overflow:hidden;">';
1809
+ html += '<table style="width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;">';
1810
+ html += '<thead><tr style="background:#f5f5f5;">';
1811
+ html += '<th style="width:30%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">User Prompt</th>';
1812
+ html += '<th style="width:10%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Calls</th>';
1813
+ html += '<th style="width:20%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Mode</th>';
1814
+ html += '<th style="width:40%;padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Replacement</th>';
1815
+ html += '</tr></thead></table>';
1816
+ html += '<div style="flex:1;overflow-y:auto;"><table style="width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;"><tbody>';
1817
+
1818
+ prompts.forEach(function(row) {
1819
+ const key = row.userPrompt;
1820
+ const existing = window._userPromptMockConfig[key] || { mode: 'live', replacement: '' };
1821
+ const preview = key.length > 60 ? key.slice(0, 60) + '…' : key;
1822
+
1823
+ html += '<tr class="user-prompt-mock-row" data-row-index="' + row.rowIndex + '" style="border-bottom:1px solid #f0f0f0;vertical-align:top;">';
1824
+
1825
+ // User prompt preview column
1826
+ html += '<td style="width:30%;padding:6px 10px;overflow:hidden;text-overflow:ellipsis;"><div style="font-size:11px;color:#555;font-family:Monaco,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + esc(key) + '">' + esc(preview) + '</div></td>';
1827
+
1828
+ // Calls count column
1829
+ html += '<td style="width:10%;padding:6px 10px;color:#555;white-space:nowrap;">' + row.count + 'x</td>';
1830
+
1831
+ // Mode column
1832
+ html += '<td style="width:20%;padding:6px 10px;white-space:nowrap;">';
1833
+ html += '<select class="user-prompt-mock-mode" style="font-size:12px;padding:2px 4px;width: 100%;" onchange="window.onUserPromptMockModeChange(' + row.rowIndex + ', this.value)">';
1834
+ html += '<option value="live"' + (existing.mode === 'live' ? ' selected' : '') + '>Live</option>';
1835
+ html += '<option value="replace-all"' + (existing.mode === 'replace-all' ? ' selected' : '') + '>Replace All</option>';
1836
+ if (row.count > 1) {
1837
+ html += '<option value="replace-specific"' + (existing.mode === 'replace-specific' ? ' selected' : '') + '>Replace Specific</option>';
1838
+ }
1839
+ html += '</select>';
1840
+ html += '</td>';
1841
+
1842
+ // Replacement column
1843
+ html += '<td style="width:40%;padding:6px 10px;">';
1844
+ if (existing.mode === 'replace-all') {
1845
+ html += '<textarea class="user-prompt-mock-input" data-row-index="' + row.rowIndex + '" style="width:100%;box-sizing:border-box;font-size:11px;font-family:Monaco,monospace;padding:4px;border:1px solid #ddd;border-radius:4px;min-height:48px;resize:vertical;" oninput="window.onUserPromptMockInput(' + row.rowIndex + ', this.value)">' + esc(existing.replacement || key) + '</textarea>';
1846
+ } else if (existing.mode === 'replace-specific') {
1847
+ html += '<div style="font-size:11px;color:#555;margin-bottom:4px;">Select calls to replace:</div>';
1848
+ row.calls.forEach(function(call) {
1849
+ const isChecked = existing.callIndices && existing.callIndices.indexOf(call.callNumber) !== -1;
1850
+ html += '<div style="margin-bottom:4px;padding:4px;background:#fafafa;border-radius:4px;border:1px solid #eee;">';
1851
+ html += '<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;">';
1852
+ html += '<input type="checkbox" class="user-prompt-call-checkbox" value="' + call.callNumber + '"' + (isChecked ? ' checked' : '') + ' onchange="window.onUserPromptCallCheckChange(' + row.rowIndex + ',' + call.callNumber + ',this.checked)">';
1853
+ html += '<span>Call #' + call.callNumber + '</span>';
1854
+ html += '</label>';
1855
+ html += '</div>';
1856
+ });
1857
+ html += '<textarea class="user-prompt-mock-input" data-row-index="' + row.rowIndex + '" style="width:100%;box-sizing:border-box;font-size:11px;font-family:Monaco,monospace;padding:4px;border:1px solid #ddd;border-radius:4px;min-height:48px;resize:vertical;margin-top:4px;" oninput="window.onUserPromptMockInput(' + row.rowIndex + ', this.value)">' + esc(existing.replacement || key) + '</textarea>';
1858
+ } else {
1859
+ html += '<span style="color:#aaa;font-size:11px;">—</span>';
1860
+ }
1861
+ html += '</td>';
1862
+
1863
+ html += '</tr>';
1864
+ });
1865
+
1866
+ html += '</tbody></table></div></div>';
1867
+ return html;
1868
+ }
1869
+
1870
+ function buildUserPromptMockConfigFromUI() {
1871
+ const config = {};
1872
+ Object.keys(window._userPromptMockConfig).forEach(function(key) {
1873
+ const entry = window._userPromptMockConfig[key];
1874
+ if (!entry || entry.mode === 'live') return;
1875
+ config[key] = { mode: entry.mode, replacement: entry.replacement || '' };
1876
+ if (entry.mode === 'replace-specific' && entry.callIndices) {
1877
+ config[key].callIndices = entry.callIndices;
1878
+ }
1879
+ });
1880
+ return config;
1881
+ }
1882
+
1883
+ window.onUserPromptMockModeChange = function(rowIndex, mode) {
1884
+ const prompts = getUserPromptsFromTrace();
1885
+ const row = prompts[rowIndex];
1886
+ if (!row) return;
1887
+ const key = row.userPrompt;
1888
+ if (mode === 'live') {
1889
+ delete window._userPromptMockConfig[key];
1890
+ } else {
1891
+ if (!window._userPromptMockConfig[key]) {
1892
+ window._userPromptMockConfig[key] = { mode: mode, replacement: row.userPrompt };
1893
+ } else {
1894
+ window._userPromptMockConfig[key].mode = mode;
1895
+ }
1896
+ if (mode === 'replace-specific' && !window._userPromptMockConfig[key].callIndices) {
1897
+ window._userPromptMockConfig[key].callIndices = [];
1898
+ }
1899
+ }
1900
+ const container = document.getElementById('userPromptMockContainer');
1901
+ if (container) container.innerHTML = renderUserPromptMockSection();
1902
+ };
1903
+
1904
+ window.onUserPromptMockInput = function(rowIndex, value) {
1905
+ const prompts = getUserPromptsFromTrace();
1906
+ const row = prompts[rowIndex];
1907
+ if (!row) return;
1908
+ if (window._userPromptMockConfig[row.userPrompt]) {
1909
+ window._userPromptMockConfig[row.userPrompt].replacement = value;
1910
+ }
1911
+ };
1912
+
1913
+ window.onUserPromptCallCheckChange = function(rowIndex, callNumber, checked) {
1914
+ const prompts = getUserPromptsFromTrace();
1915
+ const row = prompts[rowIndex];
1916
+ if (!row) return;
1917
+ const key = row.userPrompt;
1918
+ if (!window._userPromptMockConfig[key]) return;
1919
+ var indices = window._userPromptMockConfig[key].callIndices || [];
1920
+ if (checked) {
1921
+ if (indices.indexOf(callNumber) === -1) indices.push(callNumber);
1922
+ } else {
1923
+ var pos = indices.indexOf(callNumber);
1924
+ if (pos >= 0) indices.splice(pos, 1);
1925
+ }
1926
+ window._userPromptMockConfig[key].callIndices = indices;
1927
+ };
1928
+
1929
+ window.openLiveValidationDialog = function() {
1930
+ if (window.liveValidationDialog) return;
1931
+ window._toolMockConfig = {}; // Reset mock configs each time dialog opens
1932
+ window._promptMockConfig = {};
1933
+ window._userPromptMockConfig = {};
1934
+
1935
+ const hasTraceTools = currentObservations.some(function(o) { return o.type === 'TOOL'; });
1936
+ const hasRegisteredTools = codeIndex.tools && codeIndex.tools.length > 0;
1937
+
1938
+ window.liveValidationDialog = document.createElement('div');
1939
+ window.liveValidationDialog.id = 'liveValidationDialog';
1940
+ window.liveValidationDialog.style = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.25);display:flex;align-items:center;justify-content:center;z-index:9999;';
1941
+ window.liveValidationDialog.innerHTML = \`
1942
+ <div style="background:white;padding:32px 28px;border-radius:12px;box-shadow:0 2px 24px #0002;width:680px;max-width:90vw;max-height:90vh;overflow-y:auto;">
1943
+ <h3 style="margin-top:0;margin-bottom:18px;font-size:20px;">Validate Updated Flow with Live Data</h3>
1944
+ <label style="font-size:15px;display:block;margin-bottom:8px;">How many times do you want to run the flow with live data?</label>
1945
+ <input id="liveValidationCount" type="number" min="1" value="1" style="width:100%;font-size:16px;padding:6px 10px;margin-bottom:18px;" />
1946
+ <label style="display:flex;align-items:center;gap:8px;font-size:14px;margin-bottom:18px;">
1947
+ <input id="liveValidationSequential" type="checkbox" />
1948
+ Run in sequence instead of parallel
1949
+ </label>
1950
+ <div style="border-top:1px solid #eee;padding-top:16px;margin-bottom:16px;">
1951
+ <div style="display:flex;align-items:center;gap:0;border-bottom:2px solid #e0e0e0;margin-bottom:0;">
1952
+ \${(hasTraceTools || hasRegisteredTools) ? \`<button id="mockTabTool" class="mock-tab-btn" onclick="window.switchMockTab('tool')" style="padding:8px 18px;font-size:14px;font-weight:600;border:none;background:none;cursor:pointer;border-bottom:2px solid #4f46e5;color:#4f46e5;margin-bottom:-2px;">Tool Mocking <span class="help-icon-wrap"><span class="help-icon">?</span><span class="help-tooltip">Control which tools use real calls vs mock data. &lsquo;Live&rsquo; runs the real tool, &lsquo;Mock All Calls&rsquo; returns mock data for every call, and &lsquo;Mock Specific Calls&rsquo; lets you pick which call indices to mock.</span></span></button>\` : ''}
1953
+ <button id="mockTabPrompt" class="mock-tab-btn" onclick="window.switchMockTab('prompt')" style="padding:8px 18px;font-size:14px;font-weight:600;border:none;background:none;cursor:pointer;border-bottom:2px solid \${(hasTraceTools || hasRegisteredTools) ? 'transparent' : '#4f46e5'};color:\${(hasTraceTools || hasRegisteredTools) ? '#888' : '#4f46e5'};margin-bottom:-2px;">System Prompt <span class="help-icon-wrap"><span class="help-icon">?</span><span class="help-tooltip">Override the system prompt for LLM calls. Check a row to enable editing &mdash; the replacement applies to all LLM calls that use that exact system prompt text.</span></span></button>
1954
+ <button id="mockTabUserPrompt" class="mock-tab-btn" onclick="window.switchMockTab('userPrompt')" style="padding:8px 18px;font-size:14px;font-weight:600;border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;color:#888;margin-bottom:-2px;">User Prompt <span class="help-icon-wrap"><span class="help-icon">?</span><span class="help-tooltip">Override user-role messages in LLM calls. Each unique user message text is listed. Choose &lsquo;Replace All&rsquo; to replace it in every LLM call where it appears, or &lsquo;Replace Specific&rsquo; to only replace it in selected call occurrences.</span></span></button>
1955
+ <div id="mockTabToolExtra" style="margin-left:auto;display:flex;align-items:center;">
1956
+ \${(hasTraceTools || hasRegisteredTools) ? \`<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;">
1957
+ <input id="showAllToolsToggle" type="checkbox" onchange="document.getElementById('toolMockContainer').innerHTML = renderToolMockSection(this.checked);" />
1958
+ Show all tools
1959
+ </label>\` : ''}
1960
+ </div>
1961
+ </div>
1962
+ <div style="height:300px;position:relative;">
1963
+ <div id="mockTabToolContent" style="height:100%;overflow:hidden;\${(hasTraceTools || hasRegisteredTools) ? '' : 'display:none;'}">
1964
+ <div id="toolMockContainer" style="height:100%;"></div>
1965
+ </div>
1966
+ <div id="mockTabPromptContent" style="height:100%;overflow:hidden;\${(hasTraceTools || hasRegisteredTools) ? 'display:none;' : ''}">
1967
+ <div id="promptMockContainer" style="height:100%;"></div>
1968
+ </div>
1969
+ <div id="mockTabUserPromptContent" style="height:100%;overflow:hidden;display:none;">
1970
+ <div id="userPromptMockContainer" style="height:100%;"></div>
1971
+ </div>
1972
+ </div>
1973
+ </div>
1974
+ <div style="display:flex;gap:12px;justify-content:space-between;align-items:center;">
1975
+ <span id="liveValidationProgress" style="font-size:14px;color:#555;"></span>
1976
+ <div style="display:flex;gap:12px;">
1977
+ <button id="cancelLiveValidation" class="btn btn-secondary">Cancel</button>
1978
+ <button id="submitLiveValidation" class="btn btn-primary">Validate</button>
1979
+ </div>
1980
+ </div>
1981
+ </div>
1982
+ \`;
1983
+ document.body.appendChild(window.liveValidationDialog);
1984
+ // Render mock sections after DOM insertion
1985
+ const toolMockContainer = document.getElementById('toolMockContainer');
1986
+ if (toolMockContainer) {
1987
+ toolMockContainer.innerHTML = renderToolMockSection(false);
1988
+ }
1989
+ const promptMockContainer = document.getElementById('promptMockContainer');
1990
+ if (promptMockContainer) {
1991
+ promptMockContainer.innerHTML = renderPromptMockSection();
1992
+ }
1993
+ const userPromptMockContainer = document.getElementById('userPromptMockContainer');
1994
+ if (userPromptMockContainer) {
1995
+ userPromptMockContainer.innerHTML = renderUserPromptMockSection();
1996
+ }
1997
+ document.getElementById('cancelLiveValidation').onclick = function() {
1998
+ window.liveValidationDialog.remove();
1999
+ window.liveValidationDialog = null;
2000
+ };
2001
+ document.getElementById('submitLiveValidation').onclick = async function() {
2002
+ const count = parseInt(document.getElementById('liveValidationCount').value, 10);
2003
+ const sequential = document.getElementById('liveValidationSequential').checked;
2004
+ if (count >= 1) {
2005
+ // Build mock configs from UI state and persist for "Run from here"
2006
+ const toolMockConfig = buildToolMockConfigFromUI();
2007
+ window._toolMockConfig = toolMockConfig;
2008
+ const promptMockConfig = buildPromptMockConfigFromUI();
2009
+ window._promptMockConfig = promptMockConfig;
2010
+ const userPromptMockConfig = buildUserPromptMockConfigFromUI();
2011
+ window._userPromptMockConfig = userPromptMockConfig;
2012
+ const submitBtn = document.getElementById('submitLiveValidation');
2013
+ submitBtn.disabled = true;
2014
+ submitBtn.textContent = 'Validating...';
2015
+ const progressEl = document.getElementById('liveValidationProgress');
2016
+
2017
+ function finishValidation(collectedTraces, errorMsg, usedSequential) {
2018
+ if (progressEl) progressEl.textContent = '';
2019
+ window.liveValidationDialog.remove();
2020
+ window.liveValidationDialog = null;
2021
+ window.liveValidationCount = count;
2022
+ window.liveValidationSequential = usedSequential;
2023
+ window.step5SelectedTrace = 0;
2024
+ window.step5SelectedObservation = 0;
2025
+ currentStep = 5;
2026
+ updateModalTitle();
2027
+ updateFooterButtons();
2028
+ if (errorMsg && collectedTraces.length === 0) {
2029
+ step5RunTraces = [];
2030
+ localStorage.removeItem('ed_step5RunTraces');
2031
+ step5RunMeta = { loading: false, error: errorMsg, runCount: count, sequential: usedSequential };
2032
+ } else {
2033
+ step5RunTraces = collectedTraces;
2034
+ persistTraces();
2035
+ step5RunMeta = { loading: false, error: '', runCount: collectedTraces.length, sequential: usedSequential };
2036
+ }
2037
+ if (window.step5SelectedTrace > step5RunTraces.length) window.step5SelectedTrace = 0;
2038
+ window.step5SelectedObservation = 0;
2039
+ renderObservationTable();
2040
+ }
2041
+
2042
+ if (sequential) {
2043
+ // Sequential mode: fire one request per run so progress reflects real completion
2044
+ if (progressEl) progressEl.textContent = \`0 of \${count} workflow runs completed\`;
2045
+ const collectedTraces = [];
2046
+ let fatalError = null;
2047
+ for (let i = 0; i < count; i++) {
2048
+ const singlePayload = { workflowName: selectedWorkflow?.name, runCount: 1, sequential: false, observations: currentObservations, toolMockConfig, promptMockConfig, userPromptMockConfig };
2049
+ try {
2050
+ const response = await fetch('/api/validate-workflow', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(singlePayload) });
2051
+ const data = await response.json();
2052
+ if (response.ok && data.ok && Array.isArray(data.traces) && data.traces.length > 0) {
2053
+ collectedTraces.push({ ...data.traces[0], runNumber: i + 1 });
2054
+ } else {
2055
+ // Push an error trace so the run is still visible in Step 5
2056
+ collectedTraces.push({ runNumber: i + 1, ok: false, error: data.error || 'Workflow validation failed.', observations: [], workflowTrace: null });
2057
+ }
2058
+ } catch (err) {
2059
+ collectedTraces.push({ runNumber: i + 1, ok: false, error: err && err.message ? err.message : String(err), observations: [], workflowTrace: null });
2060
+ }
2061
+ if (progressEl) progressEl.textContent = \`\${i + 1} of \${count} workflow runs completed\`;
2062
+ }
2063
+ finishValidation(collectedTraces, fatalError, true);
2064
+ } else {
2065
+ // Parallel mode: single bulk request
2066
+ if (progressEl) progressEl.textContent = \`Running \${count} workflow run\${count !== 1 ? 's' : ''} in parallel…\`;
2067
+ const payload = { workflowName: selectedWorkflow?.name, runCount: count, sequential: false, observations: currentObservations, toolMockConfig, promptMockConfig, userPromptMockConfig };
2068
+ try {
2069
+ const response = await fetch('/api/validate-workflow', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
2070
+ const data = await response.json();
2071
+ if (response.ok && data.ok) {
2072
+ finishValidation(Array.isArray(data.traces) ? data.traces : [], null, false);
2073
+ } else {
2074
+ finishValidation([], data.error || 'Workflow validation failed.', false);
2075
+ }
2076
+ } catch (err) {
2077
+ finishValidation([], err && err.message ? err.message : String(err), false);
2078
+ }
2079
+ }
2080
+ } else {
2081
+ document.getElementById('liveValidationCount').style.borderColor = 'red';
2082
+ }
2083
+ };
2084
+ };
2085
+
2086
+ window.openPromptConfirmation = function(onConfirm) {
2087
+ const genObs = currentObservations
2088
+ .map((o, i) => ({ obs: o, idx: i }))
2089
+ .filter(({ obs, idx }) => obs.type === 'GENERATION' && checkedObservations.has(idx));
2090
+ if (genObs.length === 0) { onConfirm(); return; }
2091
+
2092
+ const tableRows = genObs.map(({ obs, idx }) => {
2093
+ const preview = toDisplayText(obs.input, obs.type).replace(/\\s+/g, ' ').trim().slice(0, 50);
2094
+ const model = obs.model || '—';
2095
+ return \`<tr>
2096
+ <td style="padding:8px 10px;font-size:13px;color:#555;">\${idx + 1}</td>
2097
+ <td style="padding:8px 10px;font-size:13px;font-family:Monaco,monospace;">\${esc(obs.name || 'AI call')}</td>
2098
+ <td style="padding:8px 10px;font-size:12px;font-family:Monaco,monospace;color:#444;max-width:320px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">\${esc(preview)}\${preview.length === 50 ? '…' : ''}</td>
2099
+ <td style="padding:8px 10px;font-size:12px;font-family:Monaco,monospace;color:#555;">\${esc(model)}</td>
2100
+ </tr>\`;
2101
+ }).join('');
2102
+
2103
+ const dlg = document.createElement('div');
2104
+ dlg.style = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;z-index:9998;';
2105
+ dlg.innerHTML = \`
2106
+ <div style="background:white;padding:32px 28px;border-radius:12px;box-shadow:0 2px 24px #0003;min-width:640px;max-width:92vw;">
2107
+ <h3 style="margin-top:0;margin-bottom:8px;font-size:20px;">Have you updated your AI prompts?</h3>
2108
+ <p style="margin:0 0 18px;font-size:14px;color:#555;">The following AI generation steps were found in your trace. Make sure you have edited the prompts in Step 4 before validating with live data.</p>
2109
+ <div style="border:1px solid #e0e0e0;border-radius:6px;overflow:hidden;margin-bottom:24px;">
2110
+ <table style="width:100%;border-collapse:collapse;">
2111
+ <thead>
2112
+ <tr style="background:#f5f5f5;">
2113
+ <th style="padding:8px 10px;text-align:left;font-size:12px;color:#555;border-bottom:1px solid #e0e0e0;">#</th>
2114
+ <th style="padding:8px 10px;text-align:left;font-size:12px;color:#555;border-bottom:1px solid #e0e0e0;">Name</th>
2115
+ <th style="padding:8px 10px;text-align:left;font-size:12px;color:#555;border-bottom:1px solid #e0e0e0;">Input preview</th>
2116
+ <th style="padding:8px 10px;text-align:left;font-size:12px;color:#555;border-bottom:1px solid #e0e0e0;">Model</th>
2117
+ </tr>
2118
+ </thead>
2119
+ <tbody>\${tableRows}</tbody>
2120
+ </table>
2121
+ </div>
2122
+ <div style="display:flex;gap:12px;justify-content:flex-end;">
2123
+ <button id="cancelPromptConfirm" class="btn btn-secondary">Cancel</button>
2124
+ <button id="proceedPromptConfirm" class="btn btn-primary">Yes, Proceed</button>
2125
+ </div>
2126
+ </div>
2127
+ \`;
2128
+ document.body.appendChild(dlg);
2129
+ dlg.querySelector('#cancelPromptConfirm').onclick = () => dlg.remove();
2130
+ dlg.querySelector('#proceedPromptConfirm').onclick = () => { dlg.remove(); onConfirm(); };
2131
+ };
2132
+
2133
+ uploadArea.onclick = () => fileInput.click();
2134
+
2135
+ // Drag and drop handlers
2136
+ uploadArea.ondragover = (e) => {
2137
+ e.preventDefault();
2138
+ e.stopPropagation();
2139
+ uploadArea.style.borderColor = '#0066cc';
2140
+ uploadArea.style.background = '#f0f7ff';
2141
+ };
2142
+
2143
+ uploadArea.ondragleave = (e) => {
2144
+ e.preventDefault();
2145
+ e.stopPropagation();
2146
+ uploadArea.style.borderColor = '#ddd';
2147
+ uploadArea.style.background = '#fafafa';
2148
+ };
2149
+
2150
+ uploadArea.ondrop = (e) => {
2151
+ e.preventDefault();
2152
+ e.stopPropagation();
2153
+ uploadArea.style.borderColor = '#ddd';
2154
+ uploadArea.style.background = '#fafafa';
2155
+
2156
+ const files = e.dataTransfer.files;
2157
+ if (files.length === 0) return;
2158
+
2159
+ const file = files[0];
2160
+ // Check if it's a JSON file
2161
+ if (!file.name.toLowerCase().endsWith('.json')) {
2162
+ uploadStatus.className = "upload-status error";
2163
+ uploadStatus.textContent = "Please drop a JSON file";
2164
+ return;
2165
+ }
2166
+
2167
+ handleFileUpload(file);
2168
+ };
2169
+
2170
+ fileInput.onchange = (e) => {
2171
+ if (!e.target.files[0]) return;
2172
+ const file = e.target.files[0];
2173
+ // Always clear file input so same file can be uploaded again
2174
+ fileInput.value = "";
2175
+ handleFileUpload(file);
2176
+ };
2177
+
2178
+ function handleFileUpload(file) {
2179
+ // Clear observations before loading new trace
2180
+ resetTraceModal();
2181
+ const reader = new FileReader();
2182
+ reader.onload = (e) => {
2183
+ try {
2184
+ const data = JSON.parse(e.target.result);
2185
+ uploadStatus.className = "upload-status";
2186
+ uploadStatus.textContent = "";
2187
+ displayTrace(data);
2188
+ } catch (err) {
2189
+ uploadArea.classList.remove("hidden");
2190
+ traceViewer.classList.remove("visible");
2191
+ uploadStatus.className = "upload-status error";
2192
+ uploadStatus.textContent = "Invalid JSON";
2193
+ }
2194
+ };
2195
+ reader.readAsText(file);
2196
+ }
2197
+
2198
+ function displayTrace(data) {
2199
+ let obs = [];
2200
+ if (Array.isArray(data)) {
2201
+ obs = data.filter(o =>
2202
+ (o.type === "GENERATION" || o.type === "TOOL" || o.type === "SPAN") &&
2203
+ (o.input !== null && o.input !== undefined) &&
2204
+ (o.output !== null && o.output !== undefined)
2205
+ );
2206
+ } else {
2207
+ const trace = data.trace || data;
2208
+ // Handle multiple formats: data.data, data.observations, trace.observations
2209
+ const rawObs = data.data || data.observations || trace.observations || [];
2210
+ obs = rawObs.filter(o =>
2211
+ (o.type === "GENERATION" || o.type === "TOOL" || o.type === "SPAN") &&
2212
+ (o.input !== null && o.input !== undefined) &&
2213
+ (o.output !== null && o.output !== undefined)
2214
+ );
2215
+ }
2216
+ // Sort by startTime ascending
2217
+ obs = obs.sort((a, b) => {
2218
+ const timeA = new Date(a.startTime || 0).getTime();
2219
+ const timeB = new Date(b.startTime || 0).getTime();
2220
+ return timeA - timeB;
2221
+ });
2222
+ // Aggregate token usage onto the workflow container SPAN if it has none
2223
+ if (selectedWorkflow?.name) {
2224
+ const containerIdx = obs.findIndex(
2225
+ o => o.type === 'SPAN' && o.name === selectedWorkflow.name
2226
+ );
2227
+ const _existingUsage = extractUsage(obs[containerIdx]);
2228
+ if (containerIdx >= 0 && (!_existingUsage || !(_existingUsage.totalTokens > 0))) {
2229
+ let inputTokens = 0, outputTokens = 0, totalTokens = 0;
2230
+ for (const o of obs) {
2231
+ if (o.type !== 'GENERATION') continue;
2232
+ const u = extractUsage(o);
2233
+ if (!u) continue;
2234
+ inputTokens += u.inputTokens ?? 0;
2235
+ outputTokens += u.outputTokens ?? 0;
2236
+ totalTokens += u.totalTokens ?? 0;
2237
+ }
2238
+ if (totalTokens > 0) {
2239
+ obs[containerIdx].usage = { inputTokens, outputTokens, totalTokens };
2240
+ }
2241
+ }
2242
+ }
2243
+ currentObservations = obs;
2244
+ selectedObservationIndex = -1;
2245
+ checkedObservations.clear();
2246
+ observationDetail.innerHTML = "";
2247
+ uploadArea.classList.add("hidden");
2248
+ traceViewer.classList.add("visible");
2249
+ currentStep = 3;
2250
+ updateModalTitle();
2251
+ updateFooterButtons();
2252
+ renderObservationTable();
2253
+ // Auto-select first observation
2254
+ if (currentObservations.length > 0) {
2255
+ selectObservation(0);
2256
+ }
2257
+ }
2258
+
2259
+
2260
+ function renderObservationTable() {
2261
+ // Capture scroll positions for step 5 columns before rebuilding DOM
2262
+ let _step5Col1Scroll = 0, _step5Col2Scroll = 0;
2263
+ if (currentStep === 5) {
2264
+ const tls = document.querySelectorAll('.trace-left');
2265
+ _step5Col1Scroll = tls[0]?.querySelector('.observation-table-wrap')?.scrollTop ?? 0;
2266
+ _step5Col2Scroll = tls[1]?.querySelector('.observation-table-wrap')?.scrollTop ?? 0;
2267
+ }
2268
+ // For other steps, preserve scroll position of the first .observation-table-wrap
2269
+ let obsTableWrap = document.querySelector('.observation-table-wrap');
2270
+ let prevScrollTop = obsTableWrap ? obsTableWrap.scrollTop : null;
2271
+ if (currentStep === 5) {
2272
+ // Initialize selections if not set
2273
+ if (window.step5SelectedTrace === undefined || window.step5SelectedTrace === null) {
2274
+ window.step5SelectedTrace = 0;
2275
+ }
2276
+ if (window.step5SelectedObservation === undefined || window.step5SelectedObservation === null) {
2277
+ window.step5SelectedObservation = 0;
2278
+ }
2279
+ // Step 5: Validate Updated Flow with Live Data
2280
+ // Render traceTable before observationsTable
2281
+ const traces = Array.isArray(step5RunTraces) ? step5RunTraces : [];
2282
+ const traceCount = traces.length + 1;
2283
+ document.getElementsByClassName("trace-layout")[0].classList.add("step-5");
2284
+ let traceTable = \`<div class="trace-section-title">Traces</div>
2285
+ <div class="observation-table-wrap">
2286
+ <table class="observation-table">
2287
+ <tbody>\`;
2288
+ for (let i = 0; i < traceCount; i++) {
2289
+ const isSelected = i === window.step5SelectedTrace;
2290
+ const run = i === 0 ? null : traces[i - 1];
2291
+ const status = run ? (run.ok ? ' ✓' : ' ✗') : '';
2292
+ const label = i === 0 ? "Original Trace" : \`\${run?.traceName ?? \`Trace-\${run?.runNumber ?? i}\`}\${status}\`;
2293
+ traceTable += \`<tr class="\${isSelected ? "selected" : ""}" onclick="window.step5SelectedTrace=\${i};window.step5SelectedObservation=0;renderObservationTable();"><td>\${label}</td></tr>\`;
2294
+ }
2295
+ traceTable += \`</tbody></table></div>\`;
2296
+
2297
+ // Observations table for selected trace
2298
+ let observationsTable = "";
2299
+ let detailsSection = "";
2300
+
2301
+ if (window.step5SelectedTrace > traces.length) {
2302
+ window.step5SelectedTrace = 0;
2303
+ }
2304
+
2305
+ if (window.step5SelectedTrace === 0) {
2306
+ // Original Trace: show all currentObservations (same as step 3)
2307
+ const _rerunDisabled = step5RunMeta.loading || step5RerunInFlight;
2308
+ observationsTable += \`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
2309
+ <div class="trace-section-title" style="margin-bottom:0">Observations</div>
2310
+ <button class="btn btn-primary" style="padding:4px 10px;font-size:12px;" onclick="rerunFullFlow(this)" \${_rerunDisabled ? 'disabled' : ''}>&#9654; Rerun</button>
2311
+ </div>
2312
+ <div class="observation-table-wrap">
2313
+ <table class="observation-table">
2314
+ <thead><tr><th>Name</th><th>Type</th><th style="width:80px;">Duration</th></tr></thead>
2315
+ <tbody>\`;
2316
+ observationsTable += currentObservations.map((obs, j) => {
2317
+ const isSelected = j === window.step5SelectedObservation;
2318
+ const name = obs.name || obs.id || ("Observation " + (j + 1));
2319
+ const type = obs.type || "UNKNOWN";
2320
+ const typeClass = type === "TOOL" ? "tool" : "ai";
2321
+ const agentBadge = obs.agentTaskIndex != null ? \`<span class="agent-task-badge">T\${obs.agentTaskIndex + 1}</span>\` : '';
2322
+ const rowClass = obs.agentTaskIndex != null ? 'agent-task-row' : '';
2323
+ return \`<tr class="\${isSelected ? "selected" : ""} \${rowClass}" onclick="window.step5SelectedObservation=\${j};renderObservationTable();"><td>\${esc(name)}\${agentBadge}</td><td><span class="obs-type \${typeClass}">\${esc(type)}</span></td><td style="color:#888;font-size:12px;">\${formatDuration(computeDurationMs(obs))}</td></tr>\`;
2324
+ }).join("");
2325
+ observationsTable += \`</tbody></table></div>\`;
2326
+ // Details for selected observation
2327
+ const selObs = currentObservations[window.step5SelectedObservation];
2328
+ if (selObs) {
2329
+ const inputText = toDisplayText(selObs.input, selObs.type);
2330
+ const outputText = toDisplayText(selObs.output, selObs.type);
2331
+ const detailId = 'step5-orig-' + window.step5SelectedObservation;
2332
+ const filePathHtml = selObs.type === "GENERATION"
2333
+ ? '<div class="file-path-placeholder"></div>'
2334
+ : renderFilePath(getObsFilePath(selObs));
2335
+ const _dur5orig = computeDurationMs(selObs);
2336
+ detailsSection = \`<div class="detail-sections" id="\${detailId}">
2337
+ \${filePathHtml}
2338
+ \${renderModel(selObs)}
2339
+ \${_dur5orig != null ? \`<div class="detail-section"><div class="detail-title">Duration</div><pre class="detail-pre">\${formatDuration(_dur5orig)}</pre></div>\` : ''}
2340
+ \${renderUsage(selObs)}
2341
+ <div class="detail-section">
2342
+ <div class="detail-title">Input</div>
2343
+ <pre class="detail-pre">\${esc(inputText)}</pre>
2344
+ </div>
2345
+ <div class="detail-section">
2346
+ <div class="detail-title">Output</div>
2347
+ <pre class="detail-pre">\${esc(outputText)}</pre>
2348
+ </div>
2349
+ </div>\`;
2350
+ if (selObs.type === "GENERATION") setTimeout(() => resolveGenFilePath(selObs, detailId), 0);
2351
+ }
2352
+ } else {
2353
+ // Live traces: index 1 → traces[0], index 2 → traces[1], …
2354
+ const liveTrace = traces[window.step5SelectedTrace - 1];
2355
+ if (liveTrace) {
2356
+ const actions = Array.isArray(liveTrace.observations) ? liveTrace.observations : [];
2357
+ const traceIdx = window.step5SelectedTrace - 1;
2358
+ const _rerunDisabled2 = step5RunMeta.loading || step5RerunInFlight;
2359
+ observationsTable += \`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
2360
+ <div class="trace-section-title" style="margin-bottom:0">Observations</div>
2361
+ <button class="btn btn-primary" style="padding:4px 10px;font-size:12px;" onclick="rerunFullFlow(this)" \${_rerunDisabled2 ? 'disabled' : ''}>&#9654; Rerun</button>
2362
+ </div>
2363
+ <div class="observation-table-wrap">
2364
+ <table class="observation-table">
2365
+ <thead><tr><th>Name</th><th>Type</th><th style="width:80px;">Duration</th></tr></thead>
2366
+ <tbody>\`;
2367
+ observationsTable += actions.map((action, j) => {
2368
+ const isSelected = j === window.step5SelectedObservation;
2369
+ const name = action.name || action.id || ("Observation " + (j + 1));
2370
+ const type = action.type || "UNKNOWN";
2371
+ const typeClass = type === "TOOL" ? "tool" : "ai";
2372
+ const agentBadge = action.agentTaskIndex != null ? \`<span class="agent-task-badge">T\${action.agentTaskIndex + 1}</span>\` : '';
2373
+ const frozenBadge = action.isFrozen ? '<span class="frozen-tag">Frozen</span>' : '';
2374
+ const rowClasses = [
2375
+ isSelected ? 'selected' : '',
2376
+ action.agentTaskIndex != null ? 'agent-task-row' : '',
2377
+ action.isFrozen ? 'frozen-row' : '',
2378
+ ].filter(Boolean).join(' ');
2379
+ return \`<tr class="\${rowClasses}" onclick="window.step5SelectedObservation=\${j};renderObservationTable();"><td>\${esc(name)}\${agentBadge}\${frozenBadge}</td><td><span class="obs-type \${typeClass}">\${esc(type)}</span></td><td style="color:#888;font-size:12px;">\${formatDuration(computeDurationMs(action))}</td></tr>\`;
2380
+ }).join("");
2381
+ observationsTable += \`</tbody></table></div>\`;
2382
+ // Details for selected observation
2383
+ if (actions[window.step5SelectedObservation]) {
2384
+ const obs = actions[window.step5SelectedObservation];
2385
+ const inputText = toDisplayText(obs.input, obs.type);
2386
+ const outputText = toDisplayText(obs.output, obs.type);
2387
+ const detailId = 'step5-live-' + (window.step5SelectedTrace - 1) + '-' + window.step5SelectedObservation;
2388
+ const filePathHtml = obs.type === "GENERATION"
2389
+ ? '<div class="file-path-placeholder"></div>'
2390
+ : renderFilePath(getObsFilePath(obs));
2391
+
2392
+ // For the first observation (workflow output), show Original Output for comparison
2393
+ let originalOutputSection = '';
2394
+ if (window.step5SelectedObservation === 0 && currentObservations[0]) {
2395
+ const originalOutputText = toDisplayText(currentObservations[0].output, currentObservations[0].type);
2396
+ originalOutputSection = \`<div class="detail-section">
2397
+ <div class="detail-title">Original Output</div>
2398
+ <pre class="detail-pre">\${esc(originalOutputText)}</pre>
2399
+ </div>\`;
2400
+ }
2401
+
2402
+ // Agent steps: always show "Resume from Task X" button
2403
+ // Non-agent steps: show "Run from here" only if they have workflowEventId (HTTP/DB events)
2404
+ // Steps without agentTaskIndex and without workflowEventId get no button
2405
+ let runFromBpHtml = '';
2406
+ if (obs.agentTaskIndex != null) {
2407
+ runFromBpHtml = \`<div class="detail-section" style="padding:8px 12px;"><button class="resume-agent-btn" onclick="resumeAgentFromTask(\${traceIdx},\${window.step5SelectedObservation},\${obs.agentTaskIndex},event)">&#9654; Resume from Task \${obs.agentTaskIndex + 1}</button></div>\`;
2408
+ } else if (obs.workflowEventId != null) {
2409
+ runFromBpHtml = \`<div class="detail-section" style="padding:8px 12px;"><button class="run-from-bp-btn" onclick="runFromBreakpoint(\${traceIdx},\${window.step5SelectedObservation},event)">&#9654; Run from here</button></div>\`;
2410
+ }
2411
+ const _dur5live = computeDurationMs(obs);
2412
+ detailsSection = \`<div class="detail-sections" id="\${detailId}">
2413
+ \${filePathHtml}
2414
+ \${runFromBpHtml}
2415
+ \${renderModel(obs)}
2416
+ \${_dur5live != null ? \`<div class="detail-section"><div class="detail-title">Duration</div><pre class="detail-pre">\${formatDuration(_dur5live)}</pre></div>\` : ''}
2417
+ \${renderUsage(obs)}
2418
+ <div class="detail-section">
2419
+ <div class="detail-title">Input</div>
2420
+ <pre class="detail-pre">\${esc(inputText)}</pre>
2421
+ </div>
2422
+ \${originalOutputSection}
2423
+ <div class="detail-section">
2424
+ <div class="detail-title">Output</div>
2425
+ <pre class="detail-pre">\${esc(outputText)}</pre>
2426
+ </div>
2427
+ </div>\`;
2428
+ if (obs.type === "GENERATION") setTimeout(() => resolveGenFilePath(obs, detailId), 0);
2429
+ }
2430
+ }
2431
+ }
2432
+
2433
+ if (step5RunMeta.loading) {
2434
+ detailsSection = \`<div class="detail-sections">
2435
+ <div class="detail-section">
2436
+ <div class="detail-title">Validation</div>
2437
+ <pre class="detail-pre">Running \${step5RunMeta.runCount || 1} workflow run(s) in \${step5RunMeta.sequential ? 'sequence' : 'parallel'} mode...</pre>
2438
+ </div>
2439
+ </div>\`;
2440
+ } else if (step5RunMeta.error && !detailsSection) {
2441
+ detailsSection = \`<div class="detail-sections">
2442
+ <div class="detail-section">
2443
+ <div class="detail-title">Validation Error</div>
2444
+ <pre class="detail-pre">\${esc(step5RunMeta.error)}</pre>
2445
+ </div>
2446
+ </div>\`;
2447
+ }
2448
+
2449
+ // Render 3 sibling columns inside the CSS grid
2450
+ const traceLayout = document.getElementsByClassName("trace-layout")[0];
2451
+ traceLayout.innerHTML = \`
2452
+ <div class="trace-left">\${traceTable}</div>
2453
+ <div class="trace-left">\${observationsTable || '<div class="trace-section-title">Observations</div>'}</div>
2454
+ <div class="trace-right">\${detailsSection}</div>
2455
+ \`;
2456
+ // Restore scroll positions after DOM rebuild
2457
+ const _newTls = traceLayout.querySelectorAll('.trace-left');
2458
+ const _w1 = _newTls[0]?.querySelector('.observation-table-wrap');
2459
+ const _w2 = _newTls[1]?.querySelector('.observation-table-wrap');
2460
+ if (_w1) _w1.scrollTop = _step5Col1Scroll;
2461
+ if (_w2) _w2.scrollTop = _step5Col2Scroll;
2462
+ return;
2463
+ }
2464
+
2465
+ if (currentStep === 4) {
2466
+ const traceLayout = document.getElementsByClassName("trace-layout")[0];
2467
+ traceLayout.classList.remove("step-5");
2468
+ traceLayout.classList.add("step-4");
2469
+ const obsIndices = Array.from(checkedObservations);
2470
+
2471
+ // Column 1: Steps (observation list)
2472
+ let col1 = \`<div class="trace-section-title">Steps</div>
2473
+ <div class="observation-table-wrap">
2474
+ <table class="observation-table">
2475
+ <thead><tr><th>Name</th><th>Type</th><th style="width:80px;">Duration</th></tr></thead>
2476
+ <tbody>\`;
2477
+ col1 += obsIndices.map(idx => {
2478
+ const obs = currentObservations[idx];
2479
+ const isSelected = idx === selectedObservationIndex;
2480
+ const name = obs.name || obs.id || ('Observation ' + (idx + 1));
2481
+ const type = obs.type || 'UNKNOWN';
2482
+ const typeClass = type === 'TOOL' ? 'tool' : 'ai';
2483
+ const history = rerunHistory.get(idx) || [];
2484
+ const latest = history[history.length - 1];
2485
+ const badge = latest
2486
+ ? (latest.running ? ' <span class="rerun-status running">⟳</span>'
2487
+ : latest.ok ? ' <span class="rerun-status success">✓</span>'
2488
+ : ' <span class="rerun-status error">✗</span>')
2489
+ : '';
2490
+ return \`<tr class="\${isSelected ? 'selected' : ''}" onclick="window.step4SelectObservation(\${idx})"><td>\${esc(name)}\${badge}</td><td><span class="obs-type \${typeClass}">\${esc(type)}</span></td><td style="color:#888;font-size:12px;">\${formatDuration(computeDurationMs(obs))}</td></tr>\`;
2491
+ }).join('');
2492
+ col1 += \`</tbody></table></div>\`;
2493
+
2494
+ // Column 2: Runs for the selected observation
2495
+ const inFlight = selectedObservationIndex >= 0 && rerunInFlight.has(selectedObservationIndex);
2496
+ let col2 = \`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
2497
+ <div class="trace-section-title" style="margin-bottom:0">Runs</div>
2498
+ \${selectedObservationIndex >= 0 ? \`<button class="btn btn-primary" style="padding:4px 12px;font-size:12px;" onclick="rerunObservation(\${selectedObservationIndex})" \${inFlight ? 'disabled' : ''}>\${inFlight ? 'Running...' : 'Rerun'}</button>\` : ''}
2499
+ </div>\`;
2500
+ if (selectedObservationIndex >= 0) {
2501
+ const history = rerunHistory.get(selectedObservationIndex) || [];
2502
+ if (history.length === 0) {
2503
+ col2 += \`<div style="color:#999;font-size:13px;padding:8px;">No runs yet. Click Rerun to start.</div>\`;
2504
+ } else {
2505
+ col2 += \`<div class="observation-table-wrap"><table class="observation-table"><thead><tr><th>Run</th><th>Status</th></tr></thead><tbody>\`;
2506
+ col2 += history.map((run, ri) => {
2507
+ const isSelRun = ri === step4SelectedRun;
2508
+ const status = run.running ? '⟳ Running' : (run.ok ? '✓ Complete' : '✗ Failed');
2509
+ return \`<tr class="\${isSelRun ? 'selected' : ''}" onclick="window.step4SelectRun(\${ri})"><td>Run \${run.runNumber}</td><td>\${status}</td></tr>\`;
2510
+ }).join('');
2511
+ col2 += \`</tbody></table></div>\`;
2512
+ }
2513
+ } else {
2514
+ col2 += \`<div style="color:#999;font-size:13px;padding:8px;">Select a step to view runs.</div>\`;
2515
+ }
2516
+
2517
+ // Column 3: Details for the selected run
2518
+ let col3 = '';
2519
+ if (selectedObservationIndex >= 0) {
2520
+ const obs = currentObservations[selectedObservationIndex];
2521
+ const history = rerunHistory.get(selectedObservationIndex) || [];
2522
+ const run = step4SelectedRun >= 0 ? history[step4SelectedRun] : null;
2523
+ const inputText = toDisplayText(obs.input, obs.type);
2524
+ const outputText = toDisplayText(obs.output, obs.type);
2525
+ const detailId = 'step4-detail-' + selectedObservationIndex + '-' + step4SelectedRun;
2526
+ const filePathHtml = obs.type === "GENERATION"
2527
+ ? '<div class="file-path-placeholder"></div>'
2528
+ : renderFilePath(getObsFilePath(obs));
2529
+ let currentOutputSection = '';
2530
+ if (run) {
2531
+ if (run.running) {
2532
+ currentOutputSection = \`<div class="detail-section"><div class="detail-title">Current Output</div><pre class="detail-pre">Running...</pre></div>\`;
2533
+ } else if (run.ok) {
2534
+ currentOutputSection = \`<div class="detail-section"><div class="detail-title">Current Output</div><pre class="detail-pre">\${esc(toDisplayText(run.output, obs.type))}</pre></div>\`;
2535
+ } else {
2536
+ currentOutputSection = \`<div class="detail-section"><div class="detail-title">Current Output</div><pre class="detail-pre">Rerun failed: \${esc(run.error || 'Unknown error')}</pre></div>\`;
2537
+ }
2538
+ }
2539
+ // Editable AI input UI
2540
+ let inputSection = '';
2541
+ if (obs.type === "GENERATION") {
2542
+ const hasUpdate = updatedInputs.has(selectedObservationIndex);
2543
+ inputSection += \`<div class="detail-section">
2544
+ <div class="detail-title">Input</div>
2545
+ <pre class="detail-pre" style="position:relative;">\${esc(toDisplayText(obs.input, obs.type))}
2546
+ <button class="btn btn-secondary edit-btn" style="position:absolute;top:8px;right:8px;padding:2px 10px;font-size:12px;\${hasUpdate ? 'display:none;' : ''}" onclick="window.enableInputEditing('\${detailId}', \${selectedObservationIndex})">Edit</button>
2547
+ <button class="btn btn-secondary reset-btn" style="position:absolute;top:8px;right:8px;padding:2px 10px;font-size:12px;\${hasUpdate ? '' : 'display:none;'}" onclick="window.resetInput('\${detailId}', \${selectedObservationIndex})">Reset</button>
2548
+ </pre>
2549
+ </div>\`;
2550
+ if (hasUpdate) {
2551
+ inputSection += \`<div class="detail-section updated-input-section">
2552
+ <div class="detail-title">Update Input</div>
2553
+ <textarea id="editInputTextarea" class="detail-pre" style="width:100%;height:400px;">\${esc(updatedInputs.get(selectedObservationIndex))}</textarea>
2554
+ <button class="btn btn-secondary save-btn" style="margin-top:8px;float:right;padding:2px 10px;font-size:12px;" onclick="window.saveUpdatedInput(\${selectedObservationIndex})">Save</button>
2555
+ </div>\`;
2556
+ }
2557
+ } else {
2558
+ inputSection += \`<div class="detail-section"><div class="detail-title">Input</div><pre class="detail-pre">\${esc(toDisplayText(obs.input, obs.type))}</pre></div>\`;
2559
+ }
2560
+ const _dur4 = computeDurationMs(obs);
2561
+ const currentDurSection = (run && !run.running && run.ok && run.currentDurationMs != null)
2562
+ ? \`<div class="detail-section"><div class="detail-title">Current Duration</div><pre class="detail-pre">\${formatDuration(run.currentDurationMs)}</pre></div>\`
2563
+ : '';
2564
+ const currentUsageSection = (run && !run.running && run.ok && run.currentUsage && run.currentUsage.totalTokens > 0)
2565
+ ? \`<div class="detail-section"><div class="detail-title">Current Usage</div><pre class="detail-pre">\${[
2566
+ run.currentUsage.inputTokens != null ? 'Input tokens: ' + run.currentUsage.inputTokens : null,
2567
+ run.currentUsage.outputTokens != null ? 'Output tokens: ' + run.currentUsage.outputTokens : null,
2568
+ run.currentUsage.totalTokens != null ? 'Total tokens: ' + run.currentUsage.totalTokens : null,
2569
+ ].filter(Boolean).join('\\n')}</pre></div>\`
2570
+ : '';
2571
+ col3 = \`<div class="detail-sections" id="\${detailId}">
2572
+ \${filePathHtml}
2573
+ \${renderModel(obs)}
2574
+ \${_dur4 != null ? \`<div class="detail-section"><div class="detail-title">Duration</div><pre class="detail-pre">\${formatDuration(_dur4)}</pre></div>\` : ''}
2575
+ \${currentDurSection}
2576
+ \${renderUsage(obs)}
2577
+ \${currentUsageSection}
2578
+ \${inputSection}
2579
+ <div class="detail-section"><div class="detail-title">Output</div><pre class="detail-pre">\${esc(outputText)}</pre></div>
2580
+ \${currentOutputSection}
2581
+ </div>\`;
2582
+ if (obs.type === "GENERATION") setTimeout(() => resolveGenFilePath(obs, detailId), 0);
2583
+ }
2584
+
2585
+ traceLayout.innerHTML = \`
2586
+ <div class="trace-left">\${col1}</div>
2587
+ <div class="trace-left">\${col2}</div>
2588
+ <div class="trace-right">\${col3}</div>
2589
+ \`;
2590
+ // Auto-select first observation if none selected
2591
+ if (obsIndices.length > 0 && selectedObservationIndex === -1) {
2592
+ window.step4SelectObservation(obsIndices[0]);
2593
+ }
2594
+ return;
2595
+ }
2596
+
2597
+ const traceLayoutEl = document.getElementsByClassName("trace-layout")[0];
2598
+ if (traceLayoutEl.classList.contains("step-5") || traceLayoutEl.classList.contains("step-4")) {
2599
+ traceLayoutEl.classList.remove("step-5");
2600
+ traceLayoutEl.classList.remove("step-4");
2601
+ let headerHtml = '';
2602
+ if (currentStep === 3) {
2603
+ headerHtml = '<tr><th style="width: 40px;">Check</th><th>Name</th><th>Type</th><th>Duration</th></tr>';
2604
+ }
2605
+ traceLayoutEl.innerHTML = \`
2606
+ <div class="trace-left">
2607
+ <div class="trace-section-title">Observations</div>
2608
+ <div class="observation-table-wrap">
2609
+ <table class="observation-table">
2610
+ <thead id="observationTableHead">\${headerHtml}</thead>
2611
+ <tbody id="observationTableBody"></tbody>
2612
+ </table>
2613
+ </div>
2614
+ </div>
2615
+ <div class="trace-right">
2616
+ <div id="observationDetail"></div>
2617
+ </div>
2618
+ \`;
2619
+ observationTableBody = document.getElementById("observationTableBody");
2620
+ observationDetail = document.getElementById("observationDetail");
2621
+ }
2622
+
2623
+ const obsToRender = currentObservations;
2624
+ const indices = currentObservations.map((_, i) => i);
2625
+
2626
+ if (!obsToRender.length) {
2627
+ observationTableBody.innerHTML = '<tr><td colspan="3" style="padding: 16px; color: #777;">No observations found.</td></tr>';
2628
+ return;
2629
+ }
2630
+
2631
+ observationTableBody.innerHTML = obsToRender.map((obs, displayIndex) => {
2632
+ const actualIndex = indices[displayIndex];
2633
+ const isSelected = actualIndex === selectedObservationIndex;
2634
+ const isChecked = checkedObservations.has(actualIndex);
2635
+ const name = obs.name || obs.id || ("Observation " + (displayIndex + 1));
2636
+ const type = obs.type || "UNKNOWN";
2637
+ const typeClass = type === "TOOL" ? "tool" : "ai";
2638
+ // Step 3: Mark broken - show checkboxes
2639
+ return \`<tr class="\${isSelected ? "selected" : ""}">
2640
+ <td style="width: 40px;"><input type="checkbox" class="obs-checkbox" value="\${actualIndex}" \${isChecked ? "checked" : ""}></td>
2641
+ <td onclick="selectObservation(\${actualIndex})">\${esc(name)}</td>
2642
+ <td><span class="obs-type \${typeClass}">\${esc(type)}</span></td>
2643
+ <td style="color:#888;font-size:12px;">\${formatDuration(computeDurationMs(obs))}</td>
2644
+ </tr>\`;
2645
+ }).join("");
2646
+
2647
+ document.querySelectorAll(".obs-checkbox").forEach(checkbox => {
2648
+ checkbox.onchange = (e) => {
2649
+ const idx = parseInt(e.target.value);
2650
+ if (e.target.checked) {
2651
+ checkedObservations.add(idx);
2652
+ } else {
2653
+ checkedObservations.delete(idx);
2654
+ }
2655
+ };
2656
+ });
2657
+ // Restore scroll position if previously saved
2658
+ if (currentStep === 5 && prevStep5ObsScrollTop !== null) {
2659
+ // After rendering, restore scroll for the observations table in the second .trace-left
2660
+ const traceLefts = document.querySelectorAll('.trace-left');
2661
+ if (traceLefts.length > 1) {
2662
+ const obsWrap = traceLefts[1].querySelector('.observation-table-wrap');
2663
+ if (obsWrap) obsWrap.scrollTop = prevStep5ObsScrollTop;
2664
+ }
2665
+ } else {
2666
+ obsTableWrap = document.querySelector('.observation-table-wrap');
2667
+ if (obsTableWrap && prevScrollTop !== null) {
2668
+ obsTableWrap.scrollTop = prevScrollTop;
2669
+ }
2670
+ }
2671
+ }
2672
+
2673
+ function selectObservation(index) {
2674
+ // Preserve scroll position of observation-table-wrap
2675
+ let obsTableWrap = document.querySelector('.observation-table-wrap');
2676
+ let prevScrollTop = obsTableWrap ? obsTableWrap.scrollTop : null;
2677
+ selectedObservationIndex = index;
2678
+ renderObservationTable();
2679
+ // Restore scroll position after rendering
2680
+ obsTableWrap = document.querySelector('.observation-table-wrap');
2681
+ if (obsTableWrap && prevScrollTop !== null) {
2682
+ obsTableWrap.scrollTop = prevScrollTop;
2683
+ }
2684
+ const obs = currentObservations[index];
2685
+ const inputText = toDisplayText(obs.input, obs.type);
2686
+ const outputText = toDisplayText(obs.output, obs.type);
2687
+ const detailId = 'obs-detail-' + index;
2688
+ const filePathHtml = obs.type === "GENERATION"
2689
+ ? '<div class="file-path-placeholder"></div>'
2690
+ : renderFilePath(getObsFilePath(obs));
2691
+
2692
+ const _dur3 = computeDurationMs(obs);
2693
+ observationDetail.innerHTML = \`<div class="detail-sections" id="\${detailId}">
2694
+ \${filePathHtml}
2695
+ \${renderModel(obs)}
2696
+ \${_dur3 != null ? \`<div class="detail-section"><div class="detail-title">Duration</div><pre class="detail-pre">\${formatDuration(_dur3)}</pre></div>\` : ''}
2697
+ \${renderUsage(obs)}
2698
+ <div class="detail-section">
2699
+ <div class="detail-title">Input</div>
2700
+ <pre class="detail-pre">\${esc(inputText)}</pre>
2701
+ </div>
2702
+ <div class="detail-section">
2703
+ <div class="detail-title">Output</div>
2704
+ <pre class="detail-pre">\${esc(outputText)}</pre>
2705
+ </div>
2706
+ </div>\`;
2707
+ if (obs.type === "GENERATION") resolveGenFilePath(obs, detailId);
2708
+ }
2709
+
2710
+ async function rerunObservation(index) {
2711
+ const obs = currentObservations[index];
2712
+ let inputToUse = obs.input;
2713
+ if (obs.type === "GENERATION" && updatedInputs.has(index)) {
2714
+ inputToUse = updatedInputs.get(index);
2715
+ }
2716
+ // Proceed with rerun logic using inputToUse
2717
+ console.log('Rerunning observation with input:', inputToUse);
2718
+ const history = rerunHistory.get(index) || [];
2719
+ const newRun = { runNumber: history.length + 1, running: true, ok: null };
2720
+ history.push(newRun);
2721
+ rerunHistory.set(index, history);
2722
+ rerunInFlight.add(index);
2723
+ if (selectedObservationIndex === index) {
2724
+ step4SelectedRun = history.length - 1;
2725
+ }
2726
+ renderObservationTable();
2727
+ try {
2728
+ const payload = { observation: { ...obs, input: inputToUse } };
2729
+ const response = await fetch('/api/rerun-observation', {
2730
+ method: 'POST',
2731
+ headers: { 'Content-Type': 'application/json' },
2732
+ body: JSON.stringify(payload),
2733
+ });
2734
+ const data = await response.json();
2735
+ newRun.running = false;
2736
+ if (response.ok && data.ok) {
2737
+ newRun.ok = true;
2738
+ newRun.output = data.currentOutput;
2739
+ newRun.currentDurationMs = data.currentDurationMs ?? null;
2740
+ newRun.currentUsage = data.currentUsage ?? null;
2741
+ } else {
2742
+ newRun.ok = false;
2743
+ newRun.error = data.error || 'Rerun failed.';
2744
+ }
2745
+ } catch (err) {
2746
+ newRun.running = false;
2747
+ newRun.ok = false;
2748
+ newRun.error = err && err.message ? err.message : String(err);
2749
+ } finally {
2750
+ rerunInFlight.delete(index);
2751
+ renderObservationTable();
2752
+ }
2753
+ }
2754
+
2755
+ async function runFromBreakpoint(traceIdx, obsIdx, evt) {
2756
+ const btn = evt.target;
2757
+ const liveTrace = step5RunTraces[traceIdx];
2758
+ if (!liveTrace) return;
2759
+ const obs = liveTrace.observations[obsIdx];
2760
+ if (!obs || obs.workflowEventId == null) return;
2761
+
2762
+ btn.disabled = true;
2763
+ btn.textContent = 'Running…';
2764
+
2765
+ try {
2766
+ const payload = {
2767
+ workflowName: selectedWorkflow ? selectedWorkflow.name : '',
2768
+ checkpoint: obs.workflowEventId,
2769
+ snapshotId: liveTrace.snapshotId,
2770
+ observations: currentObservations,
2771
+ toolMockConfig: window._toolMockConfig || {},
2772
+ promptMockConfig: window._promptMockConfig || {},
2773
+ userPromptMockConfig: window._userPromptMockConfig || {},
2774
+ };
2775
+ const response = await fetch('/api/run-from-breakpoint', {
2776
+ method: 'POST',
2777
+ headers: { 'Content-Type': 'application/json' },
2778
+ body: JSON.stringify(payload),
2779
+ });
2780
+ const data = await response.json();
2781
+ if (response.ok) {
2782
+ // Sub-trace naming: "Trace-N-M" where N = parent name, M = resume count from parent
2783
+ const parentTraceName = liveTrace.traceName ?? \`Trace-\${traceIdx + 1}\`;
2784
+ const siblingCount = step5RunTraces.filter(t => t.parentTraceName === parentTraceName).length;
2785
+ const traceName = \`\${parentTraceName}-\${siblingCount + 1}\`;
2786
+ const newTrace = { ...data, runNumber: step5RunTraces.length + 1, traceName, parentTraceName };
2787
+ step5RunTraces.push(newTrace);
2788
+ persistTraces();
2789
+ renderObservationTable();
2790
+ } else {
2791
+ btn.textContent = '✗ ' + (data.error || 'Failed');
2792
+ btn.disabled = false;
2793
+ }
2794
+ } catch (err) {
2795
+ btn.textContent = '✗ Error';
2796
+ btn.disabled = false;
2797
+ }
2798
+ }
2799
+
2800
+ async function rerunFullFlow(btn) {
2801
+ if (step5RerunInFlight || step5RunMeta.loading) return;
2802
+ step5RerunInFlight = true;
2803
+ renderObservationTable();
2804
+ try {
2805
+ const payload = { workflowName: selectedWorkflow?.name, runCount: 1, sequential: false, observations: currentObservations, toolMockConfig: window._toolMockConfig || {}, promptMockConfig: window._promptMockConfig || {}, userPromptMockConfig: window._userPromptMockConfig || {} };
2806
+ const response = await fetch('/api/validate-workflow', {
2807
+ method: 'POST',
2808
+ headers: { 'Content-Type': 'application/json' },
2809
+ body: JSON.stringify(payload),
2810
+ });
2811
+ const data = await response.json();
2812
+ if (response.ok && data.ok && Array.isArray(data.traces) && data.traces.length > 0) {
2813
+ const newTrace = { ...data.traces[0], runNumber: step5RunTraces.length + 1 };
2814
+ step5RunTraces.push(newTrace);
2815
+ persistTraces();
2816
+ window.step5SelectedTrace = step5RunTraces.length;
2817
+ window.step5SelectedObservation = 0;
2818
+ }
2819
+ } catch (err) {
2820
+ // silently ignore — dashboard may not be connected
2821
+ } finally {
2822
+ step5RerunInFlight = false;
2823
+ renderObservationTable();
2824
+ }
2825
+ }
2826
+
2827
+ async function resumeAgentFromTask(traceIdx, obsIdx, taskIndex, evt) {
2828
+ const btn = evt.target;
2829
+ const liveTrace = step5RunTraces[traceIdx];
2830
+ if (!liveTrace) return;
2831
+
2832
+ // Extract AgentPlan from the workflow's currentOutput
2833
+ const currentOutput = liveTrace.currentOutput;
2834
+ const agentPlan = currentOutput && typeof currentOutput === 'object' && Array.isArray(currentOutput.tasks)
2835
+ ? currentOutput
2836
+ : null;
2837
+ if (!agentPlan) {
2838
+ alert('No agent plan found in this trace. Ensure the workflow returns an AgentPlan from executorAgent() or resumeAgentFromTrace().');
2839
+ return;
2840
+ }
2841
+
2842
+ btn.disabled = true;
2843
+ btn.textContent = 'Resuming…';
2844
+
2845
+ try {
2846
+ const payload = {
2847
+ workflowName: selectedWorkflow ? selectedWorkflow.name : '',
2848
+ taskIndex: taskIndex,
2849
+ agentState: {
2850
+ plan: agentPlan,
2851
+ trace: [],
2852
+ resumeFromTaskIndex: taskIndex,
2853
+ },
2854
+ snapshotId: liveTrace.snapshotId,
2855
+ toolMockConfig: window._toolMockConfig || {},
2856
+ promptMockConfig: window._promptMockConfig || {},
2857
+ userPromptMockConfig: window._userPromptMockConfig || {},
2858
+ };
2859
+ const response = await fetch('/api/resume-agent-from-task', {
2860
+ method: 'POST',
2861
+ headers: { 'Content-Type': 'application/json' },
2862
+ body: JSON.stringify(payload),
2863
+ });
2864
+ const data = await response.json();
2865
+ if (response.ok) {
2866
+ // Sub-trace naming: "Trace-N-M" where N = parent name, M = resume count from parent
2867
+ const parentTraceName = liveTrace.traceName ?? \`Trace-\${traceIdx + 1}\`;
2868
+ const siblingCount = step5RunTraces.filter(t => t.parentTraceName === parentTraceName).length;
2869
+ const traceName = \`\${parentTraceName}-\${siblingCount + 1}\`;
2870
+ const newTrace = { ...data, runNumber: step5RunTraces.length + 1, traceName, parentTraceName };
2871
+ step5RunTraces.push(newTrace);
2872
+ persistTraces();
2873
+ renderObservationTable();
2874
+ } else {
2875
+ btn.textContent = '✗ ' + (data.error || 'Failed');
2876
+ btn.disabled = false;
2877
+ }
2878
+ } catch (err) {
2879
+ btn.textContent = '✗ Error';
2880
+ btn.disabled = false;
2881
+ }
2882
+ }
2883
+
2884
+ window.enableInputEditing = function(detailId, index) {
2885
+ const obs = currentObservations[index];
2886
+ const current = updatedInputs.has(index)
2887
+ ? updatedInputs.get(index)
2888
+ : toDisplayText(obs.input, obs.type);
2889
+ updatedInputs.set(index, current);
2890
+ renderObservationTable();
2891
+ };
2892
+
2893
+ window.resetInput = function(detailId, index) {
2894
+ updatedInputs.delete(index);
2895
+ renderObservationTable();
2896
+ };
2897
+
2898
+ window.saveUpdatedInput = function(index) {
2899
+ const ta = document.getElementById('editInputTextarea');
2900
+ if (!ta) return;
2901
+ updatedInputs.set(index, ta.value);
2902
+ renderObservationTable();
2903
+ };
2904
+
2905
+ function getObsFilePath(obs) {
2906
+ if (obs && obs.type === "TOOL") {
2907
+ const tool = codeIndex.tools.find(t => t.name === obs.name);
2908
+ if (tool) return tool.lineNumber ? tool.filePath + ':' + tool.lineNumber : tool.filePath;
2909
+ }
2910
+ return null;
2911
+ }
2912
+
2913
+ function extractSystemPrompt(obs) {
2914
+ if (!obs || obs.type !== "GENERATION") return null;
2915
+ let input = obs.input;
2916
+ // Parse input if it's a JSON string
2917
+ if (
2918
+ typeof input === 'string' &&
2919
+ (input.startsWith('{') || input.startsWith('[')) &&
2920
+ (input.endsWith('}') || input.endsWith(']'))
2921
+ ) {
2922
+ try {
2923
+ input = JSON.parse(input);
2924
+ } catch { /* not JSON */ }
2925
+ }
2926
+ const messages = Array.isArray(input) ? input
2927
+ : (input && Array.isArray(input.messages)) ? input.messages : [];
2928
+ const sys = messages.find(m => m && m.role === "system");
2929
+ if (!sys || !sys.content) return null;
2930
+ return String(sys.content).trim();
2931
+ }
2932
+
2933
+ function getSearchFragments(systemPrompt) {
2934
+ if (!systemPrompt || systemPrompt.length < 10) return [];
2935
+ const fragments = [];
2936
+
2937
+ // 1. First sentence (often unique: "You are an expert SQL generator...")
2938
+ const firstSentence = systemPrompt.split(/[.!?]\\n/)[0].trim();
2939
+ if (firstSentence.length >= 20 && firstSentence.length <= 100) {
2940
+ fragments.push(firstSentence);
2941
+ }
2942
+
2943
+ // 2. First 50 chars
2944
+ if (systemPrompt.length >= 40) {
2945
+ fragments.push(systemPrompt.slice(0, 50).trim());
2946
+ }
2947
+
2948
+ // 3. First 80 chars (if different from above)
2949
+ if (systemPrompt.length >= 80) {
2950
+ const fragment80 = systemPrompt.slice(0, 80).trim();
2951
+ if (!fragments.some(f => fragment80.startsWith(f))) {
2952
+ fragments.push(fragment80);
2953
+ }
2954
+ }
2955
+
2956
+ // 4. Try to find a unique phrase after common prefixes
2957
+ const afterYouAre = systemPrompt.match(/You are (?:an? )?([^.\\n]{15,60})/);
2958
+ if (afterYouAre && afterYouAre[1]) {
2959
+ fragments.push(afterYouAre[1].trim());
2960
+ }
2961
+
2962
+ return fragments.filter(f => f.length >= 15);
2963
+ }
2964
+
2965
+ async function resolveGenFilePath(obs, containerId) {
2966
+ const systemPrompt = extractSystemPrompt(obs);
2967
+ if (!systemPrompt) return;
2968
+
2969
+ const fragments = getSearchFragments(systemPrompt);
2970
+ if (fragments.length === 0) return;
2971
+
2972
+ try {
2973
+ // Try each fragment until we find a match
2974
+ for (const fragment of fragments) {
2975
+ const res = await fetch('/api/search-source?q=' + encodeURIComponent(fragment));
2976
+ const data = await res.json();
2977
+ if (data.filePath) {
2978
+ const label = data.lineNumber ? data.filePath + ':' + data.lineNumber : data.filePath;
2979
+ const container = document.getElementById(containerId);
2980
+ if (!container) return;
2981
+ const placeholder = container.querySelector('.file-path-placeholder');
2982
+ if (placeholder) placeholder.outerHTML = renderFilePath(label);
2983
+ return; // Success, stop trying
2984
+ }
2985
+ }
2986
+ } catch { /* ignore network errors */ }
2987
+ }
2988
+
2989
+ function stripAbsolutePath(filePath) {
2990
+ console.log('stripAbsolutePath called with:', filePath, 'repoRoot:', repoRoot);
2991
+ if (!filePath) return filePath;
2992
+
2993
+ // If we have a repo root, strip it from the absolute path
2994
+ if (repoRoot) {
2995
+ // Normalize path separators and handle case-insensitive filesystems
2996
+ let normalizedRoot = repoRoot.replace(/\\\\/g, '/').toLowerCase();
2997
+ let normalizedPath = filePath.replace(/\\\\/g, '/').toLowerCase();
2998
+
2999
+ // Ensure root ends with / for proper matching
3000
+ if (!normalizedRoot.endsWith('/')) {
3001
+ normalizedRoot += '/';
3002
+ }
3003
+
3004
+ if (normalizedPath.startsWith(normalizedRoot)) {
3005
+ // Return the original case of the file path after the root
3006
+ return filePath.replace(/\\\\/g, '/').substring(repoRoot.length).replace(/^\\//, '');
3007
+ }
3008
+
3009
+ // Also try without trailing slash
3010
+ normalizedRoot = normalizedRoot.slice(0, -1);
3011
+ if (normalizedPath.startsWith(normalizedRoot + '/')) {
3012
+ return filePath.replace(/\\\\/g, '/').substring(repoRoot.length).replace(/^\\//, '');
3013
+ }
3014
+ }
3015
+
3016
+ // Fallback if repo root not available or path doesn't match
3017
+ return filePath;
3018
+ }
3019
+
3020
+ function renderFilePath(filePath) {
3021
+ if (!filePath) return '';
3022
+ const cleanPath = stripAbsolutePath(filePath);
3023
+ return \`<div class="detail-section">
3024
+ <div class="detail-title">File Path</div>
3025
+ <pre class="detail-pre">\${esc(cleanPath)}</pre>
3026
+ </div>\`;
3027
+ }
3028
+
3029
+ function renderModel(obs) {
3030
+ if (!obs || obs.type !== "GENERATION" || !obs.model) return '';
3031
+ let label = obs.model;
3032
+ if (obs.modelParameters) {
3033
+ const parts = [];
3034
+ if (obs.modelParameters.temperature != null) parts.push('temp=' + obs.modelParameters.temperature);
3035
+ if (obs.modelParameters.max_tokens != null) parts.push('max_tokens=' + obs.modelParameters.max_tokens);
3036
+ if (parts.length) label += ' (' + parts.join(', ') + ')';
3037
+ }
3038
+ return \`<div class="detail-section">
3039
+ <div class="detail-title">Model</div>
3040
+ <pre class="detail-pre">\${esc(label)}</pre>
3041
+ </div>\`;
3042
+ }
3043
+
3044
+ function stripMarkdownCodeFence(text) {
3045
+ // Remove markdown code fences like \`\`\`sql, \`\`\`json, \`\`\`, etc.
3046
+ return text.replace(/^\`\`\`[\\w]*\\n?/gm, '').replace(/\\n?\`\`\`\$/gm, '').trim();
3047
+ }
3048
+
3049
+ function toDisplayText(value, type) {
3050
+ if (value === null || value === undefined || value === "") {
3051
+ return "No data";
3052
+ }
3053
+
3054
+ // If it's a string, try to parse it first (might be JSON)
3055
+ if (typeof value === "string") {
3056
+ if (value.startsWith('[') || value.startsWith('{')) {
3057
+ try {
3058
+ const parsed = JSON.parse(value);
3059
+ value = parsed; // Use parsed version for further processing
3060
+ } catch {
3061
+ // Not JSON, strip markdown and return
3062
+ return stripMarkdownCodeFence(value);
3063
+ }
3064
+ } else {
3065
+ return stripMarkdownCodeFence(value);
3066
+ }
3067
+ }
3068
+
3069
+ // GENERATION input: raw messages array
3070
+ if (type === "GENERATION" && Array.isArray(value)) {
3071
+ return JSON.stringify(value, null, 2);
3072
+ }
3073
+
3074
+ // GENERATION input wrapped in {messages:[...]} (legacy / Langfuse format)
3075
+ if (type === "GENERATION" && value.messages) {
3076
+ return JSON.stringify(value.messages, null, 2);
3077
+ }
3078
+
3079
+ // GENERATION output: assistant message object
3080
+ if (type === "GENERATION" && value.role) {
3081
+ const parts = [];
3082
+ // Text content
3083
+ if (value.content && typeof value.content === 'string') {
3084
+ parts.push(stripMarkdownCodeFence(value.content));
3085
+ } else if (Array.isArray(value.content)) {
3086
+ // Anthropic / Gemini content block array
3087
+ const text = value.content
3088
+ .filter(b => b && (b.type === 'text' || b.text))
3089
+ .map(b => b.text || b.value || '')
3090
+ .join('');
3091
+ if (text) parts.push(stripMarkdownCodeFence(text));
3092
+ // Anthropic tool_use blocks
3093
+ const toolUses = value.content.filter(b => b && b.type === 'tool_use');
3094
+ if (toolUses.length) parts.push('Tool calls:\\n' + JSON.stringify(toolUses, null, 2));
3095
+ } else if (value.parts && Array.isArray(value.parts)) {
3096
+ // Gemini parts array
3097
+ const text = value.parts.filter(p => p.text).map(p => p.text).join('');
3098
+ if (text) parts.push(stripMarkdownCodeFence(text));
3099
+ const fnCalls = value.parts.filter(p => p.functionCall);
3100
+ if (fnCalls.length) parts.push('Function calls:\\n' + JSON.stringify(fnCalls, null, 2));
3101
+ }
3102
+ // OpenAI tool_calls array
3103
+ if (Array.isArray(value.tool_calls) && value.tool_calls.length) {
3104
+ parts.push('Tool calls:\\n' + JSON.stringify(value.tool_calls, null, 2));
3105
+ }
3106
+ return parts.length ? parts.join('\\n\\n') : 'No content';
3107
+ }
3108
+
3109
+ // For other objects, stringify them
3110
+ try {
3111
+ return JSON.stringify(value, null, 2);
3112
+ } catch {
3113
+ return String(value);
3114
+ }
3115
+ }
3116
+
3117
+ function resetTraceModal() {
3118
+ uploadArea.classList.remove("hidden");
3119
+ traceViewer.classList.remove("visible");
3120
+ let customFooter = document.getElementById("step5FooterBtns");
3121
+ if (customFooter) customFooter.remove();
3122
+ uploadStatus.className = "upload-status";
3123
+ uploadStatus.textContent = "";
3124
+ fileInput.value = "";
3125
+ currentObservations = [];
3126
+ selectedObservationIndex = -1;
3127
+ checkedObservations.clear();
3128
+ rerunHistory.clear();
3129
+ rerunInFlight.clear();
3130
+ step4SelectedRun = -1;
3131
+ step5RunTraces = [];
3132
+ localStorage.removeItem('ed_step5RunTraces');
3133
+ step5RunMeta = { loading: false, error: '', runCount: 0, sequential: false };
3134
+ observationTableBody.innerHTML = "";
3135
+ observationDetail.innerHTML = "";
3136
+ currentStep = 0;
3137
+ updateModalTitle();
3138
+ }
3139
+
3140
+ function updateModalTitle() {
3141
+ const titles = {
3142
+ 0: "Step 2: Import Failed Trace",
3143
+ 3: "Step 3: Mark broken step",
3144
+ 4: "Step 4: Validate your Fixes",
3145
+ 5: "Step 5: Validate Updated Flow with Live Data"
3146
+ };
3147
+ modalTitle.textContent = titles[currentStep] || "Step 2: Import Failed Trace";
3148
+ }
3149
+
3150
+ function updateFooterButtons() {
3151
+ const changeBtn = document.getElementById("changeTraceBtn");
3152
+ const nextBtn = document.getElementById("nextBtn");
3153
+
3154
+ if (currentStep <= 2) {
3155
+ changeBtn.textContent = "Change Workflow Function";
3156
+ nextBtn.textContent = "Next";
3157
+ nextBtn.disabled = true;
3158
+ } else if (currentStep === 3) {
3159
+ changeBtn.textContent = "Change Trace File";
3160
+ nextBtn.textContent = "Next";
3161
+ nextBtn.disabled = false;
3162
+ } else if (currentStep === 4) {
3163
+ changeBtn.textContent = "Select Different Steps";
3164
+ nextBtn.textContent = "Fix Works as Expected";
3165
+ nextBtn.disabled = false;
3166
+ } else if (currentStep === 5) {
3167
+ // Step 5 - show custom buttons
3168
+ changeBtn.textContent = "Still Failing";
3169
+ nextBtn.textContent = "Done";
3170
+ } else {
3171
+ let customFooter = document.getElementById("step5FooterBtns");
3172
+ if (customFooter) customFooter.remove();
3173
+ }
3174
+ }
3175
+
3176
+ fetch("/api/repo-root").then(r => r.json()).then(d => {
3177
+ console.log("[Dashboard] Repo root fetched:", d);
3178
+ repoRoot = d.repoRoot || '';
3179
+ console.log("[Dashboard] repoRoot set to:", repoRoot);
3180
+
3181
+ // Now fetch workflows after repo root is loaded
3182
+ return fetch("/api/workflows");
3183
+ }).then(r => r.json()).then(d => {
3184
+ console.log("[Dashboard] Workflows fetched:", d);
3185
+ allWorkflows = d.workflows || [];
3186
+ console.log("[Dashboard] Calling render with", allWorkflows.length, "workflows");
3187
+ render();
3188
+ }).catch(err => {
3189
+ console.error("Failed to fetch repo root or workflows:", err);
3190
+ tbody.innerHTML = '<tr><td colspan="2" style="text-align: center; padding: 40px; color: #c62828;">Error loading workflows</td></tr>';
3191
+ });
3192
+
3193
+ fetch("/api/code-index").then(r => r.json()).then(d => {
3194
+ codeIndex = d;
3195
+ console.log("Code index:", d);
3196
+ }).catch(err => {
3197
+ console.error("Failed to fetch code index:", err);
3198
+ });
3199
+
3200
+ function render(search = "") {
3201
+ console.log("[Dashboard] render() called with search:", search, "workflows:", allWorkflows.length);
3202
+ const filtered = search ? allWorkflows.filter(w =>
3203
+ w.name.toLowerCase().includes(search.toLowerCase()) ||
3204
+ w.filePath.toLowerCase().includes(search.toLowerCase())
3205
+ ) : allWorkflows;
3206
+ countEl.textContent = filtered.length;
3207
+ console.log("[Dashboard] Rendering", filtered.length, "workflows");
3208
+ tbody.innerHTML = filtered.length ? filtered.map((w, i) => \`<tr onclick="showModal(\${i},'\${search}')">
3209
+ <td><div class="workflow-name-cell">\${esc(w.name)}\${w.isAsync ? '<span class="async-badge">async</span>' : ""}</div></td>
3210
+ <td><div class="workflow-path-cell">\${esc(stripAbsolutePath(w.filePath))}</div></td>
3211
+ </tr>\`).join("") : \`<tr><td colspan="2" style="text-align: center; padding: 40px; color: #999;">No workflows found</td></tr>\`;
3212
+ console.log("[Dashboard] tbody updated");
3213
+ }
3214
+
3215
+ function showModal(index, search) {
3216
+ const filtered = search ? allWorkflows.filter(w =>
3217
+ w.name.toLowerCase().includes(search.toLowerCase()) ||
3218
+ w.filePath.toLowerCase().includes(search.toLowerCase())
3219
+ ) : allWorkflows;
3220
+ selectedWorkflow = filtered[index];
3221
+ modal.classList.add("open");
3222
+ resetTraceModal();
3223
+ updateFooterButtons();
3224
+ }
3225
+
3226
+ window.showModal = showModal;
3227
+ window.selectObservation = selectObservation;
3228
+ window.step4SelectObservation = function(idx) {
3229
+ selectedObservationIndex = idx;
3230
+ const history = rerunHistory.get(idx) || [];
3231
+ step4SelectedRun = history.length > 0 ? history.length - 1 : -1;
3232
+ renderObservationTable();
3233
+ };
3234
+ window.step4SelectRun = function(ri) {
3235
+ step4SelectedRun = ri;
3236
+ renderObservationTable();
3237
+ };
3238
+ function esc(t) { const d = document.createElement("div"); d.textContent = t; return d.innerHTML; }
3239
+ window.esc = esc;
3240
+
3241
+ document.getElementById("searchInput").oninput = (e) => render(e.target.value);
3242
+ </script>
3243
+ </body>
3244
+ </html>`;
3245
+ /* DASHBOARD_HTML_END */
3246
+ }
3247
+ const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.turbo', 'build', 'coverage']);
3248
+ const SEARCH_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx']);
3249
+ /**
3250
+ * Normalize text for fuzzy searching: collapse whitespace, handle common variations
3251
+ */
3252
+ function normalizeForSearch(text) {
3253
+ return text
3254
+ .replace(/\s+/g, ' ') // Collapse whitespace
3255
+ .replace(/["'`]/g, '') // Remove quotes that might differ
3256
+ .trim()
3257
+ .toLowerCase();
3258
+ }
3259
+ /**
3260
+ * Walk the project tree and find the first file+line containing `query`.
3261
+ * Returns { filePath, lineNumber } or null.
3262
+ * Now supports fuzzy matching with normalized text.
3263
+ */
3264
+ function searchInFiles(dir, query) {
3265
+ let entries;
3266
+ try {
3267
+ entries = readdirSync(dir);
3268
+ }
3269
+ catch {
3270
+ return null;
3271
+ }
3272
+ const normalizedQuery = normalizeForSearch(query);
3273
+ const exactQuery = query.trim();
3274
+ for (const entry of entries) {
3275
+ if (SEARCH_SKIP_DIRS.has(entry))
3276
+ continue;
3277
+ const full = path.join(dir, entry);
3278
+ let stat;
3279
+ try {
3280
+ stat = statSync(full);
3281
+ }
3282
+ catch {
3283
+ continue;
3284
+ }
3285
+ if (stat.isDirectory()) {
3286
+ const result = searchInFiles(full, query);
3287
+ if (result)
3288
+ return result;
3289
+ }
3290
+ else if (SEARCH_EXTS.has(path.extname(entry))) {
3291
+ try {
3292
+ const content = readFileSync(full, 'utf8');
3293
+ const lines = content.split('\n');
3294
+ // Try exact match first (faster)
3295
+ for (let i = 0; i < lines.length; i++) {
3296
+ if (lines[i].includes(exactQuery)) {
3297
+ return { filePath: full, lineNumber: i + 1 };
3298
+ }
3299
+ }
3300
+ // Try normalized/fuzzy match
3301
+ const normalizedContent = normalizeForSearch(content);
3302
+ if (normalizedContent.includes(normalizedQuery)) {
3303
+ // Find which line it's on
3304
+ let charCount = 0;
3305
+ for (let i = 0; i < lines.length; i++) {
3306
+ const normalizedLine = normalizeForSearch(lines[i]);
3307
+ if (normalizedLine.includes(normalizedQuery)) {
3308
+ return { filePath: full, lineNumber: i + 1 };
3309
+ }
3310
+ charCount += lines[i].length + 1; // +1 for newline
3311
+ }
3312
+ }
3313
+ }
3314
+ catch { /* skip unreadable files */ }
3315
+ }
3316
+ }
3317
+ return null;
3318
+ }
3319
+ /** Load elasticdash.config.ts via a tsx-enabled subprocess and return the parsed object. */
3320
+ async function loadElasticDashConfig(cwd) {
3321
+ const configPath = resolveRuntimeModule(cwd, 'elasticdash.config');
3322
+ if (!configPath)
3323
+ return {};
3324
+ return new Promise((resolve) => {
3325
+ const nodeOptions = process.env.NODE_OPTIONS ?? '';
3326
+ const tsxFlag = '--import tsx';
3327
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim();
3328
+ const childEnv = { ...process.env, NODE_OPTIONS: isDenoProject(cwd) ? nodeOptions : childNodeOptions };
3329
+ const configUrl = pathToFileURL(configPath).href;
3330
+ const child = spawn(process.execPath, ['--input-type=module'], {
3331
+ env: childEnv,
3332
+ cwd,
3333
+ stdio: ['pipe', 'pipe', 'ignore'],
3334
+ });
3335
+ let output = '';
3336
+ child.stdout.on('data', (chunk) => { output += chunk.toString(); });
3337
+ child.on('close', () => {
3338
+ try {
3339
+ resolve(JSON.parse(output));
3340
+ }
3341
+ catch {
3342
+ resolve({});
3343
+ }
3344
+ });
3345
+ child.on('error', () => resolve({}));
3346
+ child.stdin.write(`import m from '${configUrl}'; process.stdout.write(JSON.stringify(m.default ?? m))`);
3347
+ child.stdin.end();
3348
+ });
3349
+ }
3350
+ /** Resolve {{env.VAR}} and {{input.field}} placeholders in a value. */
3351
+ function resolveTemplateValue(value, input) {
3352
+ if (typeof value === 'string') {
3353
+ // If the entire string is a single placeholder, return the raw value so arrays/objects
3354
+ // are not coerced to strings (e.g. messages: "{{input.messages}}" stays an array).
3355
+ const exactInput = value.match(/^\{\{input\.([^}]+)\}\}$/);
3356
+ if (exactInput)
3357
+ return input[exactInput[1]] ?? '';
3358
+ const exactEnv = value.match(/^\{\{env\.([^}]+)\}\}$/);
3359
+ if (exactEnv)
3360
+ return process.env[exactEnv[1]] ?? '';
3361
+ // Interpolated placeholder — coerce to string for embedding within a larger string
3362
+ return value.replace(/\{\{env\.([^}]+)\}\}/g, (_, k) => process.env[k] ?? '')
3363
+ .replace(/\{\{input\.([^}]+)\}\}/g, (_, k) => String(input[k] ?? ''))
3364
+ .replace(/\{\{timestamp\}\}/g, () => String(Date.now()));
3365
+ }
3366
+ if (Array.isArray(value))
3367
+ return value.map(v => resolveTemplateValue(v, input));
3368
+ if (value && typeof value === 'object') {
3369
+ const out = {};
3370
+ for (const [k, v] of Object.entries(value)) {
3371
+ out[k] = resolveTemplateValue(v, input);
3372
+ }
3373
+ return out;
3374
+ }
3375
+ return value;
3376
+ }
3377
+ async function runHttpWorkflow(opts) {
3378
+ const { workflowName, workflowInput, frozenEvents = [], promptMocks = {}, userPromptMocks, toolMockConfig, aiMockConfig, pushedEvents, runConfigs, config, dashboardPort } = opts;
3379
+ const runId = randomUUID();
3380
+ // Register run config so the user's server can fetch frozen events, prompt mocks, and output mocks
3381
+ pushedEvents.set(runId, []);
3382
+ runConfigs.set(runId, { frozenEvents, promptMocks, userPromptMocks, toolMockConfig, aiMockConfig });
3383
+ try {
3384
+ const parsedInput = parseObservationInput(workflowInput);
3385
+ const inputObj = parsedInput && typeof parsedInput === 'object' && !Array.isArray(parsedInput) ? parsedInput : {};
3386
+ const method = config.method ?? 'POST';
3387
+ const resolvedHeaders = {};
3388
+ for (const [k, v] of Object.entries(config.headers ?? {})) {
3389
+ resolvedHeaders[k] = resolveTemplateValue(v, inputObj);
3390
+ }
3391
+ resolvedHeaders['x-elasticdash-run-id'] = runId;
3392
+ resolvedHeaders['x-elasticdash-server'] = `http://localhost:${dashboardPort}`;
3393
+ const body = config.bodyTemplate
3394
+ ? JSON.stringify(resolveTemplateValue(config.bodyTemplate, inputObj))
3395
+ : undefined;
3396
+ if (body && !resolvedHeaders['Content-Type']) {
3397
+ resolvedHeaders['Content-Type'] = 'application/json';
3398
+ }
3399
+ console.log(`[elasticdash] HTTP workflow "${workflowName}" → ${method} ${config.url} (runId=${runId})`);
3400
+ const response = await fetch(config.url, { method, headers: resolvedHeaders, body });
3401
+ let currentOutput;
3402
+ if (config.responseFormat === 'vercel-ai-stream' && response.body) {
3403
+ const decoder = new TextDecoder();
3404
+ const reader = response.body.getReader();
3405
+ let text = '';
3406
+ for (;;) {
3407
+ const { done, value } = await reader.read();
3408
+ if (done)
3409
+ break;
3410
+ text += decoder.decode(value, { stream: true });
3411
+ }
3412
+ // Extract final text from Vercel AI stream data lines
3413
+ let finalText = '';
3414
+ for (const line of text.split('\n')) {
3415
+ if (!line.startsWith('0:'))
3416
+ continue;
3417
+ try {
3418
+ finalText += JSON.parse(line.slice(2));
3419
+ }
3420
+ catch { /* skip */ }
3421
+ }
3422
+ currentOutput = finalText || text;
3423
+ }
3424
+ else {
3425
+ try {
3426
+ currentOutput = await response.clone().json();
3427
+ }
3428
+ catch {
3429
+ currentOutput = await response.text();
3430
+ }
3431
+ }
3432
+ if (!response.ok) {
3433
+ return { ok: false, error: `HTTP ${response.status}: ${response.statusText}`, currentOutput };
3434
+ }
3435
+ // Drain window: wait for in-flight push POSTs to arrive
3436
+ const drainMs = parseInt(process.env.ELASTICDASH_HTTP_DRAIN_MS ?? '300', 10);
3437
+ await new Promise(resolve => setTimeout(resolve, drainMs));
3438
+ const events = (pushedEvents.get(runId) ?? []).sort((a, b) => a.timestamp - b.timestamp);
3439
+ console.log(`[elasticdash] runHttpWorkflow drain complete: ${events.length} events collected for runId=${runId}`);
3440
+ const workflowTrace = { traceId: runId, events };
3441
+ return { ok: true, currentOutput, workflowTrace, steps: [], llmSteps: [], toolCalls: [], customSteps: [] };
3442
+ }
3443
+ catch (error) {
3444
+ return { ok: false, error: `HTTP workflow failed: ${formatError(error)}` };
3445
+ }
3446
+ finally {
3447
+ pushedEvents.delete(runId);
3448
+ runConfigs.delete(runId);
3449
+ }
3450
+ }
3451
+ /**
3452
+ * Start the dashboard server
3453
+ */
3454
+ export async function startDashboardServer(cwd, options = {}) {
3455
+ const port = options.port ?? 4573;
3456
+ const autoOpen = options.autoOpen ?? true;
3457
+ // In-memory store for telemetry events pushed from HTTP workflow mode runs.
3458
+ // Maps runId -> accumulated WorkflowEvent[]
3459
+ const pushedEvents = new Map();
3460
+ // Per-run config for HTTP workflow mode (frozen events + prompt mocks for replay).
3461
+ // Maps runId -> { frozenEvents, promptMocks }
3462
+ const runConfigs = new Map();
3463
+ // Scan workflows, tools, and config once at startup
3464
+ const workflows = scanWorkflows(cwd);
3465
+ const tools = scanTools(cwd);
3466
+ const codeIndex = { workflows, tools };
3467
+ const elasticdashConfig = await loadElasticDashConfig(cwd);
3468
+ console.log(`[elasticdash] Scanned: ${workflows.length} workflows, ${tools.length} tools`);
3469
+ // Create HTTP server
3470
+ const server = http.createServer((req, res) => {
3471
+ // Disable socket inactivity timeout — workflow runs can take arbitrarily long
3472
+ req.socket.setTimeout(0);
3473
+ const url = new URL(req.url || '/', `http://${req.headers.host}`);
3474
+ if (url.pathname === '/api/workflows') {
3475
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3476
+ res.end(JSON.stringify({ workflows }));
3477
+ }
3478
+ else if (url.pathname === '/api/repo-root') {
3479
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3480
+ res.end(JSON.stringify({ repoRoot: cwd }));
3481
+ }
3482
+ else if (url.pathname === '/api/code-index') {
3483
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3484
+ res.end(JSON.stringify(codeIndex));
3485
+ }
3486
+ else if (url.pathname === '/api/search-source') {
3487
+ const q = url.searchParams.get('q') || '';
3488
+ const result = q.length >= 8 ? searchInFiles(cwd, q) : null;
3489
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3490
+ res.end(JSON.stringify(result ?? {}));
3491
+ }
3492
+ else if (url.pathname === '/api/rerun-observation' && req.method === 'POST') {
3493
+ ;
3494
+ (async () => {
3495
+ try {
3496
+ const body = (await readJsonBody(req));
3497
+ if (!body?.observation || typeof body.observation !== 'object') {
3498
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3499
+ res.end(JSON.stringify({ ok: false, error: 'Request must include an observation object.' }));
3500
+ return;
3501
+ }
3502
+ const result = await rerunObservation(cwd, body.observation, tools);
3503
+ const statusCode = result.ok ? 200 : 400;
3504
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
3505
+ res.end(JSON.stringify(result));
3506
+ }
3507
+ catch (error) {
3508
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3509
+ res.end(JSON.stringify({ ok: false, error: formatError(error) }));
3510
+ }
3511
+ })();
3512
+ }
3513
+ else if (url.pathname === '/api/validate-workflow' && req.method === 'POST') {
3514
+ ;
3515
+ (async () => {
3516
+ try {
3517
+ const body = (await readJsonBody(req));
3518
+ const workflowName = typeof body.workflowName === 'string' ? body.workflowName.trim() : '';
3519
+ const httpConfig = elasticdashConfig.workflows?.[workflowName];
3520
+ if (httpConfig?.mode === 'http') {
3521
+ // HTTP workflow mode — call user's dev server instead of subprocess
3522
+ const runCount = typeof body.runCount === 'number' ? Math.max(1, Math.min(50, body.runCount)) : 1;
3523
+ const sequential = body.sequential === true;
3524
+ const resolvedInput = resolveWorkflowArgsFromObservations(body, workflowName);
3525
+ const workflowInput = resolvedInput.input ?? null;
3526
+ const traces = [];
3527
+ const promptMocks = flattenPromptMockConfig(body.promptMockConfig);
3528
+ const valUserPromptMocks = body.userPromptMockConfig && typeof body.userPromptMockConfig === 'object' && !Array.isArray(body.userPromptMockConfig)
3529
+ ? body.userPromptMockConfig
3530
+ : undefined;
3531
+ const valToolMockConfig = body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
3532
+ ? body.toolMockConfig
3533
+ : undefined;
3534
+ const valAiMockConfig = body.aiMockConfig && typeof body.aiMockConfig === 'object' && !Array.isArray(body.aiMockConfig)
3535
+ ? body.aiMockConfig
3536
+ : undefined;
3537
+ const runOne = async (runNumber) => {
3538
+ const result = await runHttpWorkflow({
3539
+ workflowName, workflowInput, pushedEvents, runConfigs,
3540
+ config: httpConfig, dashboardPort: port,
3541
+ promptMocks, userPromptMocks: valUserPromptMocks, toolMockConfig: valToolMockConfig, aiMockConfig: valAiMockConfig,
3542
+ });
3543
+ const traceStub = { getSteps: () => [], getLLMSteps: () => [], getToolCalls: () => [], getCustomSteps: () => [], recordLLMStep: () => { }, recordToolCall: () => { }, recordCustomStep: () => { } };
3544
+ return {
3545
+ runNumber, ok: result.ok, error: result.error,
3546
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, result.error, traceStub, result.workflowTrace),
3547
+ workflowTrace: result.workflowTrace,
3548
+ currentOutput: result.currentOutput,
3549
+ snapshotId: result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined,
3550
+ };
3551
+ };
3552
+ if (sequential) {
3553
+ for (let i = 1; i <= runCount; i++)
3554
+ traces.push(await runOne(i));
3555
+ }
3556
+ else {
3557
+ traces.push(...await Promise.all(Array.from({ length: runCount }, (_, i) => runOne(i + 1))));
3558
+ }
3559
+ const ok = traces.some(t => t.ok);
3560
+ res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
3561
+ res.end(JSON.stringify({ ok, mode: sequential ? 'sequential' : 'parallel', runCount, traces }));
3562
+ return;
3563
+ }
3564
+ const result = await validateWorkflowRuns(cwd, body);
3565
+ const statusCode = result.ok ? 200 : 400;
3566
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
3567
+ res.end(JSON.stringify(result));
3568
+ }
3569
+ catch (error) {
3570
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3571
+ res.end(JSON.stringify({ ok: false, error: formatError(error) }));
3572
+ }
3573
+ })();
3574
+ }
3575
+ else if (url.pathname === '/api/run-from-breakpoint' && req.method === 'POST') {
3576
+ ;
3577
+ (async () => {
3578
+ try {
3579
+ const body = (await readJsonBody(req));
3580
+ const workflowName = typeof body.workflowName === 'string' ? body.workflowName.trim() : '';
3581
+ if (!workflowName) {
3582
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3583
+ res.end(JSON.stringify({ ok: false, error: 'workflowName is required.' }));
3584
+ return;
3585
+ }
3586
+ const checkpoint = typeof body.checkpoint === 'number' ? body.checkpoint : 0;
3587
+ let history;
3588
+ if (typeof body.snapshotId === 'string') {
3589
+ const snap = loadSnapshot(cwd, body.snapshotId);
3590
+ history = snap ? snap.events : [];
3591
+ }
3592
+ else {
3593
+ history = Array.isArray(body.history) ? body.history : [];
3594
+ }
3595
+ const validationBody = { workflowName, observations: body.observations };
3596
+ const resolvedInput = resolveWorkflowArgsFromObservations(validationBody, workflowName);
3597
+ if (resolvedInput.error) {
3598
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3599
+ res.end(JSON.stringify({ ok: false, error: resolvedInput.error }));
3600
+ return;
3601
+ }
3602
+ const workflowInput = resolvedInput.input ?? null;
3603
+ const frozenEvents = history.filter((event) => (event.id <= checkpoint
3604
+ && (event.type === 'ai' || event.type === 'tool' || event.type === 'http' || event.type === 'db')));
3605
+ const frozenEventIds = new Set(frozenEvents.map((e) => e.id));
3606
+ const httpConfig = elasticdashConfig.workflows?.[workflowName];
3607
+ if (httpConfig?.mode === 'http') {
3608
+ // HTTP workflow mode — call user's dev server with frozen events + prompt mocks for step replay
3609
+ const bpPromptMocks = flattenPromptMockConfig(body.promptMockConfig);
3610
+ const bpUserPromptMocks = body.userPromptMockConfig && typeof body.userPromptMockConfig === 'object' && !Array.isArray(body.userPromptMockConfig)
3611
+ ? body.userPromptMockConfig
3612
+ : undefined;
3613
+ const bpToolMockConfig = body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
3614
+ ? body.toolMockConfig
3615
+ : undefined;
3616
+ const bpAiMockConfig = body.aiMockConfig && typeof body.aiMockConfig === 'object' && !Array.isArray(body.aiMockConfig)
3617
+ ? body.aiMockConfig
3618
+ : undefined;
3619
+ console.log(`[elasticdash] Run from breakpoint (HTTP mode): workflow="${workflowName}" checkpoint=${checkpoint} frozen=${frozenEvents.length}`);
3620
+ const result = await runHttpWorkflow({
3621
+ workflowName, workflowInput, pushedEvents, runConfigs,
3622
+ config: httpConfig, dashboardPort: port,
3623
+ frozenEvents, promptMocks: bpPromptMocks, userPromptMocks: bpUserPromptMocks,
3624
+ toolMockConfig: bpToolMockConfig, aiMockConfig: bpAiMockConfig,
3625
+ });
3626
+ const traceStub = { getSteps: () => [], getLLMSteps: () => [], getToolCalls: () => [], getCustomSteps: () => [], recordLLMStep: () => { }, recordToolCall: () => { }, recordCustomStep: () => { } };
3627
+ const snapshotId = result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined;
3628
+ const trace = {
3629
+ runNumber: 0,
3630
+ ok: result.ok,
3631
+ error: result.ok ? undefined : result.error,
3632
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, result.ok ? undefined : result.error, traceStub, result.workflowTrace, frozenEventIds),
3633
+ workflowTrace: result.workflowTrace,
3634
+ currentOutput: result.currentOutput,
3635
+ snapshotId,
3636
+ };
3637
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3638
+ res.end(JSON.stringify(trace));
3639
+ return;
3640
+ }
3641
+ const workflowsModulePath = resolveWorkflowModule(cwd);
3642
+ if (!workflowsModulePath) {
3643
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3644
+ res.end(JSON.stringify({ ok: false, error: 'Cannot find ed_workflows.ts/js in workspace root.' }));
3645
+ return;
3646
+ }
3647
+ const workflowArgs = resolvedInput.args ?? [];
3648
+ const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools') ?? null;
3649
+ const toolMockConfig = body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
3650
+ ? body.toolMockConfig
3651
+ : undefined;
3652
+ const aiMockConfig = body.aiMockConfig && typeof body.aiMockConfig === 'object' && !Array.isArray(body.aiMockConfig)
3653
+ ? body.aiMockConfig
3654
+ : undefined;
3655
+ const promptMockConfig = body.promptMockConfig && typeof body.promptMockConfig === 'object' && !Array.isArray(body.promptMockConfig)
3656
+ ? body.promptMockConfig
3657
+ : undefined;
3658
+ const userPromptMockConfig = body.userPromptMockConfig && typeof body.userPromptMockConfig === 'object' && !Array.isArray(body.userPromptMockConfig)
3659
+ ? body.userPromptMockConfig
3660
+ : undefined;
3661
+ console.log(`[elasticdash] Run from breakpoint: workflow="${workflowName}" checkpoint=${checkpoint} historyLen=${history.length}`);
3662
+ const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, workflowArgs, workflowInput, { replayMode: true, checkpoint, history, ...(toolMockConfig ? { toolMockConfig } : {}), ...(aiMockConfig ? { aiMockConfig } : {}), ...(promptMockConfig ? { promptMockConfig } : {}), ...(userPromptMockConfig ? { userPromptMockConfig } : {}) });
3663
+ const traceStub = {
3664
+ getSteps: () => (result.steps ?? []),
3665
+ getLLMSteps: () => (result.llmSteps ?? []),
3666
+ getToolCalls: () => (result.toolCalls ?? []),
3667
+ getCustomSteps: () => (result.customSteps ?? []),
3668
+ recordLLMStep: () => { },
3669
+ recordToolCall: () => { },
3670
+ recordCustomStep: () => { },
3671
+ };
3672
+ const snapshotId = result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined;
3673
+ const trace = {
3674
+ runNumber: 0,
3675
+ ok: result.ok,
3676
+ error: result.ok ? undefined : result.error,
3677
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, result.ok ? undefined : result.error, traceStub, result.workflowTrace, frozenEventIds),
3678
+ workflowTrace: result.workflowTrace,
3679
+ currentOutput: result.currentOutput,
3680
+ snapshotId,
3681
+ };
3682
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3683
+ res.end(JSON.stringify(trace));
3684
+ }
3685
+ catch (error) {
3686
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3687
+ res.end(JSON.stringify({ ok: false, error: formatError(error) }));
3688
+ }
3689
+ })();
3690
+ }
3691
+ else if (url.pathname === '/api/resume-agent-from-task' && req.method === 'POST') {
3692
+ ;
3693
+ (async () => {
3694
+ try {
3695
+ const body = (await readJsonBody(req));
3696
+ const workflowName = typeof body.workflowName === 'string' ? body.workflowName.trim() : '';
3697
+ if (!workflowName) {
3698
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3699
+ res.end(JSON.stringify({ ok: false, error: 'workflowName is required.' }));
3700
+ return;
3701
+ }
3702
+ const taskIndex = typeof body.taskIndex === 'number' ? body.taskIndex : 0;
3703
+ let history;
3704
+ if (typeof body.snapshotId === 'string') {
3705
+ const snap = loadSnapshot(cwd, body.snapshotId);
3706
+ history = snap ? snap.events : [];
3707
+ }
3708
+ else {
3709
+ history = Array.isArray(body.history) ? body.history : [];
3710
+ }
3711
+ if (!body.agentState || typeof body.agentState !== 'object') {
3712
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3713
+ res.end(JSON.stringify({ ok: false, error: 'agentState is required.' }));
3714
+ return;
3715
+ }
3716
+ // Reconstruct AgentState: override resumeFromTaskIndex with the requested taskIndex
3717
+ const incomingState = body.agentState;
3718
+ const agentState = {
3719
+ plan: (incomingState.plan ?? incomingState),
3720
+ trace: incomingState.trace ?? history,
3721
+ resumeFromTaskIndex: taskIndex,
3722
+ };
3723
+ const workflowsModulePath = resolveWorkflowModule(cwd);
3724
+ if (!workflowsModulePath) {
3725
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3726
+ res.end(JSON.stringify({ ok: false, error: 'Cannot find ed_workflows.ts/js in workspace root.' }));
3727
+ return;
3728
+ }
3729
+ const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools') ?? null;
3730
+ const toolMockConfig = body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
3731
+ ? body.toolMockConfig
3732
+ : undefined;
3733
+ const aiMockConfig = body.aiMockConfig && typeof body.aiMockConfig === 'object' && !Array.isArray(body.aiMockConfig)
3734
+ ? body.aiMockConfig
3735
+ : undefined;
3736
+ const promptMockConfig = body.promptMockConfig && typeof body.promptMockConfig === 'object' && !Array.isArray(body.promptMockConfig)
3737
+ ? body.promptMockConfig
3738
+ : undefined;
3739
+ const userPromptMockConfig = body.userPromptMockConfig && typeof body.userPromptMockConfig === 'object' && !Array.isArray(body.userPromptMockConfig)
3740
+ ? body.userPromptMockConfig
3741
+ : undefined;
3742
+ console.log(`[elasticdash] Resume agent from task: workflow="${workflowName}" taskIndex=${taskIndex}`);
3743
+ const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, [], null, { replayMode: history.length > 0, checkpoint: 0, history, agentState, ...(toolMockConfig ? { toolMockConfig } : {}), ...(aiMockConfig ? { aiMockConfig } : {}), ...(promptMockConfig ? { promptMockConfig } : {}), ...(userPromptMockConfig ? { userPromptMockConfig } : {}) });
3744
+ const traceStub = {
3745
+ getSteps: () => (result.steps ?? []),
3746
+ getLLMSteps: () => (result.llmSteps ?? []),
3747
+ getToolCalls: () => (result.toolCalls ?? []),
3748
+ getCustomSteps: () => (result.customSteps ?? []),
3749
+ recordLLMStep: () => { },
3750
+ recordToolCall: () => { },
3751
+ recordCustomStep: () => { },
3752
+ };
3753
+ const snapshotId = result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined;
3754
+ const trace = {
3755
+ runNumber: 0,
3756
+ ok: result.ok,
3757
+ error: result.ok ? undefined : result.error,
3758
+ observations: buildValidationObservations(workflowName, null, result.currentOutput, result.ok ? undefined : result.error, traceStub, result.workflowTrace),
3759
+ workflowTrace: result.workflowTrace,
3760
+ currentOutput: result.currentOutput,
3761
+ snapshotId,
3762
+ };
3763
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3764
+ res.end(JSON.stringify(trace));
3765
+ }
3766
+ catch (error) {
3767
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3768
+ res.end(JSON.stringify({ ok: false, error: formatError(error) }));
3769
+ }
3770
+ })();
3771
+ }
3772
+ else if (url.pathname === '/api/snapshots' && req.method === 'GET') {
3773
+ const id = url.searchParams.get('id');
3774
+ if (!id) {
3775
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3776
+ res.end(JSON.stringify({ ok: false, error: 'id is required.' }));
3777
+ }
3778
+ else {
3779
+ const snap = loadSnapshot(cwd, id);
3780
+ if (!snap) {
3781
+ res.writeHead(404, { 'Content-Type': 'application/json' });
3782
+ res.end(JSON.stringify({ ok: false, error: 'Snapshot not found.' }));
3783
+ }
3784
+ else {
3785
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3786
+ res.end(JSON.stringify(snap));
3787
+ }
3788
+ }
3789
+ }
3790
+ else if (url.pathname.startsWith('/api/run-configs/') && req.method === 'GET') {
3791
+ const runId = url.pathname.slice('/api/run-configs/'.length);
3792
+ const cfg = runConfigs.get(runId);
3793
+ res.writeHead(cfg ? 200 : 404, { 'Content-Type': 'application/json' });
3794
+ 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 } : {}) }));
3795
+ }
3796
+ else if (url.pathname === '/api/trace-events' && req.method === 'POST') {
3797
+ // Receive telemetry events pushed from wrapAI / wrapTool in HTTP workflow mode
3798
+ ;
3799
+ (async () => {
3800
+ try {
3801
+ const body = (await readJsonBody(req));
3802
+ if (typeof body.runId !== 'string' || !body.runId || !body.event || typeof body.event !== 'object') {
3803
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3804
+ res.end(JSON.stringify({ ok: false, error: 'runId (string) and event (object) are required.' }));
3805
+ return;
3806
+ }
3807
+ const existing = pushedEvents.get(body.runId);
3808
+ if (!existing) {
3809
+ console.log(`[elasticdash] /api/trace-events: unknown runId=${body.runId}, known runIds=[${[...pushedEvents.keys()].join(',')}]`);
3810
+ res.writeHead(404, { 'Content-Type': 'application/json' });
3811
+ res.end(JSON.stringify({ ok: false, error: 'unknown runId' }));
3812
+ return;
3813
+ }
3814
+ const evt = body.event;
3815
+ existing.push(evt);
3816
+ console.log(`[elasticdash] /api/trace-events: stored event type=${evt.type} name=${('name' in evt ? evt.name : '?')} runId=${body.runId} total=${existing.length}`);
3817
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3818
+ res.end(JSON.stringify({ ok: true }));
3819
+ }
3820
+ catch (error) {
3821
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3822
+ res.end(JSON.stringify({ ok: false, error: formatError(error) }));
3823
+ }
3824
+ })();
3825
+ }
3826
+ else {
3827
+ res.writeHead(200, { 'Content-Type': 'text/html' });
3828
+ res.end(getDashboardHtml());
3829
+ }
3830
+ });
3831
+ // Start listening
3832
+ await new Promise((resolve, reject) => {
3833
+ server.listen(port, () => resolve());
3834
+ server.on('error', reject);
3835
+ });
3836
+ const snapshotsDir = path.join(cwd, '.temp', 'snapshots');
3837
+ function cleanupSnapshots() {
3838
+ try {
3839
+ if (existsSync(snapshotsDir))
3840
+ rmSync(snapshotsDir, { recursive: true, force: true });
3841
+ }
3842
+ catch { /* best-effort */ }
3843
+ }
3844
+ for (const sig of ['SIGINT', 'SIGTERM']) {
3845
+ process.once(sig, () => {
3846
+ cleanupSnapshots();
3847
+ process.exit(0);
3848
+ });
3849
+ }
3850
+ const url = `http://localhost:${port}`;
3851
+ // Auto-open browser
3852
+ if (autoOpen) {
3853
+ openBrowser(url);
3854
+ }
3855
+ return {
3856
+ url,
3857
+ async close() {
3858
+ cleanupSnapshots();
3859
+ return new Promise((resolve, reject) => {
3860
+ server.close((err) => {
3861
+ if (err)
3862
+ reject(err);
3863
+ else
3864
+ resolve();
3865
+ });
3866
+ });
3867
+ },
3868
+ };
3869
+ }
3870
+ // Watch for changes in eb_* files
3871
+ const watcher = chokidar.watch('**/*', {
3872
+ ignored: /node_modules/,
3873
+ persistent: true
3874
+ });
3875
+ watcher.on('ready', () => {
3876
+ console.log('File watcher is ready');
3877
+ });
3878
+ watcher.on('error', (error) => {
3879
+ console.error('File watcher error:', error);
3880
+ });
3881
+ // Throttle refetching to avoid excessive calls
3882
+ let refetchTimeout = null;
3883
+ watcher.on('change', (path) => {
3884
+ if (refetchTimeout)
3885
+ clearTimeout(refetchTimeout);
3886
+ refetchTimeout = setTimeout(() => {
3887
+ console.log(`File ${path} has been changed`);
3888
+ refetchFunctions();
3889
+ }, 1000); // Throttle to 1 second
3890
+ });
3891
+ async function refetchFunctions() {
3892
+ console.log('Refetching functions...');
3893
+ // Clear the require cache for all files in the watched directory (ESM-compatible)
3894
+ const visited = new Set();
3895
+ async function clearCacheRecursively(url) {
3896
+ console.log(`Clearing cache for ${url}`);
3897
+ if (visited.has(url))
3898
+ return; // Avoid infinite loops in circular dependencies
3899
+ visited.add(url);
3900
+ try {
3901
+ const worker = new Worker('./runner.js', {
3902
+ workerData: { url },
3903
+ });
3904
+ worker.on('message', (message) => {
3905
+ console.log(`Worker message: ${message}`);
3906
+ });
3907
+ worker.on('error', (error) => {
3908
+ console.warn(`Worker error for ${url}:`, error);
3909
+ });
3910
+ worker.on('exit', (code) => {
3911
+ if (code !== 0) {
3912
+ console.warn(`Worker stopped with exit code ${code}`);
3913
+ }
3914
+ });
3915
+ await new Promise((resolve) => worker.on('exit', resolve));
3916
+ }
3917
+ catch (error) {
3918
+ console.warn(`Failed to clear cache for ${url}:`, error);
3919
+ }
3920
+ }
3921
+ try {
3922
+ const watchedDirectory = 'path/to/watched/directory';
3923
+ const resolvedUrl = pathToFileURL(watchedDirectory).href;
3924
+ await clearCacheRecursively(resolvedUrl);
3925
+ // Re-import and reload functions
3926
+ const updatedFunctions = await import(`${resolvedUrl}?v=${Date.now()}`);
3927
+ console.log('Functions reloaded:', Object.keys(updatedFunctions));
3928
+ }
3929
+ catch (error) {
3930
+ console.error('Error reloading functions:', error);
3931
+ }
3932
+ }
3933
+ // Global map for updated AI inputs (used by dashboard UI)
3934
+ // The dashboard.html UI expects three buttons for editable AI input:
3935
+ // - Edit: Shows textarea for editing input (only for AI calls)
3936
+ // - Save: Saves the updated input from textarea
3937
+ // - Reset: Removes the updated input and restores original
3938
+ // These buttons are rendered in the HTML string and handled by window.enableInputEditing, window.saveUpdatedInput, window.resetInput.
3939
+ export const updatedInputs = new Map();
3940
+ //# sourceMappingURL=dashboard-server.js.map