elasticdash-test 0.1.12 → 0.1.13-alpha-1

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 (91) hide show
  1. package/README.md +130 -4
  2. package/dist/capture/event.d.ts +6 -0
  3. package/dist/capture/event.d.ts.map +1 -1
  4. package/dist/cli.js +1 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/dashboard-server.d.ts +8 -0
  7. package/dist/dashboard-server.d.ts.map +1 -1
  8. package/dist/dashboard-server.js +327 -1698
  9. package/dist/dashboard-server.js.map +1 -1
  10. package/dist/http.d.ts +9 -0
  11. package/dist/http.d.ts.map +1 -0
  12. package/dist/http.js +9 -0
  13. package/dist/http.js.map +1 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/interceptors/ai-interceptor.d.ts.map +1 -1
  19. package/dist/interceptors/ai-interceptor.js +65 -10
  20. package/dist/interceptors/ai-interceptor.js.map +1 -1
  21. package/dist/interceptors/telemetry-push.d.ts +21 -0
  22. package/dist/interceptors/telemetry-push.d.ts.map +1 -0
  23. package/dist/interceptors/telemetry-push.js +56 -0
  24. package/dist/interceptors/telemetry-push.js.map +1 -0
  25. package/dist/interceptors/tool.d.ts.map +1 -1
  26. package/dist/interceptors/tool.js +78 -24
  27. package/dist/interceptors/tool.js.map +1 -1
  28. package/dist/interceptors/workflow-ai.d.ts.map +1 -1
  29. package/dist/interceptors/workflow-ai.js +99 -17
  30. package/dist/interceptors/workflow-ai.js.map +1 -1
  31. package/dist/matchers/index.d.ts +10 -1
  32. package/dist/matchers/index.d.ts.map +1 -1
  33. package/dist/matchers/index.js +57 -12
  34. package/dist/matchers/index.js.map +1 -1
  35. package/dist/tool-runner-worker.js +2 -2
  36. package/dist/tool-runner-worker.js.map +1 -1
  37. package/dist/trace-adapter/context.d.ts +2 -0
  38. package/dist/trace-adapter/context.d.ts.map +1 -1
  39. package/dist/trace-adapter/context.js +2 -2
  40. package/dist/trace-adapter/context.js.map +1 -1
  41. package/dist/tracing.d.ts +1 -1
  42. package/dist/tracing.d.ts.map +1 -1
  43. package/dist/tracing.js +4 -4
  44. package/dist/tracing.js.map +1 -1
  45. package/dist/workflow-runner-worker.js +37 -23
  46. package/dist/workflow-runner-worker.js.map +1 -1
  47. package/package.json +15 -2
  48. package/src/browser-ui.ts +281 -0
  49. package/src/capture/event.ts +26 -0
  50. package/src/capture/index.ts +3 -0
  51. package/src/capture/recorder.ts +62 -0
  52. package/src/capture/replay.ts +55 -0
  53. package/src/cli.ts +315 -0
  54. package/src/core/agent-state.ts +162 -0
  55. package/src/core/registry.ts +92 -0
  56. package/src/dashboard-server.ts +1862 -0
  57. package/src/html/dashboard.html +1782 -0
  58. package/src/http.ts +8 -0
  59. package/src/index.ts +32 -0
  60. package/src/interceptors/ai-interceptor.ts +542 -0
  61. package/src/interceptors/db-auto.ts +147 -0
  62. package/src/interceptors/db.ts +81 -0
  63. package/src/interceptors/http.ts +367 -0
  64. package/src/interceptors/side-effects.ts +83 -0
  65. package/src/interceptors/telemetry-push.ts +67 -0
  66. package/src/interceptors/tool.ts +231 -0
  67. package/src/interceptors/workflow-ai.ts +117 -0
  68. package/src/internals/conditional-recorder.ts +63 -0
  69. package/src/internals/mock-resolver.ts +92 -0
  70. package/src/matchers/index.ts +814 -0
  71. package/src/proxy/llm-capture.ts +301 -0
  72. package/src/reporter.ts +81 -0
  73. package/src/runWorkflowSubprocess.ts +74 -0
  74. package/src/runner.ts +178 -0
  75. package/src/test-setup.ts +16 -0
  76. package/src/tool-runner-worker.ts +64 -0
  77. package/src/trace-adapter/context.ts +156 -0
  78. package/src/tracing.ts +62 -0
  79. package/src/types/agent.d.ts +63 -0
  80. package/src/types/expect.d.ts +81 -0
  81. package/src/types/modules.d.ts +2 -0
  82. package/src/workflow-runner-worker.ts +357 -0
  83. package/src/workflow-runner.ts +55 -0
  84. package/dist/internals/async-storage.d.ts +0 -21
  85. package/dist/internals/async-storage.d.ts.map +0 -1
  86. package/dist/internals/async-storage.js +0 -46
  87. package/dist/internals/async-storage.js.map +0 -1
  88. package/dist/internals/node-crypto.d.ts +0 -12
  89. package/dist/internals/node-crypto.d.ts.map +0 -1
  90. package/dist/internals/node-crypto.js +0 -38
  91. package/dist/internals/node-crypto.js.map +0 -1
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { pathToFileURL } from 'url';
6
+ import { randomUUID } from 'node:crypto';
6
7
  import { callProviderLLM } from './matchers/index.js';
7
8
  import chokidar from 'chokidar';
8
9
  import express from 'express';
@@ -24,6 +25,9 @@ function loadSnapshot(cwd, snapshotId) {
24
25
  return null;
25
26
  }
26
27
  }
28
+ function isDenoProject(dir) {
29
+ return existsSync(path.join(dir, 'deno.json')) || existsSync(path.join(dir, 'deno.jsonc'));
30
+ }
27
31
  function resolveRuntimeModule(cwd, baseName) {
28
32
  for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
29
33
  const candidate = path.join(cwd, `${baseName}${ext}`);
@@ -164,12 +168,21 @@ function formatError(error) {
164
168
  }
165
169
  function runToolInSubprocess(toolsModulePath, toolName, args) {
166
170
  return new Promise((resolve) => {
171
+ const startMs = Date.now();
167
172
  const workerScript = new URL('./tool-runner-worker.js', import.meta.url).pathname;
168
- // Forward NODE_OPTIONS so tsx/esm transpiles TypeScript in the child process
173
+ const projectDir = path.dirname(toolsModulePath);
174
+ const denoProject = isDenoProject(projectDir);
175
+ // For Deno projects use `deno run --allow-all` so that https:// imports and
176
+ // TypeScript are handled natively. For Node projects keep the existing tsx path.
169
177
  const nodeOptions = process.env.NODE_OPTIONS ?? '';
170
- const childEnv = { ...process.env, NODE_OPTIONS: nodeOptions };
171
- const child = spawn(process.execPath, [workerScript], {
178
+ const tsxFlag = '--import tsx';
179
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim();
180
+ const childEnv = { ...process.env, NODE_OPTIONS: denoProject ? nodeOptions : childNodeOptions };
181
+ const runtime = denoProject ? 'deno' : process.execPath;
182
+ const runtimeArgs = denoProject ? ['run', '--allow-all', workerScript] : [workerScript];
183
+ const child = spawn(runtime, runtimeArgs, {
172
184
  env: childEnv,
185
+ cwd: projectDir,
173
186
  stdio: ['pipe', 'pipe', 'pipe'],
174
187
  });
175
188
  const RESULT_PREFIX = '__ELASTICDASH_RESULT__:';
@@ -191,17 +204,21 @@ function runToolInSubprocess(toolsModulePath, toolName, args) {
191
204
  process.stderr.write(chunk);
192
205
  });
193
206
  child.on('close', () => {
207
+ const currentDurationMs = Date.now() - startMs;
194
208
  if (resultLine) {
195
209
  try {
196
- resolve(JSON.parse(resultLine));
210
+ resolve({ ...JSON.parse(resultLine), currentDurationMs });
197
211
  return;
198
212
  }
199
213
  catch { /* fall through */ }
200
214
  }
201
- resolve({ ok: false, error: stderr.trim() || 'Tool subprocess produced no output.' });
215
+ resolve({ ok: false, error: stderr.trim() || 'Tool subprocess produced no output.', currentDurationMs });
202
216
  });
203
217
  child.on('error', (err) => {
204
- resolve({ ok: false, error: `Failed to spawn tool subprocess: ${err.message}` });
218
+ const hint = denoProject && err.code === 'ENOENT'
219
+ ? ' (Deno project detected — ensure "deno" is installed and available in PATH)'
220
+ : '';
221
+ resolve({ ok: false, error: `Failed to spawn tool subprocess: ${err.message}${hint}`, currentDurationMs: Date.now() - startMs });
205
222
  });
206
223
  // Always use absolute file URL for toolsModulePath
207
224
  const payload = JSON.stringify({
@@ -216,17 +233,40 @@ function runToolInSubprocess(toolsModulePath, toolName, args) {
216
233
  function runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, args, input, options) {
217
234
  return new Promise((resolve) => {
218
235
  const workerScript = new URL('./workflow-runner-worker.js', import.meta.url).pathname;
219
- // Forward NODE_OPTIONS so tsx/esm transpiles TypeScript in the child process
236
+ const projectDir = path.dirname(workflowsModulePath);
237
+ const denoProject = isDenoProject(projectDir);
238
+ // For Deno projects use `deno run --allow-all` so that https:// imports and
239
+ // TypeScript are handled natively. For Node projects keep the existing tsx path.
220
240
  const nodeOptions = process.env.NODE_OPTIONS ?? '';
221
- const childEnv = { ...process.env, NODE_OPTIONS: nodeOptions };
222
- const child = spawn(process.execPath, [workerScript], {
241
+ const tsxFlag = '--import tsx';
242
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim();
243
+ const childEnv = { ...process.env, NODE_OPTIONS: denoProject ? nodeOptions : childNodeOptions };
244
+ const runtime = denoProject ? 'deno' : process.execPath;
245
+ const runtimeArgs = denoProject ? ['run', '--allow-all', workerScript] : [workerScript];
246
+ const child = spawn(runtime, runtimeArgs, {
223
247
  env: childEnv,
248
+ cwd: projectDir,
224
249
  stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
225
250
  });
226
251
  let fd3Data = '';
227
252
  let stderr = '';
253
+ // Line-buffer stdout so that large result JSON lines split across multiple
254
+ // data events are reassembled before processing.
255
+ const WORKFLOW_RESULT_PREFIX = '__ELASTICDASH_RESULT__:';
256
+ let stdoutBuf = '';
228
257
  child.stdout.on('data', (chunk) => {
229
- process.stdout.write(chunk);
258
+ stdoutBuf += chunk.toString();
259
+ const lines = stdoutBuf.split('\n');
260
+ stdoutBuf = lines.pop() ?? ''; // keep last (possibly incomplete) line
261
+ for (const line of lines) {
262
+ if (line.startsWith(WORKFLOW_RESULT_PREFIX)) {
263
+ // Stdout fallback channel (used by Deno when fd3 is unavailable)
264
+ fd3Data += line.slice(WORKFLOW_RESULT_PREFIX.length);
265
+ }
266
+ else if (line) {
267
+ process.stdout.write(line + '\n');
268
+ }
269
+ }
230
270
  });
231
271
  child.stderr.on('data', (chunk) => {
232
272
  stderr += chunk.toString();
@@ -237,6 +277,13 @@ function runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowN
237
277
  fd3Data += chunk.toString();
238
278
  });
239
279
  child.on('close', () => {
280
+ // Flush any remaining buffered stdout line (e.g. result with no trailing newline)
281
+ if (stdoutBuf.startsWith(WORKFLOW_RESULT_PREFIX)) {
282
+ fd3Data += stdoutBuf.slice(WORKFLOW_RESULT_PREFIX.length);
283
+ }
284
+ else if (stdoutBuf) {
285
+ process.stdout.write(stdoutBuf + '\n');
286
+ }
240
287
  if (fd3Data) {
241
288
  try {
242
289
  resolve(JSON.parse(fd3Data));
@@ -247,7 +294,10 @@ function runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowN
247
294
  resolve({ ok: false, error: stderr.trim() || 'Workflow subprocess produced no output.' });
248
295
  });
249
296
  child.on('error', (err) => {
250
- resolve({ ok: false, error: `Failed to spawn workflow subprocess: ${err.message}` });
297
+ const hint = denoProject && err.code === 'ENOENT'
298
+ ? ' (Deno project detected — ensure "deno" is installed and available in PATH)'
299
+ : '';
300
+ resolve({ ok: false, error: `Failed to spawn workflow subprocess: ${err.message}${hint}` });
251
301
  });
252
302
  // Always use absolute file URL for workflowsModulePath and toolsModulePath
253
303
  const payload = JSON.stringify({
@@ -301,8 +351,8 @@ async function runGenerationObservation(observation) {
301
351
  const model = observation.model;
302
352
  const temperature = typeof observation.modelParameters?.temperature === 'number' ? observation.modelParameters.temperature : 0;
303
353
  const maxTokens = typeof observation.modelParameters?.max_tokens === 'number' ? observation.modelParameters.max_tokens : 512;
304
- const output = await callProviderLLM(prompt, { provider, model }, systemPrompt ?? 'You are a helpful assistant.', maxTokens, temperature);
305
- return { ok: true, currentOutput: output };
354
+ const result = await callProviderLLM(prompt, { provider, model }, systemPrompt ?? 'You are a helpful assistant.', maxTokens, temperature);
355
+ return { ok: true, currentOutput: result.content, currentDurationMs: result.durationMs, currentUsage: result.usage };
306
356
  }
307
357
  catch (error) {
308
358
  return { ok: false, error: `Generation rerun failed: ${formatError(error)}` };
@@ -418,64 +468,21 @@ function toObservationFromWorkflowEvent(event) {
418
468
  const inp = event.input;
419
469
  const out = event.output;
420
470
  const provider = inp?.provider ?? '';
421
- let completion = '';
422
- if (out) {
423
- if (out.streamed === true && typeof out.completion === 'string') {
424
- completion = out.completion;
425
- }
426
- else if (provider === 'gemini') {
427
- const candidates = out.candidates;
428
- if (Array.isArray(candidates) && candidates.length > 0) {
429
- const first = candidates[0];
430
- if (first.content && typeof first.content === 'object') {
431
- const parts = first.content.parts;
432
- if (Array.isArray(parts) && parts.length > 0) {
433
- completion = String(parts[0].text ?? '');
434
- }
435
- }
436
- }
437
- }
438
- else {
439
- const choices = out.choices;
440
- if (Array.isArray(choices) && choices.length > 0) {
441
- const first = choices[0];
442
- if (first.message && typeof first.message === 'object') {
443
- completion = String(first.message.content ?? '');
444
- }
445
- else if (typeof first.text === 'string') {
446
- completion = first.text;
447
- }
448
- }
449
- // Embedding response: data[].embedding
450
- if (!completion) {
451
- const data = out.data;
452
- if (Array.isArray(data) && data.length > 0) {
453
- const first = data[0];
454
- if (Array.isArray(first?.embedding)) {
455
- completion = `[${data.length} embedding(s), ${first.embedding.length} dimensions]`;
456
- }
457
- }
458
- }
459
- // Gemini embedding response: embeddings[].values
460
- if (!completion) {
461
- const embeddings = out.embeddings;
462
- if (Array.isArray(embeddings) && embeddings.length > 0) {
463
- const first = embeddings[0];
464
- if (Array.isArray(first?.values)) {
465
- completion = `[${embeddings.length} embedding(s), ${first.values.length} dimensions]`;
466
- }
467
- }
468
- }
469
- }
471
+ // For streaming events, out is { streamed: true, completion } — extract text for fallback
472
+ let streamedCompletion;
473
+ if (out?.streamed === true && typeof out.completion === 'string') {
474
+ streamedCompletion = out.completion;
470
475
  }
471
476
  return {
472
477
  type: 'GENERATION',
473
478
  name: provider || 'llm',
474
479
  provider: provider || undefined,
475
480
  model: inp?.model ?? event.name,
476
- input: inp?.prompt,
477
- output: completion,
481
+ input: inp?.messages ?? inp?.prompt,
482
+ output: streamedCompletion !== undefined ? streamedCompletion : out,
478
483
  startTime: normalizeStartTime(event.timestamp),
484
+ durationMs: event.durationMs,
485
+ usage: event.usage,
479
486
  workflowEventId: event.id,
480
487
  ...agentFields,
481
488
  };
@@ -487,6 +494,7 @@ function toObservationFromWorkflowEvent(event) {
487
494
  input: event.input,
488
495
  output: event.output,
489
496
  startTime: normalizeStartTime(event.timestamp),
497
+ durationMs: event.durationMs,
490
498
  workflowEventId: event.id,
491
499
  ...agentFields,
492
500
  };
@@ -499,6 +507,7 @@ function toObservationFromWorkflowEvent(event) {
499
507
  input: event.input,
500
508
  output: event.output,
501
509
  startTime: normalizeStartTime(event.timestamp),
510
+ durationMs: event.durationMs,
502
511
  workflowEventId: event.id,
503
512
  ...agentFields,
504
513
  };
@@ -510,6 +519,7 @@ function toObservationFromWorkflowEvent(event) {
510
519
  input: event.input,
511
520
  output: event.output,
512
521
  startTime: normalizeStartTime(event.timestamp),
522
+ durationMs: event.durationMs,
513
523
  workflowEventId: event.id,
514
524
  ...agentFields,
515
525
  };
@@ -520,6 +530,7 @@ function toObservationFromWorkflowEvent(event) {
520
530
  input: event.input,
521
531
  output: event.output,
522
532
  startTime: normalizeStartTime(event.timestamp),
533
+ durationMs: event.durationMs,
523
534
  workflowEventId: event.id,
524
535
  ...agentFields,
525
536
  };
@@ -571,6 +582,22 @@ function buildValidationObservations(workflowName, workflowInput, workflowOutput
571
582
  }
572
583
  }
573
584
  }
585
+ // Compute total duration and aggregate token usage for the container observation
586
+ if (workflowTrace && workflowTrace.events.length > 0) {
587
+ const endTime = workflowTrace.events.reduce((max, e) => Math.max(max, e.timestamp + e.durationMs), workflowStartTime);
588
+ observations[0].durationMs = endTime - workflowStartTime;
589
+ let inputTokens = 0, outputTokens = 0, totalTokens = 0;
590
+ for (const e of workflowTrace.events) {
591
+ if (e.type === 'ai' && e.usage) {
592
+ inputTokens += e.usage.inputTokens ?? 0;
593
+ outputTokens += e.usage.outputTokens ?? 0;
594
+ totalTokens += e.usage.totalTokens ?? 0;
595
+ }
596
+ }
597
+ if (totalTokens > 0) {
598
+ observations[0].usage = { inputTokens, outputTokens, totalTokens };
599
+ }
600
+ }
574
601
  // Sort all observations except the workflow entry (index 0) by startTime
575
602
  const [workflowEntry, ...rest] = observations;
576
603
  rest.sort((a, b) => (a.startTime ?? 0) - (b.startTime ?? 0));
@@ -925,1626 +952,7 @@ function openBrowser(url) {
925
952
  */
926
953
  function getDashboardHtml() {
927
954
  /* DASHBOARD_HTML_START */
928
- return `<!DOCTYPE html>
929
- <html lang="en">
930
- <head>
931
- <meta charset="UTF-8">
932
- <meta name="viewport" content="width=device-width, initial-scale=1">
933
- <title>ElasticDash Dashboard</title>
934
- <style>
935
- /* Ensure first cell in observation-table never overflows parent */
936
- .observation-table td:first-child {
937
- max-width: 120px;
938
- overflow: auto;
939
- white-space: nowrap;
940
- }
941
- * { margin: 0; padding: 0; box-sizing: border-box; }
942
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
943
- .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
944
- header { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
945
- h1 { font-size: 28px; margin-bottom: 8px; color: #1a1a1a; }
946
- .subtitle { font-size: 14px; color: #666; margin-bottom: 16px; }
947
- .search-box { display: flex; gap: 10px; }
948
- input[type="text"] { flex: 1; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
949
- input[type="text"]:focus { outline: none; border-color: #0066cc; box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); }
950
- .result-count { padding: 10px 12px; background: #f0f0f0; border-radius: 6px; font-size: 14px; color: #666; }
951
- .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; }
952
- .workflows-table { width: 100%; border-collapse: collapse; }
953
- .workflows-table thead { background: #f5f5f5; position: sticky; top: 0; z-index: 10; }
954
- .workflows-table th { padding: 12px 16px; text-align: left; font-weight: 600; font-size: 13px; color: #333; border-bottom: 2px solid #ddd; }
955
- .workflows-table td { padding: 12px 16px; border-bottom: 1px solid #eee; }
956
- .workflows-table tbody tr { cursor: pointer; transition: background-color 0.2s; }
957
- .workflows-table tbody tr:hover { background-color: #f9f9f9; }
958
- .workflow-name-cell { font-family: Monaco, monospace; font-weight: 600; color: #0066cc; }
959
- .workflow-path-cell { font-family: Monaco, monospace; font-size: 12px; color: #666; }
960
- .async-badge { display: inline-block; background: #e8f3ff; color: #0066cc; padding: 2px 8px; border-radius: 4px; font-size: 11px; margin-left: 8px; }
961
- .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; }
962
- .modal.open { display: flex; }
963
- .modal-content { background: white; border-radius: 12px; width: 92%; max-width: 1100px; max-height: 90vh; overflow-y: auto; padding: 30px; }
964
- .modal-header { display: flex; justify-content: space-between; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #eee; }
965
- .modal-title { font-size: 20px; font-weight: 600; }
966
- .modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #999; }
967
- .upload-area { border: 2px dashed #ddd; border-radius: 8px; padding: 30px; text-align: center; cursor: pointer; background: #fafafa; overflow: hidden; height: 520px; position: relative; }
968
- .upload-area:hover { border-color: #0066cc; background: #f0f7ff; }
969
- .upload-area > div {
970
- position: absolute;
971
- top: 50%;
972
- left: 50%;
973
- transform: translate(-50%, -50%);
974
- }
975
- .upload-icon { font-size: 32px; margin-bottom: 12px; }
976
- input[type="file"] { display: none; }
977
- .upload-status { margin-top: 20px; padding: 12px; border-radius: 6px; display: none; }
978
- .upload-status.success { display: block; background: #e8f5e9; color: #2e7d32; }
979
- .upload-status.error { display: block; background: #ffebee; color: #c62828; }
980
- .hidden { display: none !important; }
981
- .trace-viewer { display: none; margin-top: 20px; }
982
- .trace-viewer.visible { display: block; }
983
- .trace-layout { display: grid; grid-template-columns: 40% calc(60% - 16px); gap: 16px; overflow: auto; height: 520px; }
984
- .trace-layout.step-5 { display: grid; grid-template-columns: calc(30% - 16px) calc(30% - 16px) 40%; gap: 16px; overflow: auto; height: 520px; }
985
- .trace-layout.step-4 { display: grid; grid-template-columns: calc(30% - 16px) calc(30% - 16px) 40%; gap: 16px; overflow: auto; height: 520px; }
986
- .trace-left, .trace-right { background: #f9f9f9; border-radius: 8px; padding: 14px; border: 1px solid #eee; }
987
- .trace-section-title { font-size: 14px; font-weight: 600; margin-bottom: 10px; }
988
- .observation-table-wrap { max-height: 460px; overflow: auto; background: white; border-radius: 6px; border: 1px solid #eee; }
989
- .observation-table { width: 100%; border-collapse: collapse; }
990
- .observation-table thead { background: #f5f5f5; position: sticky; top: 0; z-index: 1; }
991
- .observation-table th { text-align: left; font-size: 12px; font-weight: 600; color: #555; padding: 10px 12px; border-bottom: 1px solid #e8e8e8; }
992
- .observation-table td { font-size: 13px; padding: 10px 12px; border-bottom: 1px solid #f0f0f0; }
993
- .observation-table tbody tr { cursor: pointer; }
994
- .observation-table tbody tr:hover { background: #f7fbff; }
995
- .observation-table tbody tr.selected { background: #e8f3ff; }
996
- .obs-type { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; background: #e6e6e6; color: #333; }
997
- .obs-type.tool { background: #e8f7ef; color: #1f7a44; }
998
- .obs-type.ai { background: #e8f1ff; color: #1f5fbf; }
999
- .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; }
1000
- .run-from-bp-btn:hover { background: #e0edff; border-color: #5a8fd8; color: #1f5fbf; }
1001
- .run-from-bp-btn:disabled { opacity: 0.6; cursor: default; }
1002
- .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; }
1003
- .resume-agent-btn:hover { background: #e6d8ff; border-color: #7c52b8; color: #3d1f7a; }
1004
- .resume-agent-btn:disabled { opacity: 0.6; cursor: default; }
1005
- .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; }
1006
- .agent-task-row { background: #f7f0ff; border-left: 3px solid #8560c6; }
1007
- .frozen-row { background: #eef7ff; border-left: 3px solid #8cbcf5; }
1008
- .observation-table tbody tr.selected.frozen-row { background: #dcebff; }
1009
- .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; }
1010
- .detail-sections { display: flex; flex-direction: column; gap: 12px; height: 486.5px; overflow-y: auto; }
1011
- .detail-section { background: white; border: 1px solid #eee; border-radius: 6px; padding: 10px; }
1012
- .detail-title { font-size: 12px; font-weight: 600; margin-bottom: 8px; color: #555; text-transform: uppercase; letter-spacing: 0.02em; }
1013
- .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; }
1014
- .modal-footer { display: flex; margin-top: 24px; padding-top: 20px; border-top: 1px solid #eee; gap: 12px; justify-content: space-between; }
1015
- .btn { padding: 10px 20px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; border: none; }
1016
- .btn-secondary { background: #f0f0f0; color: #333; }
1017
- .btn-secondary:hover { background: #e0e0e0; }
1018
- .btn-primary { background: #0066cc; color: white; }
1019
- .btn-primary:hover { background: #0052a3; }
1020
- .btn:disabled { opacity: 0.6; cursor: not-allowed; }
1021
- .btn-primary:disabled:hover { background: #0066cc; }
1022
- .btn-secondary:disabled:hover { background: #f0f0f0; }
1023
- .obs-checkbox { width: 18px; height: 18px; cursor: pointer; }
1024
- .rerun-status { display: inline-block; margin-left: 8px; font-size: 11px; font-weight: 600; }
1025
- .rerun-status.running { color: #666; }
1026
- .rerun-status.success { color: #1f7a44; }
1027
- .rerun-status.error { color: #c62828; }
1028
- @media (max-width: 900px) {
1029
- .trace-layout { grid-template-columns: 1fr; }
1030
- }
1031
- </style>
1032
- <script>
1033
- const updatedInputs = new Map();
1034
- </script>
1035
- </head>
1036
- <body>
1037
- <div class="container">
1038
- <header>
1039
- <h1>Workflow Functions</h1>
1040
- <div class="subtitle">Select a workflow to debug with trace analysis</div>
1041
- <div class="search-box">
1042
- <input type="text" id="searchInput" placeholder="Search by name or path..." autocomplete="off">
1043
- <div class="result-count"><span id="resultCount">0</span> workflows</div>
1044
- </div>
1045
- </header>
1046
- <div class="workflows-list">
1047
- <table class="workflows-table">
1048
- <thead><tr><th style="width: 35%">Function Name</th><th>File Path</th></tr></thead>
1049
- <tbody id="workflowsTableBody"><tr><td colspan="2" style="text-align: center; padding: 40px;">Loading...</td></tr></tbody>
1050
- </table>
1051
- </div>
1052
- </div>
1053
- <div id="traceModal" class="modal">
1054
- <div class="modal-content">
1055
- <div class="modal-header">
1056
- <h2 class="modal-title">Import Trace for Analysis</h2>
1057
- <button class="modal-close" id="closeModal">&times;</button>
1058
- </div>
1059
- <div id="uploadArea" class="upload-area">
1060
- <div>Drag and Drop <br />or <br />Click to Upload</div>
1061
- <input type="file" id="traceFile" accept=".json" />
1062
- </div>
1063
- <div id="uploadStatus" class="upload-status"></div>
1064
- <div id="traceViewer" class="trace-viewer">
1065
- <div class="trace-layout">
1066
- <div class="trace-left">
1067
- <div class="trace-section-title">Observations</div>
1068
- <div class="observation-table-wrap">
1069
- <table class="observation-table">
1070
- <thead id="observationTableHead"><tr><th style="width: 40px;">Check</th><th>Name</th><th>Type</th></tr></thead>
1071
- <tbody id="observationTableBody"></tbody>
1072
- </table>
1073
- </div>
1074
- </div>
1075
- <div class="trace-right">
1076
- <div id="observationDetail"></div>
1077
- </div>
1078
- </div>
1079
- </div>
1080
- <div id="modalFooter" class="modal-footer">
1081
- <button class="btn btn-secondary" id="changeTraceBtn">Change Trace File</button>
1082
- <button class="btn btn-primary" id="nextBtn">Next</button>
1083
- </div>
1084
- </div>
1085
- </div>
1086
- <script>
1087
- console.log("[Dashboard] Script starting...");
1088
- let allWorkflows = [], codeIndex = {workflows: [], tools: []}, selectedWorkflow = null;
1089
- let currentObservations = [], selectedObservationIndex = -1;
1090
- let rerunHistory = new Map();
1091
- let rerunInFlight = new Set();
1092
- let step4SelectedRun = -1;
1093
- let step5RunTraces = [];
1094
- let repoRoot = ''; // Will be fetched from API
1095
- try {
1096
- const _saved = localStorage.getItem('ed_step5RunTraces');
1097
- if (_saved) step5RunTraces = JSON.parse(_saved);
1098
- } catch {}
1099
- let step5RunMeta = { loading: false, error: '', runCount: 0, sequential: false };
1100
-
1101
- function persistTraces() {
1102
- try {
1103
- // Store compact version — strip bulky workflowTrace.events (snapshot is on server)
1104
- const compact = step5RunTraces.map(function(t) {
1105
- const { workflowTrace, ...rest } = t;
1106
- return rest;
1107
- });
1108
- localStorage.setItem('ed_step5RunTraces', JSON.stringify(compact));
1109
- } catch {}
1110
- }
1111
- const tbody = document.getElementById("workflowsTableBody");
1112
- const countEl = document.getElementById("resultCount");
1113
- const modal = document.getElementById("traceModal");
1114
- const uploadArea = document.getElementById("uploadArea");
1115
- const fileInput = document.getElementById("traceFile");
1116
- const modalFooter = document.getElementById("modalFooter");
1117
- const uploadStatus = document.getElementById("uploadStatus");
1118
- const traceViewer = document.getElementById("traceViewer");
1119
- let observationTableBody = document.getElementById("observationTableBody");
1120
- let observationDetail = document.getElementById("observationDetail");
1121
- const modalTitle = document.querySelector(".modal-title");
1122
- console.log("[Dashboard] DOM elements loaded, tbody:", tbody);
1123
-
1124
- let currentStep = 0; // 0=upload, 3=mark, 4=verify, 5=validate
1125
- let checkedObservations = new Set();
1126
-
1127
- document.getElementById("closeModal").onclick = () => {
1128
- modal.classList.remove("open");
1129
- resetTraceModal();
1130
- };
1131
- modal.onclick = (e) => {
1132
- if (e.target === modal) {
1133
- modal.classList.remove("open");
1134
- resetTraceModal();
1135
- }
1136
- };
1137
-
1138
- document.getElementById("changeTraceBtn").onclick = () => {
1139
- if (currentStep === 3) {
1140
- resetTraceModal();
1141
- } else if (currentStep === 4) {
1142
- // Go back to Step 3
1143
- currentStep = 3;
1144
- checkedObservations.clear(); // Clear to allow reselecting different steps
1145
- updateModalTitle();
1146
- updateFooterButtons();
1147
- renderObservationTable();
1148
- // Auto-select first observation
1149
- if (currentObservations.length > 0) {
1150
- selectObservation(0);
1151
- }
1152
- } else if (currentStep === 5) {
1153
- // Go back to Step 3 (Still Failing)
1154
- currentStep = 3;
1155
- checkedObservations.clear(); // Clear to allow reselecting different steps
1156
- updateModalTitle();
1157
- updateFooterButtons();
1158
- renderObservationTable();
1159
- // Auto-select first observation
1160
- if (currentObservations.length > 0) {
1161
- selectObservation(0);
1162
- }
1163
- }
1164
- };
1165
-
1166
- document.getElementById("nextBtn").onclick = () => {
1167
- if (currentStep < 3) {
1168
- alert("Please upload a trace file to continue");
1169
- return;
1170
- }
1171
- if (currentStep === 3) {
1172
- // Validate that at least one checkbox is checked
1173
- if (checkedObservations.size === 0) {
1174
- alert("Please select at least one step to mark as broken");
1175
- return;
1176
- }
1177
- // Move to Step 4
1178
- currentStep = 4;
1179
- updateModalTitle();
1180
- updateFooterButtons();
1181
- renderObservationTable();
1182
- // Auto-select first checked observation
1183
- const checkedArray = Array.from(checkedObservations);
1184
- if (checkedArray.length > 0) {
1185
- window.step4SelectObservation(checkedArray[0]);
1186
- }
1187
- } else if (currentStep === 4) {
1188
- // Show prompt-update confirmation (if needed), then live validation dialog
1189
- window.openPromptConfirmation(() => window.openLiveValidationDialog());
1190
- return;
1191
- } else if (currentStep === 5) {
1192
- modal.classList.remove("open");
1193
- resetTraceModal();
1194
- customFooter.remove();
1195
- }
1196
- };
1197
-
1198
- // ---- Tool Mock Config State ----
1199
- window._toolMockConfig = {}; // { toolName: { mode: 'live'|'mock-all'|'mock-specific', callIndices: [], mockData: {} } }
1200
-
1201
- function getToolsFromTrace() {
1202
- // Extract unique tool names and their call details from the uploaded trace observations
1203
- const toolCalls = {};
1204
- currentObservations.forEach(function(obs, i) {
1205
- if (obs.type !== 'TOOL') return;
1206
- const name = obs.name || '(unknown)';
1207
- if (!toolCalls[name]) toolCalls[name] = [];
1208
- toolCalls[name].push({ index: toolCalls[name].length + 1, obsIndex: i, input: obs.input, output: obs.output });
1209
- });
1210
- return toolCalls;
1211
- }
1212
-
1213
- function getAllRegisteredTools() {
1214
- // From codeIndex.tools (fetched at page load from /api/code-index)
1215
- return (codeIndex.tools || []).map(function(t) { return t.name; });
1216
- }
1217
-
1218
- function buildToolMockConfigFromUI() {
1219
- const config = {};
1220
- const rows = document.querySelectorAll('.tool-mock-row');
1221
- rows.forEach(function(row) {
1222
- const toolName = row.dataset.toolName;
1223
- const modeSelect = row.querySelector('.tool-mock-mode');
1224
- const mode = modeSelect ? modeSelect.value : 'live';
1225
- if (mode === 'live') return;
1226
- const entry = { mode: mode };
1227
- if (mode === 'mock-specific') {
1228
- const checkboxes = row.querySelectorAll('.tool-call-checkbox:checked');
1229
- entry.callIndices = Array.from(checkboxes).map(function(cb) { return parseInt(cb.value, 10); });
1230
- if (entry.callIndices.length === 0) return; // No calls selected, treat as live
1231
- }
1232
- // Collect mock data
1233
- entry.mockData = {};
1234
- const dataInputs = row.querySelectorAll('.tool-mock-data-input');
1235
- dataInputs.forEach(function(inp) {
1236
- const callIdx = parseInt(inp.dataset.callIdx, 10);
1237
- if (!inp.value.trim()) return;
1238
- try { entry.mockData[callIdx] = JSON.parse(inp.value); }
1239
- catch(e) { entry.mockData[callIdx] = inp.value; }
1240
- });
1241
- config[toolName] = entry;
1242
- });
1243
- return config;
1244
- }
1245
-
1246
- function cleanValue(value) {
1247
- if (typeof value === "string") {
1248
- value = value.replaceAll('\\\\"', '');
1249
- // remove surrounding quotes if they exist
1250
- if (value.startsWith('"') && value.endsWith('"')) {
1251
- return value.slice(1, -1);
1252
- }
1253
- return value;
1254
- }
1255
-
1256
- if (Array.isArray(value)) {
1257
- return value.map(cleanValue);
1258
- }
1259
-
1260
- if (typeof value === "object" && value !== null) {
1261
- const result = {};
1262
- for (const key in value) {
1263
- result[key] = cleanValue(value[key]);
1264
- }
1265
- return result;
1266
- }
1267
-
1268
- return value;
1269
- }
1270
-
1271
- function convert(input) {
1272
- const parsed = JSON.parse(input);
1273
- return cleanValue(parsed);
1274
- }
1275
-
1276
- function renderToolMockSection(showAll) {
1277
- const traceTools = getToolsFromTrace();
1278
- const allToolNames = getAllRegisteredTools();
1279
- const traceToolNames = Object.keys(traceTools);
1280
- const toolNames = showAll
1281
- ? Array.from(new Set([...traceToolNames, ...allToolNames]))
1282
- : traceToolNames;
1283
-
1284
- if (toolNames.length === 0) {
1285
- return '<div style="color:#999;font-size:13px;padding:6px 0;">No tools detected.</div>';
1286
- }
1287
-
1288
- let html = '<div style="max-height:260px;overflow-y:auto;border:1px solid #e0e0e0;border-radius:6px;">';
1289
- html += '<table style="width:100%;border-collapse:collapse;font-size:13px;">';
1290
- html += '<thead><tr style="background:#f5f5f5;">';
1291
- html += '<th style="padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Tool</th>';
1292
- html += '<th style="padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Calls in Trace</th>';
1293
- html += '<th style="padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Mock Mode</th>';
1294
- html += '<th style="padding:6px 10px;text-align:left;border-bottom:1px solid #e0e0e0;">Details</th>';
1295
- html += '</tr></thead><tbody>';
1296
-
1297
- toolNames.forEach(function(name) {
1298
- const calls = traceTools[name] || [];
1299
- const inTrace = traceToolNames.includes(name);
1300
- const existing = window._toolMockConfig[name] || { mode: 'live' };
1301
- const nameStyle = inTrace ? '' : 'color:#999;';
1302
-
1303
- html += '<tr class="tool-mock-row" data-tool-name="' + esc(name) + '" style="border-bottom:1px solid #f0f0f0;">';
1304
- html += '<td style="padding:6px 10px;font-family:Monaco,monospace;' + nameStyle + '">' + esc(name) + (inTrace ? '' : ' <span style="font-size:10px;color:#aaa;">(not in trace)</span>') + '</td>';
1305
- html += '<td style="padding:6px 10px;">' + calls.length + '</td>';
1306
- html += '<td style="padding:6px 10px;">';
1307
- html += '<select class="tool-mock-mode" style="font-size:12px;padding:2px 4px;" onchange="window.onToolMockModeChange(\\'' + esc(name) + '\\', this.value)">';
1308
- html += '<option value="live"' + (existing.mode === 'live' ? ' selected' : '') + '>Live</option>';
1309
- html += '<option value="mock-all"' + (existing.mode === 'mock-all' ? ' selected' : '') + '>Mock All Calls</option>';
1310
- if (calls.length > 0) {
1311
- html += '<option value="mock-specific"' + (existing.mode === 'mock-specific' ? ' selected' : '') + '>Mock Specific Calls</option>';
1312
- }
1313
- html += '</select>';
1314
- html += '</td>';
1315
-
1316
- // Details column: per-call checkboxes + mock data inputs
1317
- html += '<td style="padding:6px 10px;">';
1318
- if (existing.mode === 'mock-all') {
1319
- let defaultData = (existing.mockData && existing.mockData[0] !== undefined) ? JSON.stringify(existing.mockData[0]) : (calls.length > 0 ? JSON.stringify(calls[0].output) : '');
1320
- defaultData = convert(defaultData);
1321
- html += '<div style="font-size:11px;color:#555;margin-bottom:4px;">Mock data (JSON):</div>';
1322
- 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>';
1323
- } else if (existing.mode === 'mock-specific' && calls.length > 0) {
1324
- html += '<div style="font-size:11px;color:#555;margin-bottom:4px;">Select calls to mock:</div>';
1325
- calls.forEach(function(call) {
1326
- const isChecked = existing.callIndices && existing.callIndices.includes(call.index);
1327
- const inputPreview = typeof call.input === 'string' ? call.input.slice(0, 40) : JSON.stringify(call.input || '').slice(0, 40);
1328
- let mockVal = (existing.mockData && existing.mockData[call.index] !== undefined) ? JSON.stringify(existing.mockData[call.index]) : JSON.stringify(call.output);
1329
- mockVal = convert(mockVal);
1330
- html += '<div style="margin-bottom:6px;padding:4px;background:#fafafa;border-radius:4px;border:1px solid #eee;">';
1331
- html += '<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;">';
1332
- html += '<input type="checkbox" class="tool-call-checkbox" value="' + call.index + '"' + (isChecked ? ' checked' : '') + ' onchange="window.onToolCallCheckChange(\\'' + esc(name) + '\\',' + call.index + ',this.checked)">';
1333
- html += '<span>Call #' + call.index + '</span>';
1334
- html += '<span style="color:#888;font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + esc(inputPreview) + '</span>';
1335
- html += '</label>';
1336
- if (isChecked) {
1337
- 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>';
1338
- }
1339
- html += '</div>';
1340
- });
1341
- } else {
1342
- html += '<span style="color:#aaa;font-size:11px;">—</span>';
1343
- }
1344
- html += '</td>';
1345
- html += '</tr>';
1346
- });
1347
-
1348
- html += '</tbody></table></div>';
1349
- return html;
1350
- }
1351
-
1352
- window.onToolMockModeChange = function(toolName, mode) {
1353
- if (!window._toolMockConfig[toolName]) window._toolMockConfig[toolName] = { mode: 'live' };
1354
- // Save current mock data before switching
1355
- window._toolMockConfig[toolName] = { ...window._toolMockConfig[toolName], mode: mode };
1356
- if (mode === 'mock-specific' && !window._toolMockConfig[toolName].callIndices) {
1357
- window._toolMockConfig[toolName].callIndices = [];
1358
- }
1359
- // Re-render tool mock section
1360
- const showAll = document.getElementById('showAllToolsToggle');
1361
- const container = document.getElementById('toolMockContainer');
1362
- if (container) container.innerHTML = renderToolMockSection(showAll && showAll.checked);
1363
- };
1364
-
1365
- window.onToolCallCheckChange = function(toolName, callIdx, checked) {
1366
- if (!window._toolMockConfig[toolName]) window._toolMockConfig[toolName] = { mode: 'mock-specific', callIndices: [] };
1367
- const indices = window._toolMockConfig[toolName].callIndices || [];
1368
- if (checked && !indices.includes(callIdx)) {
1369
- indices.push(callIdx);
1370
- } else if (!checked) {
1371
- const pos = indices.indexOf(callIdx);
1372
- if (pos >= 0) indices.splice(pos, 1);
1373
- }
1374
- window._toolMockConfig[toolName].callIndices = indices;
1375
- const showAll = document.getElementById('showAllToolsToggle');
1376
- const container = document.getElementById('toolMockContainer');
1377
- if (container) container.innerHTML = renderToolMockSection(showAll && showAll.checked);
1378
- };
1379
-
1380
- window.openLiveValidationDialog = function() {
1381
- if (window.liveValidationDialog) return;
1382
- window._toolMockConfig = {}; // Reset mock config each time dialog opens
1383
-
1384
- const hasTraceTools = currentObservations.some(function(o) { return o.type === 'TOOL'; });
1385
- const hasRegisteredTools = codeIndex.tools && codeIndex.tools.length > 0;
1386
-
1387
- window.liveValidationDialog = document.createElement('div');
1388
- window.liveValidationDialog.id = 'liveValidationDialog';
1389
- 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;';
1390
- window.liveValidationDialog.innerHTML = \`
1391
- <div style="background:white;padding:32px 28px;border-radius:12px;box-shadow:0 2px 24px #0002;min-width:680px;max-width:90vw;max-height:90vh;overflow-y:auto;">
1392
- <h3 style="margin-top:0;margin-bottom:18px;font-size:20px;">Validate Updated Flow with Live Data</h3>
1393
- <label style="font-size:15px;display:block;margin-bottom:8px;">How many times do you want to run the flow with live data?</label>
1394
- <input id="liveValidationCount" type="number" min="1" value="1" style="width:100%;font-size:16px;padding:6px 10px;margin-bottom:18px;" />
1395
- <label style="display:flex;align-items:center;gap:8px;font-size:14px;margin-bottom:18px;">
1396
- <input id="liveValidationSequential" type="checkbox" />
1397
- Run in sequence instead of parallel
1398
- </label>
1399
- \${(hasTraceTools || hasRegisteredTools) ? \`
1400
- <div style="border-top:1px solid #eee;padding-top:16px;margin-bottom:16px;">
1401
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
1402
- <div style="font-size:15px;font-weight:600;">Tool Mocking</div>
1403
- <label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;">
1404
- <input id="showAllToolsToggle" type="checkbox" onchange="document.getElementById('toolMockContainer').innerHTML = renderToolMockSection(this.checked);" />
1405
- Show all registered tools
1406
- </label>
1407
- </div>
1408
- <div id="toolMockContainer"></div>
1409
- </div>\` : ''}
1410
- <div style="display:flex;gap:12px;justify-content:space-between;align-items:center;">
1411
- <span id="liveValidationProgress" style="font-size:14px;color:#555;"></span>
1412
- <div style="display:flex;gap:12px;">
1413
- <button id="cancelLiveValidation" class="btn btn-secondary">Cancel</button>
1414
- <button id="submitLiveValidation" class="btn btn-primary">Validate</button>
1415
- </div>
1416
- </div>
1417
- </div>
1418
- \`;
1419
- document.body.appendChild(window.liveValidationDialog);
1420
- // Render the tool mock section after DOM insertion
1421
- const toolMockContainer = document.getElementById('toolMockContainer');
1422
- if (toolMockContainer) {
1423
- toolMockContainer.innerHTML = renderToolMockSection(false);
1424
- }
1425
- document.getElementById('cancelLiveValidation').onclick = function() {
1426
- window.liveValidationDialog.remove();
1427
- window.liveValidationDialog = null;
1428
- };
1429
- document.getElementById('submitLiveValidation').onclick = async function() {
1430
- const count = parseInt(document.getElementById('liveValidationCount').value, 10);
1431
- const sequential = document.getElementById('liveValidationSequential').checked;
1432
- if (count >= 1) {
1433
- // Build the tool mock config from UI state
1434
- const toolMockConfig = buildToolMockConfigFromUI();
1435
- const submitBtn = document.getElementById('submitLiveValidation');
1436
- submitBtn.disabled = true;
1437
- submitBtn.textContent = 'Validating...';
1438
- const progressEl = document.getElementById('liveValidationProgress');
1439
-
1440
- function finishValidation(collectedTraces, errorMsg, usedSequential) {
1441
- if (progressEl) progressEl.textContent = '';
1442
- window.liveValidationDialog.remove();
1443
- window.liveValidationDialog = null;
1444
- window.liveValidationCount = count;
1445
- window.liveValidationSequential = usedSequential;
1446
- window.step5SelectedTrace = 0;
1447
- window.step5SelectedObservation = 0;
1448
- currentStep = 5;
1449
- updateModalTitle();
1450
- updateFooterButtons();
1451
- if (errorMsg && collectedTraces.length === 0) {
1452
- step5RunTraces = [];
1453
- localStorage.removeItem('ed_step5RunTraces');
1454
- step5RunMeta = { loading: false, error: errorMsg, runCount: count, sequential: usedSequential };
1455
- } else {
1456
- step5RunTraces = collectedTraces;
1457
- persistTraces();
1458
- step5RunMeta = { loading: false, error: '', runCount: collectedTraces.length, sequential: usedSequential };
1459
- }
1460
- if (window.step5SelectedTrace > step5RunTraces.length) window.step5SelectedTrace = 0;
1461
- window.step5SelectedObservation = 0;
1462
- renderObservationTable();
1463
- }
1464
-
1465
- if (sequential) {
1466
- // Sequential mode: fire one request per run so progress reflects real completion
1467
- if (progressEl) progressEl.textContent = \`0 of \${count} workflow runs completed\`;
1468
- const collectedTraces = [];
1469
- let fatalError = null;
1470
- for (let i = 0; i < count; i++) {
1471
- const singlePayload = { workflowName: selectedWorkflow?.name, runCount: 1, sequential: false, observations: currentObservations, toolMockConfig };
1472
- try {
1473
- const response = await fetch('/api/validate-workflow', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(singlePayload) });
1474
- const data = await response.json();
1475
- if (response.ok && data.ok && Array.isArray(data.traces) && data.traces.length > 0) {
1476
- collectedTraces.push({ ...data.traces[0], runNumber: i + 1 });
1477
- } else {
1478
- // Push an error trace so the run is still visible in Step 5
1479
- collectedTraces.push({ runNumber: i + 1, ok: false, error: data.error || 'Workflow validation failed.', observations: [], workflowTrace: null });
1480
- }
1481
- } catch (err) {
1482
- collectedTraces.push({ runNumber: i + 1, ok: false, error: err && err.message ? err.message : String(err), observations: [], workflowTrace: null });
1483
- }
1484
- if (progressEl) progressEl.textContent = \`\${i + 1} of \${count} workflow runs completed\`;
1485
- }
1486
- finishValidation(collectedTraces, fatalError, true);
1487
- } else {
1488
- // Parallel mode: single bulk request
1489
- if (progressEl) progressEl.textContent = \`Running \${count} workflow run\${count !== 1 ? 's' : ''} in parallel…\`;
1490
- const payload = { workflowName: selectedWorkflow?.name, runCount: count, sequential: false, observations: currentObservations, toolMockConfig };
1491
- try {
1492
- const response = await fetch('/api/validate-workflow', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
1493
- const data = await response.json();
1494
- if (response.ok && data.ok) {
1495
- finishValidation(Array.isArray(data.traces) ? data.traces : [], null, false);
1496
- } else {
1497
- finishValidation([], data.error || 'Workflow validation failed.', false);
1498
- }
1499
- } catch (err) {
1500
- finishValidation([], err && err.message ? err.message : String(err), false);
1501
- }
1502
- }
1503
- } else {
1504
- document.getElementById('liveValidationCount').style.borderColor = 'red';
1505
- }
1506
- };
1507
- };
1508
-
1509
- window.openPromptConfirmation = function(onConfirm) {
1510
- const genObs = currentObservations
1511
- .map((o, i) => ({ obs: o, idx: i }))
1512
- .filter(({ obs, idx }) => obs.type === 'GENERATION' && checkedObservations.has(idx));
1513
- if (genObs.length === 0) { onConfirm(); return; }
1514
-
1515
- const tableRows = genObs.map(({ obs, idx }) => {
1516
- const preview = toDisplayText(obs.input, obs.type).replace(/\\s+/g, ' ').trim().slice(0, 50);
1517
- const model = obs.model || '—';
1518
- return \`<tr>
1519
- <td style="padding:8px 10px;font-size:13px;color:#555;">\${idx + 1}</td>
1520
- <td style="padding:8px 10px;font-size:13px;font-family:Monaco,monospace;">\${esc(obs.name || 'AI call')}</td>
1521
- <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>
1522
- <td style="padding:8px 10px;font-size:12px;font-family:Monaco,monospace;color:#555;">\${esc(model)}</td>
1523
- </tr>\`;
1524
- }).join('');
1525
-
1526
- const dlg = document.createElement('div');
1527
- 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;';
1528
- dlg.innerHTML = \`
1529
- <div style="background:white;padding:32px 28px;border-radius:12px;box-shadow:0 2px 24px #0003;min-width:640px;max-width:92vw;">
1530
- <h3 style="margin-top:0;margin-bottom:8px;font-size:20px;">Have you updated your AI prompts?</h3>
1531
- <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>
1532
- <div style="border:1px solid #e0e0e0;border-radius:6px;overflow:hidden;margin-bottom:24px;">
1533
- <table style="width:100%;border-collapse:collapse;">
1534
- <thead>
1535
- <tr style="background:#f5f5f5;">
1536
- <th style="padding:8px 10px;text-align:left;font-size:12px;color:#555;border-bottom:1px solid #e0e0e0;">#</th>
1537
- <th style="padding:8px 10px;text-align:left;font-size:12px;color:#555;border-bottom:1px solid #e0e0e0;">Name</th>
1538
- <th style="padding:8px 10px;text-align:left;font-size:12px;color:#555;border-bottom:1px solid #e0e0e0;">Input preview</th>
1539
- <th style="padding:8px 10px;text-align:left;font-size:12px;color:#555;border-bottom:1px solid #e0e0e0;">Model</th>
1540
- </tr>
1541
- </thead>
1542
- <tbody>\${tableRows}</tbody>
1543
- </table>
1544
- </div>
1545
- <div style="display:flex;gap:12px;justify-content:flex-end;">
1546
- <button id="cancelPromptConfirm" class="btn btn-secondary">Cancel</button>
1547
- <button id="proceedPromptConfirm" class="btn btn-primary">Yes, Proceed</button>
1548
- </div>
1549
- </div>
1550
- \`;
1551
- document.body.appendChild(dlg);
1552
- dlg.querySelector('#cancelPromptConfirm').onclick = () => dlg.remove();
1553
- dlg.querySelector('#proceedPromptConfirm').onclick = () => { dlg.remove(); onConfirm(); };
1554
- };
1555
-
1556
- uploadArea.onclick = () => fileInput.click();
1557
-
1558
- // Drag and drop handlers
1559
- uploadArea.ondragover = (e) => {
1560
- e.preventDefault();
1561
- e.stopPropagation();
1562
- uploadArea.style.borderColor = '#0066cc';
1563
- uploadArea.style.background = '#f0f7ff';
1564
- };
1565
-
1566
- uploadArea.ondragleave = (e) => {
1567
- e.preventDefault();
1568
- e.stopPropagation();
1569
- uploadArea.style.borderColor = '#ddd';
1570
- uploadArea.style.background = '#fafafa';
1571
- };
1572
-
1573
- uploadArea.ondrop = (e) => {
1574
- e.preventDefault();
1575
- e.stopPropagation();
1576
- uploadArea.style.borderColor = '#ddd';
1577
- uploadArea.style.background = '#fafafa';
1578
-
1579
- const files = e.dataTransfer.files;
1580
- if (files.length === 0) return;
1581
-
1582
- const file = files[0];
1583
- // Check if it's a JSON file
1584
- if (!file.name.toLowerCase().endsWith('.json')) {
1585
- uploadStatus.className = "upload-status error";
1586
- uploadStatus.textContent = "Please drop a JSON file";
1587
- return;
1588
- }
1589
-
1590
- handleFileUpload(file);
1591
- };
1592
-
1593
- fileInput.onchange = (e) => {
1594
- if (!e.target.files[0]) return;
1595
- const file = e.target.files[0];
1596
- // Always clear file input so same file can be uploaded again
1597
- fileInput.value = "";
1598
- handleFileUpload(file);
1599
- };
1600
-
1601
- function handleFileUpload(file) {
1602
- // Clear observations before loading new trace
1603
- resetTraceModal();
1604
- const reader = new FileReader();
1605
- reader.onload = (e) => {
1606
- try {
1607
- const data = JSON.parse(e.target.result);
1608
- uploadStatus.className = "upload-status";
1609
- uploadStatus.textContent = "";
1610
- displayTrace(data);
1611
- } catch (err) {
1612
- uploadArea.classList.remove("hidden");
1613
- traceViewer.classList.remove("visible");
1614
- uploadStatus.className = "upload-status error";
1615
- uploadStatus.textContent = "Invalid JSON";
1616
- }
1617
- };
1618
- reader.readAsText(file);
1619
- }
1620
-
1621
- function displayTrace(data) {
1622
- let obs = [];
1623
- if (Array.isArray(data)) {
1624
- obs = data.filter(o =>
1625
- (o.type === "GENERATION" || o.type === "TOOL" || o.type === "SPAN") &&
1626
- (o.input !== null && o.input !== undefined) &&
1627
- (o.output !== null && o.output !== undefined)
1628
- );
1629
- } else {
1630
- const trace = data.trace || data;
1631
- // Handle multiple formats: data.data, data.observations, trace.observations
1632
- const rawObs = data.data || data.observations || trace.observations || [];
1633
- obs = rawObs.filter(o =>
1634
- (o.type === "GENERATION" || o.type === "TOOL" || o.type === "SPAN") &&
1635
- (o.input !== null && o.input !== undefined) &&
1636
- (o.output !== null && o.output !== undefined)
1637
- );
1638
- }
1639
- // Sort by startTime ascending
1640
- obs = obs.sort((a, b) => {
1641
- const timeA = new Date(a.startTime || 0).getTime();
1642
- const timeB = new Date(b.startTime || 0).getTime();
1643
- return timeA - timeB;
1644
- });
1645
- currentObservations = obs;
1646
- selectedObservationIndex = -1;
1647
- checkedObservations.clear();
1648
- observationDetail.innerHTML = "";
1649
- uploadArea.classList.add("hidden");
1650
- traceViewer.classList.add("visible");
1651
- currentStep = 3;
1652
- updateModalTitle();
1653
- updateFooterButtons();
1654
- renderObservationTable();
1655
- // Auto-select first observation
1656
- if (currentObservations.length > 0) {
1657
- selectObservation(0);
1658
- }
1659
- }
1660
-
1661
- function renderObservationTable() {
1662
- // Capture scroll positions for step 5 columns before rebuilding DOM
1663
- let _step5Col1Scroll = 0, _step5Col2Scroll = 0;
1664
- if (currentStep === 5) {
1665
- const tls = document.querySelectorAll('.trace-left');
1666
- _step5Col1Scroll = tls[0]?.querySelector('.observation-table-wrap')?.scrollTop ?? 0;
1667
- _step5Col2Scroll = tls[1]?.querySelector('.observation-table-wrap')?.scrollTop ?? 0;
1668
- }
1669
- // For other steps, preserve scroll position of the first .observation-table-wrap
1670
- let obsTableWrap = document.querySelector('.observation-table-wrap');
1671
- let prevScrollTop = obsTableWrap ? obsTableWrap.scrollTop : null;
1672
- if (currentStep === 5) {
1673
- // Initialize selections if not set
1674
- if (window.step5SelectedTrace === undefined || window.step5SelectedTrace === null) {
1675
- window.step5SelectedTrace = 0;
1676
- }
1677
- if (window.step5SelectedObservation === undefined || window.step5SelectedObservation === null) {
1678
- window.step5SelectedObservation = 0;
1679
- }
1680
- // Step 5: Validate Updated Flow with Live Data
1681
- // Render traceTable before observationsTable
1682
- const traces = Array.isArray(step5RunTraces) ? step5RunTraces : [];
1683
- const traceCount = traces.length + 1;
1684
- document.getElementsByClassName("trace-layout")[0].classList.add("step-5");
1685
- let traceTable = \`<div class="trace-section-title">Traces</div>
1686
- <div class="observation-table-wrap">
1687
- <table class="observation-table">
1688
- <tbody>\`;
1689
- for (let i = 0; i < traceCount; i++) {
1690
- const isSelected = i === window.step5SelectedTrace;
1691
- const run = i === 0 ? null : traces[i - 1];
1692
- const status = run ? (run.ok ? ' ✓' : ' ✗') : '';
1693
- const label = i === 0 ? "Original Trace" : \`\${run?.traceName ?? \`Trace-\${run?.runNumber ?? i}\`}\${status}\`;
1694
- traceTable += \`<tr class="\${isSelected ? "selected" : ""}" onclick="window.step5SelectedTrace=\${i};window.step5SelectedObservation=0;renderObservationTable();"><td>\${label}</td></tr>\`;
1695
- }
1696
- traceTable += \`</tbody></table></div>\`;
1697
-
1698
- // Observations table for selected trace
1699
- let observationsTable = "";
1700
- let detailsSection = "";
1701
-
1702
- if (window.step5SelectedTrace > traces.length) {
1703
- window.step5SelectedTrace = 0;
1704
- }
1705
-
1706
- if (window.step5SelectedTrace === 0) {
1707
- // Original Trace: show all currentObservations (same as step 3)
1708
- observationsTable += \`<div class="trace-section-title">Observations</div>
1709
- <div class="observation-table-wrap">
1710
- <table class="observation-table">
1711
- <thead><tr><th>Name</th><th>Type</th></tr></thead>
1712
- <tbody>\`;
1713
- observationsTable += currentObservations.map((obs, j) => {
1714
- const isSelected = j === window.step5SelectedObservation;
1715
- const name = obs.name || obs.id || ("Observation " + (j + 1));
1716
- const type = obs.type || "UNKNOWN";
1717
- const typeClass = type === "TOOL" ? "tool" : "ai";
1718
- const agentBadge = obs.agentTaskIndex != null ? \`<span class="agent-task-badge">T\${obs.agentTaskIndex + 1}</span>\` : '';
1719
- const rowClass = obs.agentTaskIndex != null ? 'agent-task-row' : '';
1720
- 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></tr>\`;
1721
- }).join("");
1722
- observationsTable += \`</tbody></table></div>\`;
1723
- // Details for selected observation
1724
- const selObs = currentObservations[window.step5SelectedObservation];
1725
- if (selObs) {
1726
- const inputText = toDisplayText(selObs.input, selObs.type);
1727
- const outputText = toDisplayText(selObs.output, selObs.type);
1728
- const detailId = 'step5-orig-' + window.step5SelectedObservation;
1729
- const filePathHtml = selObs.type === "GENERATION"
1730
- ? '<div class="file-path-placeholder"></div>'
1731
- : renderFilePath(getObsFilePath(selObs));
1732
- detailsSection = \`<div class="detail-sections" id="\${detailId}">
1733
- \${filePathHtml}
1734
- \${renderModel(selObs)}
1735
- <div class="detail-section">
1736
- <div class="detail-title">Input</div>
1737
- <pre class="detail-pre">\${esc(inputText)}</pre>
1738
- </div>
1739
- <div class="detail-section">
1740
- <div class="detail-title">Output</div>
1741
- <pre class="detail-pre">\${esc(outputText)}</pre>
1742
- </div>
1743
- </div>\`;
1744
- if (selObs.type === "GENERATION") setTimeout(() => resolveGenFilePath(selObs, detailId), 0);
1745
- }
1746
- } else {
1747
- // Live traces: index 1 → traces[0], index 2 → traces[1], …
1748
- const liveTrace = traces[window.step5SelectedTrace - 1];
1749
- if (liveTrace) {
1750
- const actions = Array.isArray(liveTrace.observations) ? liveTrace.observations : [];
1751
- const traceIdx = window.step5SelectedTrace - 1;
1752
- observationsTable += \`<div class="trace-section-title">Observations</div>
1753
- <div class="observation-table-wrap">
1754
- <table class="observation-table">
1755
- <thead><tr><th>Name</th><th>Type</th></tr></thead>
1756
- <tbody>\`;
1757
- observationsTable += actions.map((action, j) => {
1758
- const isSelected = j === window.step5SelectedObservation;
1759
- const name = action.name || action.id || ("Observation " + (j + 1));
1760
- const type = action.type || "UNKNOWN";
1761
- const typeClass = type === "TOOL" ? "tool" : "ai";
1762
- const agentBadge = action.agentTaskIndex != null ? \`<span class="agent-task-badge">T\${action.agentTaskIndex + 1}</span>\` : '';
1763
- const frozenBadge = action.isFrozen ? '<span class="frozen-tag">Frozen</span>' : '';
1764
- const rowClasses = [
1765
- isSelected ? 'selected' : '',
1766
- action.agentTaskIndex != null ? 'agent-task-row' : '',
1767
- action.isFrozen ? 'frozen-row' : '',
1768
- ].filter(Boolean).join(' ');
1769
- 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></tr>\`;
1770
- }).join("");
1771
- observationsTable += \`</tbody></table></div>\`;
1772
- // Details for selected observation
1773
- if (actions[window.step5SelectedObservation]) {
1774
- const obs = actions[window.step5SelectedObservation];
1775
- const inputText = toDisplayText(obs.input, obs.type);
1776
- const outputText = toDisplayText(obs.output, obs.type);
1777
- const detailId = 'step5-live-' + (window.step5SelectedTrace - 1) + '-' + window.step5SelectedObservation;
1778
- const filePathHtml = obs.type === "GENERATION"
1779
- ? '<div class="file-path-placeholder"></div>'
1780
- : renderFilePath(getObsFilePath(obs));
1781
-
1782
- // For the first observation (workflow output), show Original Output for comparison
1783
- let originalOutputSection = '';
1784
- if (window.step5SelectedObservation === 0 && currentObservations[0]) {
1785
- const originalOutputText = toDisplayText(currentObservations[0].output, currentObservations[0].type);
1786
- originalOutputSection = \`<div class="detail-section">
1787
- <div class="detail-title">Original Output</div>
1788
- <pre class="detail-pre">\${esc(originalOutputText)}</pre>
1789
- </div>\`;
1790
- }
1791
-
1792
- // Agent steps: always show "Resume from Task X" button
1793
- // Non-agent steps: show "Run from here" only if they have workflowEventId (HTTP/DB events)
1794
- // Steps without agentTaskIndex and without workflowEventId get no button
1795
- let runFromBpHtml = '';
1796
- if (obs.agentTaskIndex != null) {
1797
- 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>\`;
1798
- } else if (obs.workflowEventId != null) {
1799
- 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>\`;
1800
- }
1801
- detailsSection = \`<div class="detail-sections" id="\${detailId}">
1802
- \${filePathHtml}
1803
- \${runFromBpHtml}
1804
- \${renderModel(obs)}
1805
- <div class="detail-section">
1806
- <div class="detail-title">Input</div>
1807
- <pre class="detail-pre">\${esc(inputText)}</pre>
1808
- </div>
1809
- \${originalOutputSection}
1810
- <div class="detail-section">
1811
- <div class="detail-title">Output</div>
1812
- <pre class="detail-pre">\${esc(outputText)}</pre>
1813
- </div>
1814
- </div>\`;
1815
- if (obs.type === "GENERATION") setTimeout(() => resolveGenFilePath(obs, detailId), 0);
1816
- }
1817
- }
1818
- }
1819
-
1820
- if (step5RunMeta.loading) {
1821
- detailsSection = \`<div class="detail-sections">
1822
- <div class="detail-section">
1823
- <div class="detail-title">Validation</div>
1824
- <pre class="detail-pre">Running \${step5RunMeta.runCount || 1} workflow run(s) in \${step5RunMeta.sequential ? 'sequence' : 'parallel'} mode...</pre>
1825
- </div>
1826
- </div>\`;
1827
- } else if (step5RunMeta.error && !detailsSection) {
1828
- detailsSection = \`<div class="detail-sections">
1829
- <div class="detail-section">
1830
- <div class="detail-title">Validation Error</div>
1831
- <pre class="detail-pre">\${esc(step5RunMeta.error)}</pre>
1832
- </div>
1833
- </div>\`;
1834
- }
1835
-
1836
- // Render 3 sibling columns inside the CSS grid
1837
- const traceLayout = document.getElementsByClassName("trace-layout")[0];
1838
- traceLayout.innerHTML = \`
1839
- <div class="trace-left">\${traceTable}</div>
1840
- <div class="trace-left">\${observationsTable || '<div class="trace-section-title">Observations</div>'}</div>
1841
- <div class="trace-right">\${detailsSection}</div>
1842
- \`;
1843
- // Restore scroll positions after DOM rebuild
1844
- const _newTls = traceLayout.querySelectorAll('.trace-left');
1845
- const _w1 = _newTls[0]?.querySelector('.observation-table-wrap');
1846
- const _w2 = _newTls[1]?.querySelector('.observation-table-wrap');
1847
- if (_w1) _w1.scrollTop = _step5Col1Scroll;
1848
- if (_w2) _w2.scrollTop = _step5Col2Scroll;
1849
- return;
1850
- }
1851
-
1852
- if (currentStep === 4) {
1853
- const traceLayout = document.getElementsByClassName("trace-layout")[0];
1854
- traceLayout.classList.remove("step-5");
1855
- traceLayout.classList.add("step-4");
1856
- const obsIndices = Array.from(checkedObservations);
1857
-
1858
- // Column 1: Steps (observation list)
1859
- let col1 = \`<div class="trace-section-title">Steps</div>
1860
- <div class="observation-table-wrap">
1861
- <table class="observation-table">
1862
- <thead><tr><th>Name</th><th>Type</th></tr></thead>
1863
- <tbody>\`;
1864
- col1 += obsIndices.map(idx => {
1865
- const obs = currentObservations[idx];
1866
- const isSelected = idx === selectedObservationIndex;
1867
- const name = obs.name || obs.id || ('Observation ' + (idx + 1));
1868
- const type = obs.type || 'UNKNOWN';
1869
- const typeClass = type === 'TOOL' ? 'tool' : 'ai';
1870
- const history = rerunHistory.get(idx) || [];
1871
- const latest = history[history.length - 1];
1872
- const badge = latest
1873
- ? (latest.running ? ' <span class="rerun-status running">⟳</span>'
1874
- : latest.ok ? ' <span class="rerun-status success">✓</span>'
1875
- : ' <span class="rerun-status error">✗</span>')
1876
- : '';
1877
- return \`<tr class="\${isSelected ? 'selected' : ''}" onclick="window.step4SelectObservation(\${idx})"><td>\${esc(name)}\${badge}</td><td><span class="obs-type \${typeClass}">\${esc(type)}</span></td></tr>\`;
1878
- }).join('');
1879
- col1 += \`</tbody></table></div>\`;
1880
-
1881
- // Column 2: Runs for the selected observation
1882
- const inFlight = selectedObservationIndex >= 0 && rerunInFlight.has(selectedObservationIndex);
1883
- let col2 = \`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
1884
- <div class="trace-section-title" style="margin-bottom:0">Runs</div>
1885
- \${selectedObservationIndex >= 0 ? \`<button class="btn btn-primary" style="padding:4px 12px;font-size:12px;" onclick="rerunObservation(\${selectedObservationIndex})" \${inFlight ? 'disabled' : ''}>\${inFlight ? 'Running...' : 'Rerun'}</button>\` : ''}
1886
- </div>\`;
1887
- if (selectedObservationIndex >= 0) {
1888
- const history = rerunHistory.get(selectedObservationIndex) || [];
1889
- if (history.length === 0) {
1890
- col2 += \`<div style="color:#999;font-size:13px;padding:8px;">No runs yet. Click Rerun to start.</div>\`;
1891
- } else {
1892
- col2 += \`<div class="observation-table-wrap"><table class="observation-table"><thead><tr><th>Run</th><th>Status</th></tr></thead><tbody>\`;
1893
- col2 += history.map((run, ri) => {
1894
- const isSelRun = ri === step4SelectedRun;
1895
- const status = run.running ? '⟳ Running' : (run.ok ? '✓ Complete' : '✗ Failed');
1896
- return \`<tr class="\${isSelRun ? 'selected' : ''}" onclick="window.step4SelectRun(\${ri})"><td>Run \${run.runNumber}</td><td>\${status}</td></tr>\`;
1897
- }).join('');
1898
- col2 += \`</tbody></table></div>\`;
1899
- }
1900
- } else {
1901
- col2 += \`<div style="color:#999;font-size:13px;padding:8px;">Select a step to view runs.</div>\`;
1902
- }
1903
-
1904
- // Column 3: Details for the selected run
1905
- let col3 = '';
1906
- if (selectedObservationIndex >= 0) {
1907
- const obs = currentObservations[selectedObservationIndex];
1908
- const history = rerunHistory.get(selectedObservationIndex) || [];
1909
- const run = step4SelectedRun >= 0 ? history[step4SelectedRun] : null;
1910
- const inputText = toDisplayText(obs.input, obs.type);
1911
- const outputText = toDisplayText(obs.output, obs.type);
1912
- const detailId = 'step4-detail-' + selectedObservationIndex + '-' + step4SelectedRun;
1913
- const filePathHtml = obs.type === "GENERATION"
1914
- ? '<div class="file-path-placeholder"></div>'
1915
- : renderFilePath(getObsFilePath(obs));
1916
- let currentOutputSection = '';
1917
- if (run) {
1918
- if (run.running) {
1919
- currentOutputSection = \`<div class="detail-section"><div class="detail-title">Current Output</div><pre class="detail-pre">Running...</pre></div>\`;
1920
- } else if (run.ok) {
1921
- currentOutputSection = \`<div class="detail-section"><div class="detail-title">Current Output</div><pre class="detail-pre">\${esc(toDisplayText(run.output, obs.type))}</pre></div>\`;
1922
- } else {
1923
- 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>\`;
1924
- }
1925
- }
1926
- // Editable AI input UI
1927
- let inputSection = '';
1928
- if (obs.type === "GENERATION") {
1929
- const hasUpdate = updatedInputs.has(selectedObservationIndex);
1930
- inputSection += \`<div class="detail-section">
1931
- <div class="detail-title">Input</div>
1932
- <pre class="detail-pre" style="position:relative;">\${esc(toDisplayText(obs.input, obs.type))}
1933
- <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>
1934
- <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>
1935
- </pre>
1936
- </div>\`;
1937
- if (hasUpdate) {
1938
- inputSection += \`<div class="detail-section updated-input-section">
1939
- <div class="detail-title">Update Input</div>
1940
- <textarea id="editInputTextarea" class="detail-pre" style="width:100%;height:400px;">\${esc(updatedInputs.get(selectedObservationIndex))}</textarea>
1941
- <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>
1942
- </div>\`;
1943
- }
1944
- } else {
1945
- inputSection += \`<div class="detail-section"><div class="detail-title">Input</div><pre class="detail-pre">\${esc(toDisplayText(obs.input, obs.type))}</pre></div>\`;
1946
- }
1947
- col3 = \`<div class="detail-sections" id="\${detailId}">
1948
- \${filePathHtml}
1949
- \${renderModel(obs)}
1950
- \${inputSection}
1951
- <div class="detail-section"><div class="detail-title">Output</div><pre class="detail-pre">\${esc(outputText)}</pre></div>
1952
- \${currentOutputSection}
1953
- </div>\`;
1954
- if (obs.type === "GENERATION") setTimeout(() => resolveGenFilePath(obs, detailId), 0);
1955
- }
1956
-
1957
- traceLayout.innerHTML = \`
1958
- <div class="trace-left">\${col1}</div>
1959
- <div class="trace-left">\${col2}</div>
1960
- <div class="trace-right">\${col3}</div>
1961
- \`;
1962
- // Auto-select first observation if none selected
1963
- if (obsIndices.length > 0 && selectedObservationIndex === -1) {
1964
- window.step4SelectObservation(obsIndices[0]);
1965
- }
1966
- return;
1967
- }
1968
-
1969
- const traceLayoutEl = document.getElementsByClassName("trace-layout")[0];
1970
- if (traceLayoutEl.classList.contains("step-5") || traceLayoutEl.classList.contains("step-4")) {
1971
- traceLayoutEl.classList.remove("step-5");
1972
- traceLayoutEl.classList.remove("step-4");
1973
- let headerHtml = '';
1974
- if (currentStep === 3) {
1975
- headerHtml = '<tr><th style="width: 40px;">Check</th><th>Name</th><th>Type</th></tr>';
1976
- }
1977
- traceLayoutEl.innerHTML = \`
1978
- <div class="trace-left">
1979
- <div class="trace-section-title">Observations</div>
1980
- <div class="observation-table-wrap">
1981
- <table class="observation-table">
1982
- <thead id="observationTableHead">\${headerHtml}</thead>
1983
- <tbody id="observationTableBody"></tbody>
1984
- </table>
1985
- </div>
1986
- </div>
1987
- <div class="trace-right">
1988
- <div id="observationDetail"></div>
1989
- </div>
1990
- \`;
1991
- observationTableBody = document.getElementById("observationTableBody");
1992
- observationDetail = document.getElementById("observationDetail");
1993
- }
1994
-
1995
- const obsToRender = currentObservations;
1996
- const indices = currentObservations.map((_, i) => i);
1997
-
1998
- if (!obsToRender.length) {
1999
- observationTableBody.innerHTML = '<tr><td colspan="3" style="padding: 16px; color: #777;">No observations found.</td></tr>';
2000
- return;
2001
- }
2002
-
2003
- observationTableBody.innerHTML = obsToRender.map((obs, displayIndex) => {
2004
- const actualIndex = indices[displayIndex];
2005
- const isSelected = actualIndex === selectedObservationIndex;
2006
- const isChecked = checkedObservations.has(actualIndex);
2007
- const name = obs.name || obs.id || ("Observation " + (displayIndex + 1));
2008
- const type = obs.type || "UNKNOWN";
2009
- const typeClass = type === "TOOL" ? "tool" : "ai";
2010
- // Step 3: Mark broken - show checkboxes
2011
- return \`<tr class="\${isSelected ? "selected" : ""}">
2012
- <td style="width: 40px;"><input type="checkbox" class="obs-checkbox" value="\${actualIndex}" \${isChecked ? "checked" : ""}></td>
2013
- <td onclick="selectObservation(\${actualIndex})">\${esc(name)}</td>
2014
- <td><span class="obs-type \${typeClass}">\${esc(type)}</span></td>
2015
- </tr>\`;
2016
- }).join("");
2017
-
2018
- document.querySelectorAll(".obs-checkbox").forEach(checkbox => {
2019
- checkbox.onchange = (e) => {
2020
- const idx = parseInt(e.target.value);
2021
- if (e.target.checked) {
2022
- checkedObservations.add(idx);
2023
- } else {
2024
- checkedObservations.delete(idx);
2025
- }
2026
- };
2027
- });
2028
- // Restore scroll position if previously saved
2029
- if (currentStep === 5 && prevStep5ObsScrollTop !== null) {
2030
- // After rendering, restore scroll for the observations table in the second .trace-left
2031
- const traceLefts = document.querySelectorAll('.trace-left');
2032
- if (traceLefts.length > 1) {
2033
- const obsWrap = traceLefts[1].querySelector('.observation-table-wrap');
2034
- if (obsWrap) obsWrap.scrollTop = prevStep5ObsScrollTop;
2035
- }
2036
- } else {
2037
- obsTableWrap = document.querySelector('.observation-table-wrap');
2038
- if (obsTableWrap && prevScrollTop !== null) {
2039
- obsTableWrap.scrollTop = prevScrollTop;
2040
- }
2041
- }
2042
- }
2043
-
2044
- function selectObservation(index) {
2045
- // Preserve scroll position of observation-table-wrap
2046
- let obsTableWrap = document.querySelector('.observation-table-wrap');
2047
- let prevScrollTop = obsTableWrap ? obsTableWrap.scrollTop : null;
2048
- selectedObservationIndex = index;
2049
- renderObservationTable();
2050
- // Restore scroll position after rendering
2051
- obsTableWrap = document.querySelector('.observation-table-wrap');
2052
- if (obsTableWrap && prevScrollTop !== null) {
2053
- obsTableWrap.scrollTop = prevScrollTop;
2054
- }
2055
- const obs = currentObservations[index];
2056
- const inputText = toDisplayText(obs.input, obs.type);
2057
- const outputText = toDisplayText(obs.output, obs.type);
2058
- const detailId = 'obs-detail-' + index;
2059
- const filePathHtml = obs.type === "GENERATION"
2060
- ? '<div class="file-path-placeholder"></div>'
2061
- : renderFilePath(getObsFilePath(obs));
2062
-
2063
- observationDetail.innerHTML = \`<div class="detail-sections" id="\${detailId}">
2064
- \${filePathHtml}
2065
- \${renderModel(obs)}
2066
- <div class="detail-section">
2067
- <div class="detail-title">Input</div>
2068
- <pre class="detail-pre">\${esc(inputText)}</pre>
2069
- </div>
2070
- <div class="detail-section">
2071
- <div class="detail-title">Output</div>
2072
- <pre class="detail-pre">\${esc(outputText)}</pre>
2073
- </div>
2074
- </div>\`;
2075
- if (obs.type === "GENERATION") resolveGenFilePath(obs, detailId);
2076
- }
2077
-
2078
- async function rerunObservation(index) {
2079
- const obs = currentObservations[index];
2080
- let inputToUse = obs.input;
2081
- if (obs.type === "GENERATION" && updatedInputs.has(index)) {
2082
- inputToUse = updatedInputs.get(index);
2083
- }
2084
- // Proceed with rerun logic using inputToUse
2085
- console.log('Rerunning observation with input:', inputToUse);
2086
- const history = rerunHistory.get(index) || [];
2087
- const newRun = { runNumber: history.length + 1, running: true, ok: null };
2088
- history.push(newRun);
2089
- rerunHistory.set(index, history);
2090
- rerunInFlight.add(index);
2091
- if (selectedObservationIndex === index) {
2092
- step4SelectedRun = history.length - 1;
2093
- }
2094
- renderObservationTable();
2095
- try {
2096
- const payload = { observation: { ...obs, input: inputToUse } };
2097
- const response = await fetch('/api/rerun-observation', {
2098
- method: 'POST',
2099
- headers: { 'Content-Type': 'application/json' },
2100
- body: JSON.stringify(payload),
2101
- });
2102
- const data = await response.json();
2103
- newRun.running = false;
2104
- if (response.ok && data.ok) {
2105
- newRun.ok = true;
2106
- newRun.output = data.currentOutput;
2107
- } else {
2108
- newRun.ok = false;
2109
- newRun.error = data.error || 'Rerun failed.';
2110
- }
2111
- } catch (err) {
2112
- newRun.running = false;
2113
- newRun.ok = false;
2114
- newRun.error = err && err.message ? err.message : String(err);
2115
- } finally {
2116
- rerunInFlight.delete(index);
2117
- renderObservationTable();
2118
- }
2119
- }
2120
-
2121
- async function runFromBreakpoint(traceIdx, obsIdx, evt) {
2122
- const btn = evt.target;
2123
- const liveTrace = step5RunTraces[traceIdx];
2124
- if (!liveTrace) return;
2125
- const obs = liveTrace.observations[obsIdx];
2126
- if (!obs || obs.workflowEventId == null) return;
2127
-
2128
- btn.disabled = true;
2129
- btn.textContent = 'Running…';
2130
-
2131
- try {
2132
- const payload = {
2133
- workflowName: selectedWorkflow ? selectedWorkflow.name : '',
2134
- checkpoint: obs.workflowEventId,
2135
- snapshotId: liveTrace.snapshotId,
2136
- observations: currentObservations,
2137
- };
2138
- const response = await fetch('/api/run-from-breakpoint', {
2139
- method: 'POST',
2140
- headers: { 'Content-Type': 'application/json' },
2141
- body: JSON.stringify(payload),
2142
- });
2143
- const data = await response.json();
2144
- if (response.ok) {
2145
- // Sub-trace naming: "Trace-N-M" where N = parent name, M = resume count from parent
2146
- const parentTraceName = liveTrace.traceName ?? \`Trace-\${traceIdx + 1}\`;
2147
- const siblingCount = step5RunTraces.filter(t => t.parentTraceName === parentTraceName).length;
2148
- const traceName = \`\${parentTraceName}-\${siblingCount + 1}\`;
2149
- const newTrace = { ...data, runNumber: step5RunTraces.length + 1, traceName, parentTraceName };
2150
- step5RunTraces.push(newTrace);
2151
- persistTraces();
2152
- renderObservationTable();
2153
- } else {
2154
- btn.textContent = '✗ ' + (data.error || 'Failed');
2155
- btn.disabled = false;
2156
- }
2157
- } catch (err) {
2158
- btn.textContent = '✗ Error';
2159
- btn.disabled = false;
2160
- }
2161
- }
2162
-
2163
- async function resumeAgentFromTask(traceIdx, obsIdx, taskIndex, evt) {
2164
- const btn = evt.target;
2165
- const liveTrace = step5RunTraces[traceIdx];
2166
- if (!liveTrace) return;
2167
-
2168
- // Extract AgentPlan from the workflow's currentOutput
2169
- const currentOutput = liveTrace.currentOutput;
2170
- const agentPlan = currentOutput && typeof currentOutput === 'object' && Array.isArray(currentOutput.tasks)
2171
- ? currentOutput
2172
- : null;
2173
- if (!agentPlan) {
2174
- alert('No agent plan found in this trace. Ensure the workflow returns an AgentPlan from executorAgent() or resumeAgentFromTrace().');
2175
- return;
2176
- }
2177
-
2178
- btn.disabled = true;
2179
- btn.textContent = 'Resuming…';
2180
-
2181
- try {
2182
- const payload = {
2183
- workflowName: selectedWorkflow ? selectedWorkflow.name : '',
2184
- taskIndex: taskIndex,
2185
- agentState: {
2186
- plan: agentPlan,
2187
- trace: [],
2188
- resumeFromTaskIndex: taskIndex,
2189
- },
2190
- snapshotId: liveTrace.snapshotId,
2191
- };
2192
- const response = await fetch('/api/resume-agent-from-task', {
2193
- method: 'POST',
2194
- headers: { 'Content-Type': 'application/json' },
2195
- body: JSON.stringify(payload),
2196
- });
2197
- const data = await response.json();
2198
- if (response.ok) {
2199
- // Sub-trace naming: "Trace-N-M" where N = parent name, M = resume count from parent
2200
- const parentTraceName = liveTrace.traceName ?? \`Trace-\${traceIdx + 1}\`;
2201
- const siblingCount = step5RunTraces.filter(t => t.parentTraceName === parentTraceName).length;
2202
- const traceName = \`\${parentTraceName}-\${siblingCount + 1}\`;
2203
- const newTrace = { ...data, runNumber: step5RunTraces.length + 1, traceName, parentTraceName };
2204
- step5RunTraces.push(newTrace);
2205
- persistTraces();
2206
- renderObservationTable();
2207
- } else {
2208
- btn.textContent = '✗ ' + (data.error || 'Failed');
2209
- btn.disabled = false;
2210
- }
2211
- } catch (err) {
2212
- btn.textContent = '✗ Error';
2213
- btn.disabled = false;
2214
- }
2215
- }
2216
-
2217
- window.enableInputEditing = function(detailId, index) {
2218
- const obs = currentObservations[index];
2219
- const current = updatedInputs.has(index)
2220
- ? updatedInputs.get(index)
2221
- : toDisplayText(obs.input, obs.type);
2222
- updatedInputs.set(index, current);
2223
- renderObservationTable();
2224
- };
2225
-
2226
- window.resetInput = function(detailId, index) {
2227
- updatedInputs.delete(index);
2228
- renderObservationTable();
2229
- };
2230
-
2231
- window.saveUpdatedInput = function(index) {
2232
- const ta = document.getElementById('editInputTextarea');
2233
- if (!ta) return;
2234
- updatedInputs.set(index, ta.value);
2235
- renderObservationTable();
2236
- };
2237
-
2238
- function getObsFilePath(obs) {
2239
- if (obs && obs.type === "TOOL") {
2240
- const tool = codeIndex.tools.find(t => t.name === obs.name);
2241
- if (tool) return tool.lineNumber ? tool.filePath + ':' + tool.lineNumber : tool.filePath;
2242
- }
2243
- return null;
2244
- }
2245
-
2246
- function extractSystemPrompt(obs) {
2247
- if (!obs || obs.type !== "GENERATION") return null;
2248
- let input = obs.input;
2249
- // Parse input if it's a JSON string
2250
- if (
2251
- typeof input === 'string' &&
2252
- (input.startsWith('{') || input.startsWith('[')) &&
2253
- (input.endsWith('}') || input.endsWith(']'))
2254
- ) {
2255
- try {
2256
- input = JSON.parse(input);
2257
- } catch { /* not JSON */ }
2258
- }
2259
- const messages = Array.isArray(input) ? input
2260
- : (input && Array.isArray(input.messages)) ? input.messages : [];
2261
- const sys = messages.find(m => m && m.role === "system");
2262
- if (!sys || !sys.content) return null;
2263
- return String(sys.content).trim();
2264
- }
2265
-
2266
- function getSearchFragments(systemPrompt) {
2267
- if (!systemPrompt || systemPrompt.length < 10) return [];
2268
- const fragments = [];
2269
-
2270
- // 1. First sentence (often unique: "You are an expert SQL generator...")
2271
- const firstSentence = systemPrompt.split(/[.!?]\\n/)[0].trim();
2272
- if (firstSentence.length >= 20 && firstSentence.length <= 100) {
2273
- fragments.push(firstSentence);
2274
- }
2275
-
2276
- // 2. First 50 chars
2277
- if (systemPrompt.length >= 40) {
2278
- fragments.push(systemPrompt.slice(0, 50).trim());
2279
- }
2280
-
2281
- // 3. First 80 chars (if different from above)
2282
- if (systemPrompt.length >= 80) {
2283
- const fragment80 = systemPrompt.slice(0, 80).trim();
2284
- if (!fragments.some(f => fragment80.startsWith(f))) {
2285
- fragments.push(fragment80);
2286
- }
2287
- }
2288
-
2289
- // 4. Try to find a unique phrase after common prefixes
2290
- const afterYouAre = systemPrompt.match(/You are (?:an? )?([^.\\n]{15,60})/);
2291
- if (afterYouAre && afterYouAre[1]) {
2292
- fragments.push(afterYouAre[1].trim());
2293
- }
2294
-
2295
- return fragments.filter(f => f.length >= 15);
2296
- }
2297
-
2298
- async function resolveGenFilePath(obs, containerId) {
2299
- const systemPrompt = extractSystemPrompt(obs);
2300
- if (!systemPrompt) return;
2301
-
2302
- const fragments = getSearchFragments(systemPrompt);
2303
- if (fragments.length === 0) return;
2304
-
2305
- try {
2306
- // Try each fragment until we find a match
2307
- for (const fragment of fragments) {
2308
- const res = await fetch('/api/search-source?q=' + encodeURIComponent(fragment));
2309
- const data = await res.json();
2310
- if (data.filePath) {
2311
- const label = data.lineNumber ? data.filePath + ':' + data.lineNumber : data.filePath;
2312
- const container = document.getElementById(containerId);
2313
- if (!container) return;
2314
- const placeholder = container.querySelector('.file-path-placeholder');
2315
- if (placeholder) placeholder.outerHTML = renderFilePath(label);
2316
- return; // Success, stop trying
2317
- }
2318
- }
2319
- } catch { /* ignore network errors */ }
2320
- }
2321
-
2322
- function stripAbsolutePath(filePath) {
2323
- console.log('stripAbsolutePath called with:', filePath, 'repoRoot:', repoRoot);
2324
- if (!filePath) return filePath;
2325
-
2326
- // If we have a repo root, strip it from the absolute path
2327
- if (repoRoot) {
2328
- // Normalize path separators and handle case-insensitive filesystems
2329
- let normalizedRoot = repoRoot.replace(/\\\\/g, '/').toLowerCase();
2330
- let normalizedPath = filePath.replace(/\\\\/g, '/').toLowerCase();
2331
-
2332
- // Ensure root ends with / for proper matching
2333
- if (!normalizedRoot.endsWith('/')) {
2334
- normalizedRoot += '/';
2335
- }
2336
-
2337
- if (normalizedPath.startsWith(normalizedRoot)) {
2338
- // Return the original case of the file path after the root
2339
- return filePath.replace(/\\\\/g, '/').substring(repoRoot.length).replace(/^\\//, '');
2340
- }
2341
-
2342
- // Also try without trailing slash
2343
- normalizedRoot = normalizedRoot.slice(0, -1);
2344
- if (normalizedPath.startsWith(normalizedRoot + '/')) {
2345
- return filePath.replace(/\\\\/g, '/').substring(repoRoot.length).replace(/^\\//, '');
2346
- }
2347
- }
2348
-
2349
- // Fallback if repo root not available or path doesn't match
2350
- return filePath;
2351
- }
2352
-
2353
- function renderFilePath(filePath) {
2354
- if (!filePath) return '';
2355
- const cleanPath = stripAbsolutePath(filePath);
2356
- return \`<div class="detail-section">
2357
- <div class="detail-title">File Path</div>
2358
- <pre class="detail-pre">\${esc(cleanPath)}</pre>
2359
- </div>\`;
2360
- }
2361
-
2362
- function renderModel(obs) {
2363
- if (!obs || obs.type !== "GENERATION" || !obs.model) return '';
2364
- let label = obs.model;
2365
- if (obs.modelParameters) {
2366
- const parts = [];
2367
- if (obs.modelParameters.temperature != null) parts.push('temp=' + obs.modelParameters.temperature);
2368
- if (obs.modelParameters.max_tokens != null) parts.push('max_tokens=' + obs.modelParameters.max_tokens);
2369
- if (parts.length) label += ' (' + parts.join(', ') + ')';
2370
- }
2371
- return \`<div class="detail-section">
2372
- <div class="detail-title">Model</div>
2373
- <pre class="detail-pre">\${esc(label)}</pre>
2374
- </div>\`;
2375
- }
2376
-
2377
- function stripMarkdownCodeFence(text) {
2378
- // Remove markdown code fences like \`\`\`sql, \`\`\`json, \`\`\`, etc.
2379
- return text.replace(/^\`\`\`[\\w]*\\n?/gm, '').replace(/\\n?\`\`\`\$/gm, '').trim();
2380
- }
2381
-
2382
- function toDisplayText(value, type) {
2383
- if (value === null || value === undefined || value === "") {
2384
- return "No data";
2385
- }
2386
-
2387
- // If it's a string, try to parse it first (might be JSON)
2388
- if (typeof value === "string") {
2389
- if (value.startsWith('[') || value.startsWith('{')) {
2390
- try {
2391
- const parsed = JSON.parse(value);
2392
- value = parsed; // Use parsed version for further processing
2393
- } catch {
2394
- // Not JSON, strip markdown and return
2395
- return stripMarkdownCodeFence(value);
2396
- }
2397
- } else {
2398
- return stripMarkdownCodeFence(value);
2399
- }
2400
- }
2401
-
2402
- // Handle GENERATION output format: {role, content, ...}
2403
- if (type === "GENERATION" && value.role && value.content) {
2404
- return stripMarkdownCodeFence(value.content || "No content");
2405
- }
2406
-
2407
- // Handle GENERATION input format: {messages: [...]}
2408
- if (type === "GENERATION" && value.messages) {
2409
- return JSON.stringify(value.messages, null, 2);
2410
- }
2411
-
2412
- // For other objects, stringify them
2413
- try {
2414
- return JSON.stringify(value, null, 2);
2415
- } catch {
2416
- return String(value);
2417
- }
2418
- }
2419
-
2420
- function resetTraceModal() {
2421
- uploadArea.classList.remove("hidden");
2422
- traceViewer.classList.remove("visible");
2423
- let customFooter = document.getElementById("step5FooterBtns");
2424
- if (customFooter) customFooter.remove();
2425
- uploadStatus.className = "upload-status";
2426
- uploadStatus.textContent = "";
2427
- fileInput.value = "";
2428
- currentObservations = [];
2429
- selectedObservationIndex = -1;
2430
- checkedObservations.clear();
2431
- rerunHistory.clear();
2432
- rerunInFlight.clear();
2433
- step4SelectedRun = -1;
2434
- step5RunTraces = [];
2435
- localStorage.removeItem('ed_step5RunTraces');
2436
- step5RunMeta = { loading: false, error: '', runCount: 0, sequential: false };
2437
- observationTableBody.innerHTML = "";
2438
- observationDetail.innerHTML = "";
2439
- currentStep = 0;
2440
- updateModalTitle();
2441
- }
2442
-
2443
- function updateModalTitle() {
2444
- const titles = {
2445
- 0: "Step 2: Import Failed Trace",
2446
- 3: "Step 3: Mark broken step",
2447
- 4: "Step 4: Validate your Fixes",
2448
- 5: "Step 5: Validate Updated Flow with Live Data"
2449
- };
2450
- modalTitle.textContent = titles[currentStep] || "Step 2: Import Failed Trace";
2451
- }
2452
-
2453
- function updateFooterButtons() {
2454
- const changeBtn = document.getElementById("changeTraceBtn");
2455
- const nextBtn = document.getElementById("nextBtn");
2456
-
2457
- if (currentStep <= 2) {
2458
- changeBtn.textContent = "Change Workflow Function";
2459
- nextBtn.textContent = "Next";
2460
- nextBtn.disabled = true;
2461
- } else if (currentStep === 3) {
2462
- changeBtn.textContent = "Change Trace File";
2463
- nextBtn.textContent = "Next";
2464
- nextBtn.disabled = false;
2465
- } else if (currentStep === 4) {
2466
- changeBtn.textContent = "Select Different Steps";
2467
- nextBtn.textContent = "Fix Works as Expected";
2468
- nextBtn.disabled = false;
2469
- } else if (currentStep === 5) {
2470
- // Step 5 - show custom buttons
2471
- changeBtn.textContent = "Still Failing";
2472
- nextBtn.textContent = "Done";
2473
- } else {
2474
- let customFooter = document.getElementById("step5FooterBtns");
2475
- if (customFooter) customFooter.remove();
2476
- }
2477
- }
2478
-
2479
- fetch("/api/repo-root").then(r => r.json()).then(d => {
2480
- console.log("[Dashboard] Repo root fetched:", d);
2481
- repoRoot = d.repoRoot || '';
2482
- console.log("[Dashboard] repoRoot set to:", repoRoot);
2483
-
2484
- // Now fetch workflows after repo root is loaded
2485
- return fetch("/api/workflows");
2486
- }).then(r => r.json()).then(d => {
2487
- console.log("[Dashboard] Workflows fetched:", d);
2488
- allWorkflows = d.workflows || [];
2489
- console.log("[Dashboard] Calling render with", allWorkflows.length, "workflows");
2490
- render();
2491
- }).catch(err => {
2492
- console.error("Failed to fetch repo root or workflows:", err);
2493
- tbody.innerHTML = '<tr><td colspan="2" style="text-align: center; padding: 40px; color: #c62828;">Error loading workflows</td></tr>';
2494
- });
2495
-
2496
- fetch("/api/code-index").then(r => r.json()).then(d => {
2497
- codeIndex = d;
2498
- console.log("Code index:", d);
2499
- }).catch(err => {
2500
- console.error("Failed to fetch code index:", err);
2501
- });
2502
-
2503
- function render(search = "") {
2504
- console.log("[Dashboard] render() called with search:", search, "workflows:", allWorkflows.length);
2505
- const filtered = search ? allWorkflows.filter(w =>
2506
- w.name.toLowerCase().includes(search.toLowerCase()) ||
2507
- w.filePath.toLowerCase().includes(search.toLowerCase())
2508
- ) : allWorkflows;
2509
- countEl.textContent = filtered.length;
2510
- console.log("[Dashboard] Rendering", filtered.length, "workflows");
2511
- tbody.innerHTML = filtered.length ? filtered.map((w, i) => \`<tr onclick="showModal(\${i},'\${search}')">
2512
- <td><div class="workflow-name-cell">\${esc(w.name)}\${w.isAsync ? '<span class="async-badge">async</span>' : ""}</div></td>
2513
- <td><div class="workflow-path-cell">\${esc(stripAbsolutePath(w.filePath))}</div></td>
2514
- </tr>\`).join("") : \`<tr><td colspan="2" style="text-align: center; padding: 40px; color: #999;">No workflows found</td></tr>\`;
2515
- console.log("[Dashboard] tbody updated");
2516
- }
2517
-
2518
- function showModal(index, search) {
2519
- const filtered = search ? allWorkflows.filter(w =>
2520
- w.name.toLowerCase().includes(search.toLowerCase()) ||
2521
- w.filePath.toLowerCase().includes(search.toLowerCase())
2522
- ) : allWorkflows;
2523
- selectedWorkflow = filtered[index];
2524
- modal.classList.add("open");
2525
- resetTraceModal();
2526
- updateFooterButtons();
2527
- }
2528
-
2529
- window.showModal = showModal;
2530
- window.selectObservation = selectObservation;
2531
- window.step4SelectObservation = function(idx) {
2532
- selectedObservationIndex = idx;
2533
- const history = rerunHistory.get(idx) || [];
2534
- step4SelectedRun = history.length > 0 ? history.length - 1 : -1;
2535
- renderObservationTable();
2536
- };
2537
- window.step4SelectRun = function(ri) {
2538
- step4SelectedRun = ri;
2539
- renderObservationTable();
2540
- };
2541
- function esc(t) { const d = document.createElement("div"); d.textContent = t; return d.innerHTML; }
2542
- window.esc = esc;
2543
-
2544
- document.getElementById("searchInput").oninput = (e) => render(e.target.value);
2545
- </script>
2546
- </body>
2547
- </html>`;
955
+ return readFileSync(path.join(__dirname, 'html', 'dashboard.html'), 'utf8');
2548
956
  /* DASHBOARD_HTML_END */
2549
957
  }
2550
958
  const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.turbo', 'build', 'coverage']);
@@ -2619,16 +1027,144 @@ function searchInFiles(dir, query) {
2619
1027
  }
2620
1028
  return null;
2621
1029
  }
1030
+ /** Load elasticdash.config.ts via a tsx-enabled subprocess and return the parsed object. */
1031
+ async function loadElasticDashConfig(cwd) {
1032
+ const configPath = resolveRuntimeModule(cwd, 'elasticdash.config');
1033
+ if (!configPath)
1034
+ return {};
1035
+ return new Promise((resolve) => {
1036
+ const nodeOptions = process.env.NODE_OPTIONS ?? '';
1037
+ const tsxFlag = '--import tsx';
1038
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim();
1039
+ const childEnv = { ...process.env, NODE_OPTIONS: isDenoProject(cwd) ? nodeOptions : childNodeOptions };
1040
+ const configUrl = pathToFileURL(configPath).href;
1041
+ const child = spawn(process.execPath, ['--input-type=module'], {
1042
+ env: childEnv,
1043
+ cwd,
1044
+ stdio: ['pipe', 'pipe', 'ignore'],
1045
+ });
1046
+ let output = '';
1047
+ child.stdout.on('data', (chunk) => { output += chunk.toString(); });
1048
+ child.on('close', () => {
1049
+ try {
1050
+ resolve(JSON.parse(output));
1051
+ }
1052
+ catch {
1053
+ resolve({});
1054
+ }
1055
+ });
1056
+ child.on('error', () => resolve({}));
1057
+ child.stdin.write(`import m from '${configUrl}'; process.stdout.write(JSON.stringify(m.default ?? m))`);
1058
+ child.stdin.end();
1059
+ });
1060
+ }
1061
+ /** Resolve {{env.VAR}} and {{input.field}} placeholders in a value. */
1062
+ function resolveTemplateValue(value, input) {
1063
+ if (typeof value === 'string') {
1064
+ return value.replace(/\{\{env\.([^}]+)\}\}/g, (_, k) => process.env[k] ?? '')
1065
+ .replace(/\{\{input\.([^}]+)\}\}/g, (_, k) => String(input[k] ?? ''))
1066
+ .replace(/\{\{timestamp\}\}/g, () => String(Date.now()));
1067
+ }
1068
+ if (Array.isArray(value))
1069
+ return value.map(v => resolveTemplateValue(v, input));
1070
+ if (value && typeof value === 'object') {
1071
+ const out = {};
1072
+ for (const [k, v] of Object.entries(value)) {
1073
+ out[k] = resolveTemplateValue(v, input);
1074
+ }
1075
+ return out;
1076
+ }
1077
+ return value;
1078
+ }
1079
+ async function runHttpWorkflow(opts) {
1080
+ const { workflowName, workflowInput, frozenEvents = [], pushedEvents, runConfigs, config, dashboardPort } = opts;
1081
+ const runId = randomUUID();
1082
+ // Register run config so the user's server can fetch frozen events
1083
+ pushedEvents.set(runId, []);
1084
+ runConfigs.set(runId, { frozenEvents });
1085
+ try {
1086
+ const inputObj = workflowInput && typeof workflowInput === 'object' ? workflowInput : {};
1087
+ const method = config.method ?? 'POST';
1088
+ const resolvedHeaders = {};
1089
+ for (const [k, v] of Object.entries(config.headers ?? {})) {
1090
+ resolvedHeaders[k] = resolveTemplateValue(v, inputObj);
1091
+ }
1092
+ resolvedHeaders['x-elasticdash-run-id'] = runId;
1093
+ resolvedHeaders['x-elasticdash-server'] = `http://localhost:${dashboardPort}`;
1094
+ const body = config.bodyTemplate
1095
+ ? JSON.stringify(resolveTemplateValue(config.bodyTemplate, inputObj))
1096
+ : undefined;
1097
+ if (body && !resolvedHeaders['Content-Type']) {
1098
+ resolvedHeaders['Content-Type'] = 'application/json';
1099
+ }
1100
+ console.log(`[elasticdash] HTTP workflow "${workflowName}" → ${method} ${config.url} (runId=${runId})`);
1101
+ const response = await fetch(config.url, { method, headers: resolvedHeaders, body });
1102
+ let currentOutput;
1103
+ if (config.responseFormat === 'vercel-ai-stream' && response.body) {
1104
+ const decoder = new TextDecoder();
1105
+ const reader = response.body.getReader();
1106
+ let text = '';
1107
+ for (;;) {
1108
+ const { done, value } = await reader.read();
1109
+ if (done)
1110
+ break;
1111
+ text += decoder.decode(value, { stream: true });
1112
+ }
1113
+ // Extract final text from Vercel AI stream data lines
1114
+ let finalText = '';
1115
+ for (const line of text.split('\n')) {
1116
+ if (!line.startsWith('0:'))
1117
+ continue;
1118
+ try {
1119
+ finalText += JSON.parse(line.slice(2));
1120
+ }
1121
+ catch { /* skip */ }
1122
+ }
1123
+ currentOutput = finalText || text;
1124
+ }
1125
+ else {
1126
+ try {
1127
+ currentOutput = await response.clone().json();
1128
+ }
1129
+ catch {
1130
+ currentOutput = await response.text();
1131
+ }
1132
+ }
1133
+ if (!response.ok) {
1134
+ return { ok: false, error: `HTTP ${response.status}: ${response.statusText}`, currentOutput };
1135
+ }
1136
+ // Drain window: wait for in-flight push POSTs to arrive
1137
+ const drainMs = parseInt(process.env.ELASTICDASH_HTTP_DRAIN_MS ?? '300', 10);
1138
+ await new Promise(resolve => setTimeout(resolve, drainMs));
1139
+ const events = (pushedEvents.get(runId) ?? []).sort((a, b) => a.timestamp - b.timestamp);
1140
+ const workflowTrace = { traceId: runId, events };
1141
+ return { ok: true, currentOutput, workflowTrace, steps: [], llmSteps: [], toolCalls: [], customSteps: [] };
1142
+ }
1143
+ catch (error) {
1144
+ return { ok: false, error: `HTTP workflow failed: ${formatError(error)}` };
1145
+ }
1146
+ finally {
1147
+ pushedEvents.delete(runId);
1148
+ runConfigs.delete(runId);
1149
+ }
1150
+ }
2622
1151
  /**
2623
1152
  * Start the dashboard server
2624
1153
  */
2625
1154
  export async function startDashboardServer(cwd, options = {}) {
2626
1155
  const port = options.port ?? 4573;
2627
1156
  const autoOpen = options.autoOpen ?? true;
2628
- // Scan workflows and tools once at startup
1157
+ // In-memory store for telemetry events pushed from HTTP workflow mode runs.
1158
+ // Maps runId -> accumulated WorkflowEvent[]
1159
+ const pushedEvents = new Map();
1160
+ // Per-run config for HTTP workflow mode (frozen events for replay).
1161
+ // Maps runId -> { frozenEvents }
1162
+ const runConfigs = new Map();
1163
+ // Scan workflows, tools, and config once at startup
2629
1164
  const workflows = scanWorkflows(cwd);
2630
1165
  const tools = scanTools(cwd);
2631
1166
  const codeIndex = { workflows, tools };
1167
+ const elasticdashConfig = await loadElasticDashConfig(cwd);
2632
1168
  console.log(`[elasticdash] Scanned: ${workflows.length} workflows, ${tools.length} tools`);
2633
1169
  // Create HTTP server
2634
1170
  const server = http.createServer((req, res) => {
@@ -2679,6 +1215,41 @@ export async function startDashboardServer(cwd, options = {}) {
2679
1215
  (async () => {
2680
1216
  try {
2681
1217
  const body = (await readJsonBody(req));
1218
+ const workflowName = typeof body.workflowName === 'string' ? body.workflowName.trim() : '';
1219
+ const httpConfig = elasticdashConfig.workflows?.[workflowName];
1220
+ if (httpConfig?.mode === 'http') {
1221
+ // HTTP workflow mode — call user's dev server instead of subprocess
1222
+ const runCount = typeof body.runCount === 'number' ? Math.max(1, Math.min(50, body.runCount)) : 1;
1223
+ const sequential = body.sequential === true;
1224
+ const resolvedInput = resolveWorkflowArgsFromObservations(body, workflowName);
1225
+ const workflowInput = resolvedInput.input ?? null;
1226
+ const traces = [];
1227
+ const runOne = async (runNumber) => {
1228
+ const result = await runHttpWorkflow({
1229
+ workflowName, workflowInput, pushedEvents, runConfigs,
1230
+ config: httpConfig, dashboardPort: port,
1231
+ });
1232
+ const traceStub = { getSteps: () => [], getLLMSteps: () => [], getToolCalls: () => [], getCustomSteps: () => [], recordLLMStep: () => { }, recordToolCall: () => { }, recordCustomStep: () => { } };
1233
+ return {
1234
+ runNumber, ok: result.ok, error: result.error,
1235
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, result.error, traceStub, result.workflowTrace),
1236
+ workflowTrace: result.workflowTrace,
1237
+ currentOutput: result.currentOutput,
1238
+ snapshotId: result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined,
1239
+ };
1240
+ };
1241
+ if (sequential) {
1242
+ for (let i = 1; i <= runCount; i++)
1243
+ traces.push(await runOne(i));
1244
+ }
1245
+ else {
1246
+ traces.push(...await Promise.all(Array.from({ length: runCount }, (_, i) => runOne(i + 1))));
1247
+ }
1248
+ const ok = traces.some(t => t.ok);
1249
+ res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
1250
+ res.end(JSON.stringify({ ok, mode: sequential ? 'sequential' : 'parallel', runCount, traces }));
1251
+ return;
1252
+ }
2682
1253
  const result = await validateWorkflowRuns(cwd, body);
2683
1254
  const statusCode = result.ok ? 200 : 400;
2684
1255
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
@@ -2710,12 +1281,6 @@ export async function startDashboardServer(cwd, options = {}) {
2710
1281
  else {
2711
1282
  history = Array.isArray(body.history) ? body.history : [];
2712
1283
  }
2713
- const workflowsModulePath = resolveWorkflowModule(cwd);
2714
- if (!workflowsModulePath) {
2715
- res.writeHead(400, { 'Content-Type': 'application/json' });
2716
- res.end(JSON.stringify({ ok: false, error: 'Cannot find ed_workflows.ts/js in workspace root.' }));
2717
- return;
2718
- }
2719
1284
  const validationBody = { workflowName, observations: body.observations };
2720
1285
  const resolvedInput = resolveWorkflowArgsFromObservations(validationBody, workflowName);
2721
1286
  if (resolvedInput.error) {
@@ -2723,15 +1288,47 @@ export async function startDashboardServer(cwd, options = {}) {
2723
1288
  res.end(JSON.stringify({ ok: false, error: resolvedInput.error }));
2724
1289
  return;
2725
1290
  }
2726
- const workflowArgs = resolvedInput.args ?? [];
2727
1291
  const workflowInput = resolvedInput.input ?? null;
1292
+ const frozenEvents = history.filter((event) => (event.id <= checkpoint
1293
+ && (event.type === 'ai' || event.type === 'tool' || event.type === 'http' || event.type === 'db')));
1294
+ const frozenEventIds = new Set(frozenEvents.map((e) => e.id));
1295
+ const httpConfig = elasticdashConfig.workflows?.[workflowName];
1296
+ if (httpConfig?.mode === 'http') {
1297
+ // HTTP workflow mode — call user's dev server with frozen events for step replay
1298
+ console.log(`[elasticdash] Run from breakpoint (HTTP mode): workflow="${workflowName}" checkpoint=${checkpoint} frozen=${frozenEvents.length}`);
1299
+ const result = await runHttpWorkflow({
1300
+ workflowName, workflowInput, pushedEvents, runConfigs,
1301
+ config: httpConfig, dashboardPort: port,
1302
+ frozenEvents,
1303
+ });
1304
+ const traceStub = { getSteps: () => [], getLLMSteps: () => [], getToolCalls: () => [], getCustomSteps: () => [], recordLLMStep: () => { }, recordToolCall: () => { }, recordCustomStep: () => { } };
1305
+ const snapshotId = result.workflowTrace ? saveSnapshot(cwd, result.workflowTrace) : undefined;
1306
+ const trace = {
1307
+ runNumber: 0,
1308
+ ok: result.ok,
1309
+ error: result.ok ? undefined : result.error,
1310
+ observations: buildValidationObservations(workflowName, workflowInput, result.currentOutput, result.ok ? undefined : result.error, traceStub, result.workflowTrace, frozenEventIds),
1311
+ workflowTrace: result.workflowTrace,
1312
+ currentOutput: result.currentOutput,
1313
+ snapshotId,
1314
+ };
1315
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1316
+ res.end(JSON.stringify(trace));
1317
+ return;
1318
+ }
1319
+ const workflowsModulePath = resolveWorkflowModule(cwd);
1320
+ if (!workflowsModulePath) {
1321
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1322
+ res.end(JSON.stringify({ ok: false, error: 'Cannot find ed_workflows.ts/js in workspace root.' }));
1323
+ return;
1324
+ }
1325
+ const workflowArgs = resolvedInput.args ?? [];
2728
1326
  const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools') ?? null;
2729
- const frozenEventIds = new Set(history
2730
- .filter((event) => (event.id <= checkpoint
2731
- && (event.type === 'ai' || event.type === 'tool' || event.type === 'http' || event.type === 'db')))
2732
- .map((event) => event.id));
1327
+ const toolMockConfig = body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
1328
+ ? body.toolMockConfig
1329
+ : undefined;
2733
1330
  console.log(`[elasticdash] Run from breakpoint: workflow="${workflowName}" checkpoint=${checkpoint} historyLen=${history.length}`);
2734
- const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, workflowArgs, workflowInput, { replayMode: true, checkpoint, history });
1331
+ const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, workflowArgs, workflowInput, { replayMode: true, checkpoint, history, ...(toolMockConfig ? { toolMockConfig } : {}) });
2735
1332
  const traceStub = {
2736
1333
  getSteps: () => (result.steps ?? []),
2737
1334
  getLLMSteps: () => (result.llmSteps ?? []),
@@ -2799,8 +1396,11 @@ export async function startDashboardServer(cwd, options = {}) {
2799
1396
  return;
2800
1397
  }
2801
1398
  const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools') ?? null;
1399
+ const toolMockConfig = body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
1400
+ ? body.toolMockConfig
1401
+ : undefined;
2802
1402
  console.log(`[elasticdash] Resume agent from task: workflow="${workflowName}" taskIndex=${taskIndex}`);
2803
- const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, [], null, { replayMode: history.length > 0, checkpoint: 0, history, agentState });
1403
+ const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, [], null, { replayMode: history.length > 0, checkpoint: 0, history, agentState, ...(toolMockConfig ? { toolMockConfig } : {}) });
2804
1404
  const traceStub = {
2805
1405
  getSteps: () => (result.steps ?? []),
2806
1406
  getLLMSteps: () => (result.llmSteps ?? []),
@@ -2847,6 +1447,35 @@ export async function startDashboardServer(cwd, options = {}) {
2847
1447
  }
2848
1448
  }
2849
1449
  }
1450
+ else if (url.pathname.startsWith('/api/run-configs/') && req.method === 'GET') {
1451
+ const runId = url.pathname.slice('/api/run-configs/'.length);
1452
+ const cfg = runConfigs.get(runId);
1453
+ res.writeHead(cfg ? 200 : 404, { 'Content-Type': 'application/json' });
1454
+ res.end(JSON.stringify({ frozenEvents: cfg?.frozenEvents ?? [] }));
1455
+ }
1456
+ else if (url.pathname === '/api/trace-events' && req.method === 'POST') {
1457
+ // Receive telemetry events pushed from wrapAI / wrapTool in HTTP workflow mode
1458
+ ;
1459
+ (async () => {
1460
+ try {
1461
+ const body = (await readJsonBody(req));
1462
+ if (typeof body.runId !== 'string' || !body.runId || !body.event || typeof body.event !== 'object') {
1463
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1464
+ res.end(JSON.stringify({ ok: false, error: 'runId (string) and event (object) are required.' }));
1465
+ return;
1466
+ }
1467
+ const existing = pushedEvents.get(body.runId) ?? [];
1468
+ existing.push(body.event);
1469
+ pushedEvents.set(body.runId, existing);
1470
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1471
+ res.end(JSON.stringify({ ok: true }));
1472
+ }
1473
+ catch (error) {
1474
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1475
+ res.end(JSON.stringify({ ok: false, error: formatError(error) }));
1476
+ }
1477
+ })();
1478
+ }
2850
1479
  else {
2851
1480
  res.writeHead(200, { 'Content-Type': 'text/html' });
2852
1481
  res.end(getDashboardHtml());