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