elasticdash-test 0.1.11 → 0.1.12

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 (36) hide show
  1. package/README.md +51 -0
  2. package/dist/capture/event.d.ts +4 -0
  3. package/dist/capture/event.d.ts.map +1 -1
  4. package/dist/capture/recorder.d.ts +5 -0
  5. package/dist/capture/recorder.d.ts.map +1 -1
  6. package/dist/capture/recorder.js +10 -0
  7. package/dist/capture/recorder.js.map +1 -1
  8. package/dist/dashboard-server.d.ts +12 -0
  9. package/dist/dashboard-server.d.ts.map +1 -1
  10. package/dist/dashboard-server.js +269 -46
  11. package/dist/dashboard-server.js.map +1 -1
  12. package/dist/index.cjs +2526 -0
  13. package/dist/index.d.ts +3 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/interceptors/ai-interceptor.d.ts.map +1 -1
  18. package/dist/interceptors/ai-interceptor.js +101 -7
  19. package/dist/interceptors/ai-interceptor.js.map +1 -1
  20. package/dist/interceptors/http.d.ts +20 -0
  21. package/dist/interceptors/http.d.ts.map +1 -1
  22. package/dist/interceptors/http.js +184 -17
  23. package/dist/interceptors/http.js.map +1 -1
  24. package/dist/interceptors/tool.d.ts.map +1 -1
  25. package/dist/interceptors/tool.js +91 -0
  26. package/dist/interceptors/tool.js.map +1 -1
  27. package/dist/internals/mock-resolver.d.ts +25 -0
  28. package/dist/internals/mock-resolver.d.ts.map +1 -0
  29. package/dist/internals/mock-resolver.js +82 -0
  30. package/dist/internals/mock-resolver.js.map +1 -0
  31. package/dist/workflow-runner-worker.js +50 -3
  32. package/dist/workflow-runner-worker.js.map +1 -1
  33. package/dist/workflow-runner.d.ts.map +1 -1
  34. package/dist/workflow-runner.js +1 -0
  35. package/dist/workflow-runner.js.map +1 -1
  36. package/package.json +1 -1
package/README.md CHANGED
@@ -38,6 +38,12 @@ npm install elasticdash-test
38
38
 
39
39
  **Requirements:** Node 20+. For Deno projects, see [Using elasticdash-test in Deno](docs/deno.md).
40
40
 
41
+ **Git ignore:** ElasticDash writes temporary runtime artifacts under `.temp/`. Add this to your `.gitignore`:
42
+
43
+ ```gitignore
44
+ .temp/
45
+ ```
46
+
41
47
  **Running CLI commands:** Use `npx` to run commands with your locally installed version (recommended to avoid version drift):
42
48
 
43
49
  ```bash
@@ -92,6 +98,12 @@ Total: 3
92
98
  Duration: 3.4s
93
99
  ```
94
100
 
101
+ **Workflow export requirements:**
102
+
103
+ - Export plain callable functions from `ed_workflows.ts/js`.
104
+ - Use JSON-serializable inputs/outputs (object or array) so dashboard replay can pass args and read results.
105
+ - Do not export framework-bound handlers directly (for example Next.js `NextRequest`/`NextResponse` route handlers).
106
+
95
107
  ---
96
108
 
97
109
  ## Documentation
@@ -213,6 +225,45 @@ In manual mode, always isolate tracing in a separate `try/catch` so trace loggin
213
225
 
214
226
  **→ See [Tool Recording & Replay](docs/tools.md) for checkpoint-based replay and freezing**
215
227
 
228
+ ### HTTP Streaming Capture and Replay
229
+
230
+ ElasticDash also captures non-AI `fetch` responses that stream over HTTP (for example SSE and NDJSON endpoints) in the HTTP interceptor.
231
+
232
+ Currently detected as streaming when response `content-type` includes:
233
+ - `text/event-stream`
234
+ - `application/x-ndjson`
235
+ - `application/stream+json`
236
+ - `application/jsonl`
237
+
238
+ How it behaves today:
239
+ - During live execution, ElasticDash tees the response stream and returns a real stream to your app code.
240
+ - In parallel, ElasticDash buffers the recorder side of the stream as raw text for trace replay.
241
+ - During replay, ElasticDash reconstructs a stream from that captured raw payload and restores status, status text, and response headers.
242
+
243
+ Replay fidelity note:
244
+ - Replay preserves stream payload content, but not original chunk boundaries or timing cadence.
245
+
246
+ Minimal stream consumption example:
247
+
248
+ ```ts
249
+ const res = await fetch('https://example.com/events')
250
+ if (!res.body) throw new Error('Expected a streaming response body')
251
+
252
+ const reader = res.body.getReader()
253
+ const decoder = new TextDecoder()
254
+ let buffer = ''
255
+
256
+ for (;;) {
257
+ const { done, value } = await reader.read()
258
+ if (done) break
259
+ buffer += decoder.decode(value, { stream: true })
260
+ }
261
+
262
+ buffer += decoder.decode()
263
+ ```
264
+
265
+ **→ See [Quick Start Guide](docs/quickstart.md#capture-streaming-flows) for end-to-end setup guidance**
266
+
216
267
  ---
217
268
 
218
269
  ## Configuration
@@ -11,6 +11,10 @@ export interface WorkflowEvent {
11
11
  agentTaskId?: string;
12
12
  /** Optional: Zero-based index of the agent task that produced this event */
13
13
  agentTaskIndex?: number;
14
+ /** Set to true when the original response / output was a stream */
15
+ streamed?: boolean;
16
+ /** Raw buffered text of a streamed response (used for replay) */
17
+ streamRaw?: string;
14
18
  }
15
19
  export interface WorkflowTrace {
16
20
  traceId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"event.d.ts","sourceRoot":"","sources":["../../src/capture/event.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,aAAa,CAAA;AAE7E,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,iBAAiB,CAAA;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,OAAO,CAAA;IACd,MAAM,EAAE,OAAO,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,4EAA4E;IAC5E,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,aAAa,EAAE,CAAA;CACxB"}
1
+ {"version":3,"file":"event.d.ts","sourceRoot":"","sources":["../../src/capture/event.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,aAAa,CAAA;AAE7E,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,iBAAiB,CAAA;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,OAAO,CAAA;IACd,MAAM,EAAE,OAAO,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,4EAA4E;IAC5E,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,iEAAiE;IACjE,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,aAAa,EAAE,CAAA;CACxB"}
@@ -4,7 +4,12 @@ export declare class TraceRecorder {
4
4
  events: WorkflowEvent[];
5
5
  private _counter;
6
6
  private _sideEffectCounter;
7
+ private _pending;
7
8
  record(event: WorkflowEvent): void;
9
+ /** Register an in-flight async recording promise so flush() can await it. */
10
+ trackAsync(promise: Promise<void>): void;
11
+ /** Await all in-flight async recordings. No-op when none are pending. */
12
+ flush(): Promise<void>;
8
13
  nextId(): number;
9
14
  /** Separate counter for Date.now / Math.random — never shares IDs with main events. */
10
15
  nextSideEffectId(): number;
@@ -1 +1 @@
1
- {"version":3,"file":"recorder.d.ts","sourceRoot":"","sources":["../../src/capture/recorder.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC9D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAEnD,qBAAa,aAAa;IACxB,MAAM,EAAE,aAAa,EAAE,CAAK;IAC5B,OAAO,CAAC,QAAQ,CAAI;IACpB,OAAO,CAAC,kBAAkB,CAAI;IAE9B,MAAM,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI;IAIlC,MAAM,IAAI,MAAM;IAIhB,uFAAuF;IACvF,gBAAgB,IAAI,MAAM;IAI1B,OAAO,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,aAAa;CAMzC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,aAAa,CAAA;IACvB,MAAM,EAAE,gBAAgB,CAAA;CACzB;AASD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,GAAG,SAAS,GAAG,IAAI,CAEvE;AAED,wBAAgB,iBAAiB,IAAI,cAAc,GAAG,SAAS,CAE9D"}
1
+ {"version":3,"file":"recorder.d.ts","sourceRoot":"","sources":["../../src/capture/recorder.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC9D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAEnD,qBAAa,aAAa;IACxB,MAAM,EAAE,aAAa,EAAE,CAAK;IAC5B,OAAO,CAAC,QAAQ,CAAI;IACpB,OAAO,CAAC,kBAAkB,CAAI;IAC9B,OAAO,CAAC,QAAQ,CAAgC;IAEhD,MAAM,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI;IAIlC,6EAA6E;IAC7E,UAAU,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI;IAKxC,yEAAyE;IACnE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B,MAAM,IAAI,MAAM;IAIhB,uFAAuF;IACvF,gBAAgB,IAAI,MAAM;IAI1B,OAAO,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,aAAa;CAMzC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,aAAa,CAAA;IACvB,MAAM,EAAE,gBAAgB,CAAA;CACzB;AASD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,GAAG,SAAS,GAAG,IAAI,CAEvE;AAED,wBAAgB,iBAAiB,IAAI,cAAc,GAAG,SAAS,CAE9D"}
@@ -4,9 +4,19 @@ export class TraceRecorder {
4
4
  events = [];
5
5
  _counter = 0;
6
6
  _sideEffectCounter = 0;
7
+ _pending = new Set();
7
8
  record(event) {
8
9
  this.events.push(event);
9
10
  }
11
+ /** Register an in-flight async recording promise so flush() can await it. */
12
+ trackAsync(promise) {
13
+ this._pending.add(promise);
14
+ promise.finally(() => { this._pending.delete(promise); });
15
+ }
16
+ /** Await all in-flight async recordings. No-op when none are pending. */
17
+ async flush() {
18
+ await Promise.allSettled([...this._pending]);
19
+ }
10
20
  nextId() {
11
21
  return ++this._counter;
12
22
  }
@@ -1 +1 @@
1
- {"version":3,"file":"recorder.js","sourceRoot":"","sources":["../../src/capture/recorder.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAIxC,MAAM,OAAO,aAAa;IACxB,MAAM,GAAoB,EAAE,CAAA;IACpB,QAAQ,GAAG,CAAC,CAAA;IACZ,kBAAkB,GAAG,CAAC,CAAA;IAE9B,MAAM,CAAC,KAAoB;QACzB,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACzB,CAAC;IAED,MAAM;QACJ,OAAO,EAAE,IAAI,CAAC,QAAQ,CAAA;IACxB,CAAC;IAED,uFAAuF;IACvF,gBAAgB;QACd,OAAO,EAAE,IAAI,CAAC,kBAAkB,CAAA;IAClC,CAAC;IAED,OAAO,CAAC,OAAgB;QACtB,OAAO;YACL,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE;YAChC,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC;SACzB,CAAA;IACH,CAAC;CACF;AAOD,MAAM,CAAC,GAAG,UAAqC,CAAA;AAC/C,MAAM,eAAe,GAAG,6BAA6B,CAAA;AACrD,MAAM,UAAU,GACb,CAAC,CAAC,eAAe,CAAmD;IACrE,IAAI,iBAAiB,EAA8B,CAAA;AACrD,IAAI,CAAC,CAAC,CAAC,eAAe,CAAC;IAAE,CAAC,CAAC,eAAe,CAAC,GAAG,UAAU,CAAA;AAExD,MAAM,UAAU,iBAAiB,CAAC,GAA+B;IAC/D,UAAU,CAAC,SAAS,CAAC,GAAG,CAAC,CAAA;AAC3B,CAAC;AAED,MAAM,UAAU,iBAAiB;IAC/B,OAAO,UAAU,CAAC,QAAQ,EAAE,CAAA;AAC9B,CAAC"}
1
+ {"version":3,"file":"recorder.js","sourceRoot":"","sources":["../../src/capture/recorder.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAIxC,MAAM,OAAO,aAAa;IACxB,MAAM,GAAoB,EAAE,CAAA;IACpB,QAAQ,GAAG,CAAC,CAAA;IACZ,kBAAkB,GAAG,CAAC,CAAA;IACtB,QAAQ,GAAuB,IAAI,GAAG,EAAE,CAAA;IAEhD,MAAM,CAAC,KAAoB;QACzB,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACzB,CAAC;IAED,6EAA6E;IAC7E,UAAU,CAAC,OAAsB;QAC/B,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;QAC1B,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA,CAAC,CAAC,CAAC,CAAA;IAC1D,CAAC;IAED,yEAAyE;IACzE,KAAK,CAAC,KAAK;QACT,MAAM,OAAO,CAAC,UAAU,CAAC,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAA;IAC9C,CAAC;IAED,MAAM;QACJ,OAAO,EAAE,IAAI,CAAC,QAAQ,CAAA;IACxB,CAAC;IAED,uFAAuF;IACvF,gBAAgB;QACd,OAAO,EAAE,IAAI,CAAC,kBAAkB,CAAA;IAClC,CAAC;IAED,OAAO,CAAC,OAAgB;QACtB,OAAO;YACL,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE;YAChC,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC;SACzB,CAAA;IACH,CAAC;CACF;AAOD,MAAM,CAAC,GAAG,UAAqC,CAAA;AAC/C,MAAM,eAAe,GAAG,6BAA6B,CAAA;AACrD,MAAM,UAAU,GACb,CAAC,CAAC,eAAe,CAAmD;IACrE,IAAI,iBAAiB,EAA8B,CAAA;AACrD,IAAI,CAAC,CAAC,CAAC,eAAe,CAAC;IAAE,CAAC,CAAC,eAAe,CAAC,GAAG,UAAU,CAAA;AAExD,MAAM,UAAU,iBAAiB,CAAC,GAA+B;IAC/D,UAAU,CAAC,SAAS,CAAC,GAAG,CAAC,CAAA;AAC3B,CAAC;AAED,MAAM,UAAU,iBAAiB;IAC/B,OAAO,UAAU,CAAC,QAAQ,EAAE,CAAA;AAC9B,CAAC"}
@@ -28,6 +28,18 @@ export interface DashboardServer {
28
28
  url: string;
29
29
  close(): Promise<void>;
30
30
  }
31
+ /** Per-tool mock configuration sent from the dashboard UI */
32
+ export interface ToolMockEntry {
33
+ /** 'live' = always call real tool, 'mock-all' = mock every call, 'mock-specific' = mock only listed call indices */
34
+ mode: 'live' | 'mock-all' | 'mock-specific';
35
+ /** When mode is 'mock-specific', which 1-based call indices to mock */
36
+ callIndices?: number[];
37
+ /** Mock data keyed by 1-based call index (or 0 for mock-all default) */
38
+ mockData?: Record<number, unknown>;
39
+ }
40
+ export interface ToolMockConfig {
41
+ [toolName: string]: ToolMockEntry;
42
+ }
31
43
  /**
32
44
  * Start the dashboard server
33
45
  */
@@ -1 +1 @@
1
- {"version":3,"file":"dashboard-server.d.ts","sourceRoot":"","sources":["../src/dashboard-server.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,SAAS;IACxB,SAAS,EAAE,YAAY,EAAE,CAAA;IACzB,KAAK,EAAE,QAAQ,EAAE,CAAA;CAClB;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB;AAu9ED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,eAAe,CAAC,CA+S1B;AAiFD,eAAO,MAAM,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAa,CAAC"}
1
+ {"version":3,"file":"dashboard-server.d.ts","sourceRoot":"","sources":["../src/dashboard-server.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,SAAS;IACxB,SAAS,EAAE,YAAY,EAAE,CAAA;IACzB,KAAK,EAAE,QAAQ,EAAE,CAAA;CAClB;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB;AAuCD,6DAA6D;AAC7D,MAAM,WAAW,aAAa;IAC5B,oHAAoH;IACpH,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,eAAe,CAAA;IAC3C,uEAAuE;IACvE,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;IACtB,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACnC;AAED,MAAM,WAAW,cAAc;IAC7B,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa,CAAA;CAClC;AAqpFD;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,eAAe,CAAC,CA+S1B;AAiFD,eAAO,MAAM,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAa,CAAC"}
@@ -260,6 +260,7 @@ function runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowN
260
260
  ...(options?.checkpoint !== undefined ? { checkpoint: options.checkpoint } : {}),
261
261
  ...(options?.history !== undefined ? { history: options.history } : {}),
262
262
  ...(options?.agentState !== undefined ? { agentState: options.agentState } : {}),
263
+ ...(options?.toolMockConfig !== undefined ? { toolMockConfig: options.toolMockConfig } : {}),
263
264
  });
264
265
  child.stdin.write(payload);
265
266
  child.stdin.end(); // Always close stdin to avoid subprocess hang
@@ -309,7 +310,9 @@ async function runGenerationObservation(observation) {
309
310
  }
310
311
  async function rerunObservation(cwd, observation, tools) {
311
312
  const type = observation.type?.toUpperCase();
312
- if (type === 'TOOL') {
313
+ const name = observation.name ?? '(unknown)';
314
+ if (type === 'TOOL' || name.startsWith('tool-')) {
315
+ observation.name = name.startsWith('tool-') ? name.slice(5) : name; // Support both explicit type and name prefix for tool observations
313
316
  return runToolObservation(cwd, observation, tools);
314
317
  }
315
318
  if (type === 'GENERATION') {
@@ -362,7 +365,9 @@ function resolveWorkflowArgsFromObservations(body, workflowName) {
362
365
  return typeof item.name === 'string' && (item.name ?? '').trim() === workflowName;
363
366
  });
364
367
  if (!matched) {
365
- return { error: `No matching observation found for workflow "${workflowName}".` };
368
+ // No workflow-level observation found (e.g. trace was loaded from an external format that
369
+ // only contains child observations). Fall back to running the workflow with no arguments.
370
+ return { args: [], input: null };
366
371
  }
367
372
  return { args: normalizeWorkflowArgs(matched.input), input: matched.input };
368
373
  }
@@ -597,6 +602,10 @@ async function validateWorkflowRuns(cwd, body) {
597
602
  }
598
603
  const workflowArgs = resolvedInput.args ?? [];
599
604
  const workflowInput = resolvedInput.input ?? null;
605
+ // Parse tool mock config if provided
606
+ const toolMockConfig = body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
607
+ ? body.toolMockConfig
608
+ : undefined;
600
609
  const workflowsModulePath = resolveWorkflowModule(cwd);
601
610
  if (!workflowsModulePath) {
602
611
  return {
@@ -612,7 +621,7 @@ async function validateWorkflowRuns(cwd, body) {
612
621
  console.log(`[elasticdash] Running workflow "${workflowName}" ${runCount} time(s) in ${mode} mode via subprocess`);
613
622
  async function runOne(runNumber) {
614
623
  console.log(`[elasticdash] === Run ${runNumber}: Starting workflow "${workflowName}" ===`);
615
- const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, workflowArgs, workflowInput)
624
+ const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, workflowArgs, workflowInput, toolMockConfig ? { toolMockConfig } : undefined)
616
625
  .catch(err => {
617
626
  throw { ok: false, error: `Workflow subprocess failed: ${formatError(err)}` };
618
627
  });
@@ -1186,13 +1195,200 @@ function getDashboardHtml() {
1186
1195
  }
1187
1196
  };
1188
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
+
1189
1380
  window.openLiveValidationDialog = function() {
1190
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
+
1191
1387
  window.liveValidationDialog = document.createElement('div');
1192
1388
  window.liveValidationDialog.id = 'liveValidationDialog';
1193
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;';
1194
1390
  window.liveValidationDialog.innerHTML = \`
1195
- <div style="background:white;padding:32px 28px;border-radius:12px;box-shadow:0 2px 24px #0002;min-width:600px;max-width:90vw;">
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;">
1196
1392
  <h3 style="margin-top:0;margin-bottom:18px;font-size:20px;">Validate Updated Flow with Live Data</h3>
1197
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>
1198
1394
  <input id="liveValidationCount" type="number" min="1" value="1" style="width:100%;font-size:16px;padding:6px 10px;margin-bottom:18px;" />
@@ -1200,6 +1396,17 @@ function getDashboardHtml() {
1200
1396
  <input id="liveValidationSequential" type="checkbox" />
1201
1397
  Run in sequence instead of parallel
1202
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>\` : ''}
1203
1410
  <div style="display:flex;gap:12px;justify-content:space-between;align-items:center;">
1204
1411
  <span id="liveValidationProgress" style="font-size:14px;color:#555;"></span>
1205
1412
  <div style="display:flex;gap:12px;">
@@ -1210,6 +1417,11 @@ function getDashboardHtml() {
1210
1417
  </div>
1211
1418
  \`;
1212
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
+ }
1213
1425
  document.getElementById('cancelLiveValidation').onclick = function() {
1214
1426
  window.liveValidationDialog.remove();
1215
1427
  window.liveValidationDialog = null;
@@ -1218,65 +1430,76 @@ function getDashboardHtml() {
1218
1430
  const count = parseInt(document.getElementById('liveValidationCount').value, 10);
1219
1431
  const sequential = document.getElementById('liveValidationSequential').checked;
1220
1432
  if (count >= 1) {
1433
+ // Build the tool mock config from UI state
1434
+ const toolMockConfig = buildToolMockConfigFromUI();
1221
1435
  const submitBtn = document.getElementById('submitLiveValidation');
1222
1436
  submitBtn.disabled = true;
1223
1437
  submitBtn.textContent = 'Validating...';
1224
1438
  const progressEl = document.getElementById('liveValidationProgress');
1225
- if (progressEl) progressEl.textContent = \`0 of \${count} workflow runs\`;
1226
- let completed = 0;
1227
- // Wrap the fetch in a progress simulation for user feedback
1228
- const payload = { workflowName: selectedWorkflow?.name, runCount: count, sequential, observations: currentObservations };
1229
- // Start the fetch but also update progress as we go
1230
- const progressInterval = setInterval(() => {
1231
- if (progressEl && completed < count) {
1232
- progressEl.textContent = \`\${completed} of \${count} workflow runs\`;
1233
- }
1234
- }, 200);
1235
- try {
1236
- const responsePromise = fetch('/api/validate-workflow', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
1237
- // Simulate progress incrementing as if runs are happening (for demo, increment every 500ms)
1238
- while (completed < count) {
1239
- await new Promise(r => setTimeout(r, 500));
1240
- completed++;
1241
- if (progressEl) progressEl.textContent = \`\${completed} of \${count} workflow runs\`;
1242
- }
1243
- const response = await responsePromise;
1244
- const data = await response.json();
1245
- clearInterval(progressInterval);
1439
+
1440
+ function finishValidation(collectedTraces, errorMsg, usedSequential) {
1246
1441
  if (progressEl) progressEl.textContent = '';
1247
1442
  window.liveValidationDialog.remove();
1248
1443
  window.liveValidationDialog = null;
1249
1444
  window.liveValidationCount = count;
1250
- window.liveValidationSequential = sequential;
1445
+ window.liveValidationSequential = usedSequential;
1251
1446
  window.step5SelectedTrace = 0;
1252
1447
  window.step5SelectedObservation = 0;
1253
1448
  currentStep = 5;
1254
1449
  updateModalTitle();
1255
1450
  updateFooterButtons();
1256
- if (response.ok && data.ok) {
1257
- step5RunTraces = Array.isArray(data.traces) ? data.traces : [];
1258
- persistTraces();
1259
- step5RunMeta = { loading: false, error: '', runCount: typeof data.runCount === 'number' ? data.runCount : count, sequential: data.mode === 'sequential' };
1260
- } else {
1451
+ if (errorMsg && collectedTraces.length === 0) {
1261
1452
  step5RunTraces = [];
1262
1453
  localStorage.removeItem('ed_step5RunTraces');
1263
- step5RunMeta = { loading: false, error: data.error || 'Workflow validation failed.', runCount: count, sequential };
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);
1264
1501
  }
1265
- } catch (err) {
1266
- clearInterval(progressInterval);
1267
- if (progressEl) progressEl.textContent = '';
1268
- window.liveValidationDialog.remove();
1269
- window.liveValidationDialog = null;
1270
- currentStep = 5;
1271
- updateModalTitle();
1272
- updateFooterButtons();
1273
- step5RunTraces = [];
1274
- localStorage.removeItem('ed_step5RunTraces');
1275
- step5RunMeta = { loading: false, error: err && err.message ? err.message : String(err), runCount: count, sequential };
1276
1502
  }
1277
- if (window.step5SelectedTrace > step5RunTraces.length) window.step5SelectedTrace = 0;
1278
- window.step5SelectedObservation = 0;
1279
- renderObservationTable();
1280
1503
  } else {
1281
1504
  document.getElementById('liveValidationCount').style.borderColor = 'red';
1282
1505
  }