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,117 @@
|
|
|
1
|
+
import { io, Socket } from 'socket.io-client'
|
|
2
|
+
import type { PortalTask, PortalTaskResult } from './types/portal.js'
|
|
3
|
+
import type { TriggerSignal } from './telemetry-batcher.js'
|
|
4
|
+
import { executePortalTask } from './portal-executor.js'
|
|
5
|
+
import { executeTrigger } from './trigger-executor.js'
|
|
6
|
+
import { scanTools, scanWorkflows } from './execution/tool-runner.js'
|
|
7
|
+
import { debugLog } from './utils/debug.js'
|
|
8
|
+
|
|
9
|
+
export interface SocketConnectorOptions {
|
|
10
|
+
serverUrl: string
|
|
11
|
+
apiKey?: string
|
|
12
|
+
sessionId: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let socket: Socket | null = null
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Establish a persistent socket.io connection to the backend.
|
|
19
|
+
*
|
|
20
|
+
* The SDK registers its sessionId and available tools/workflows
|
|
21
|
+
* on connect. The backend can then push portal tasks and trigger signals
|
|
22
|
+
* to this SDK instance for execution.
|
|
23
|
+
*
|
|
24
|
+
* Auto-reconnects on disconnect with exponential backoff (1s–30s).
|
|
25
|
+
* Safe to call multiple times — subsequent calls return the existing socket.
|
|
26
|
+
*/
|
|
27
|
+
export function connectToBackend(options: SocketConnectorOptions): Socket {
|
|
28
|
+
if (socket?.connected) return socket
|
|
29
|
+
// If a socket exists but is disconnected/reconnecting, reuse it
|
|
30
|
+
if (socket) return socket
|
|
31
|
+
|
|
32
|
+
const { serverUrl, apiKey, sessionId } = options
|
|
33
|
+
const cwd = process.cwd()
|
|
34
|
+
|
|
35
|
+
socket = io(serverUrl, {
|
|
36
|
+
auth: {
|
|
37
|
+
...(apiKey ? { apiKey } : {}),
|
|
38
|
+
sessionId,
|
|
39
|
+
},
|
|
40
|
+
transports: ['websocket', 'polling'],
|
|
41
|
+
reconnection: true,
|
|
42
|
+
reconnectionDelay: 1000,
|
|
43
|
+
reconnectionDelayMax: 30_000,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
socket.on('connect', () => {
|
|
47
|
+
debugLog(`[elasticdash] Socket connected: ${socket!.id}`)
|
|
48
|
+
const tools = scanTools(cwd)
|
|
49
|
+
const workflows = scanWorkflows(cwd)
|
|
50
|
+
socket!.emit('register', {
|
|
51
|
+
sessionId,
|
|
52
|
+
tools: tools.map(t => t.name),
|
|
53
|
+
workflows: workflows.map(w => w.name),
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
socket.on('auth:ok', (data: { projectId: number }) => {
|
|
58
|
+
debugLog(`[elasticdash] Authenticated for project ${data.projectId}`)
|
|
59
|
+
socket!.emit('join', `observability:project:${data.projectId}`)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
socket.on('portal:task', async (task: PortalTask, ack?: (result: PortalTaskResult) => void) => {
|
|
63
|
+
debugLog(`[elasticdash] Socket received portal task: ${task.taskId} type=${task.type} name=${task.name}`)
|
|
64
|
+
try {
|
|
65
|
+
const tools = scanTools(cwd)
|
|
66
|
+
const result = await executePortalTask(task, cwd, tools)
|
|
67
|
+
if (typeof ack === 'function') {
|
|
68
|
+
ack(result)
|
|
69
|
+
} else {
|
|
70
|
+
socket!.emit('portal:result', result)
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
const errorResult: PortalTaskResult = {
|
|
74
|
+
taskId: task.taskId,
|
|
75
|
+
ok: false,
|
|
76
|
+
output: null,
|
|
77
|
+
error: e instanceof Error ? e.message : String(e),
|
|
78
|
+
durationMs: 0,
|
|
79
|
+
metadata: task.metadata,
|
|
80
|
+
}
|
|
81
|
+
if (typeof ack === 'function') {
|
|
82
|
+
ack(errorResult)
|
|
83
|
+
} else {
|
|
84
|
+
socket!.emit('portal:result', errorResult)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
socket.on('trigger', async (trigger: TriggerSignal) => {
|
|
90
|
+
debugLog(`[elasticdash] Socket received trigger: ${trigger.triggerId} steps=${trigger.steps.length} runs=${trigger.runCount}`)
|
|
91
|
+
try {
|
|
92
|
+
await executeTrigger(serverUrl, apiKey, trigger)
|
|
93
|
+
} catch (e) {
|
|
94
|
+
debugLog(`[elasticdash] Socket trigger execution failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
socket.on('disconnect', (reason) => {
|
|
99
|
+
debugLog(`[elasticdash] Socket disconnected: ${reason}`)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
socket.on('connect_error', (err) => {
|
|
103
|
+
debugLog(`[elasticdash] Socket connection error: ${err.message}`)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
return socket
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Disconnect from the backend and clean up the socket.
|
|
111
|
+
*/
|
|
112
|
+
export async function disconnectFromBackend(): Promise<void> {
|
|
113
|
+
if (socket) {
|
|
114
|
+
socket.disconnect()
|
|
115
|
+
socket = null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Readable } from 'node:stream'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import type { WorkflowEvent } from './capture/event.js'
|
|
4
|
+
import { getOriginalFetch } from './interceptors/http.js'
|
|
5
|
+
import { debugLog } from './utils/debug.js'
|
|
6
|
+
import { notifyLicenseError } from './utils/license-error.js'
|
|
7
|
+
import { redactPayload } from './utils/redact.js'
|
|
8
|
+
|
|
9
|
+
export interface TriggerStep {
|
|
10
|
+
eventId: number
|
|
11
|
+
eventType: 'ai' | 'tool' | 'http' | 'db'
|
|
12
|
+
eventName: string
|
|
13
|
+
originalEventDbId: number
|
|
14
|
+
input: unknown
|
|
15
|
+
model?: string
|
|
16
|
+
provider?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FrozenEvent {
|
|
20
|
+
id: number
|
|
21
|
+
type: string
|
|
22
|
+
name: string
|
|
23
|
+
input: unknown
|
|
24
|
+
output: unknown
|
|
25
|
+
timestamp: number
|
|
26
|
+
durationMs: number | null
|
|
27
|
+
streamed?: boolean
|
|
28
|
+
streamRaw?: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TriggerSignal {
|
|
32
|
+
triggerId: number
|
|
33
|
+
runCount: number
|
|
34
|
+
steps: TriggerStep[]
|
|
35
|
+
/** HTTP/DB events from the same trace to freeze (mock) during step reruns */
|
|
36
|
+
frozenEvents?: FrozenEvent[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TelemetryBatcherOptions {
|
|
40
|
+
serverUrl: string
|
|
41
|
+
apiKey?: string
|
|
42
|
+
sessionId: string
|
|
43
|
+
metadata?: Record<string, unknown>
|
|
44
|
+
batchIntervalMs?: number
|
|
45
|
+
maxBatchSize?: number
|
|
46
|
+
redactKeys?: string[]
|
|
47
|
+
onTrigger?: (trigger: TriggerSignal) => Promise<void>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class TelemetryBatcher {
|
|
51
|
+
private buffer: WorkflowEvent[] = []
|
|
52
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
53
|
+
private readonly serverUrl: string
|
|
54
|
+
private readonly apiKey: string | undefined
|
|
55
|
+
private readonly sessionId: string
|
|
56
|
+
private readonly metadata: Record<string, unknown> | undefined
|
|
57
|
+
private readonly maxBatchSize: number
|
|
58
|
+
private readonly redactKeys: string[]
|
|
59
|
+
private readonly onTrigger: ((trigger: TriggerSignal) => Promise<void>) | undefined
|
|
60
|
+
private shuttingDown = false
|
|
61
|
+
|
|
62
|
+
constructor(opts: TelemetryBatcherOptions) {
|
|
63
|
+
this.serverUrl = opts.serverUrl.replace(/\/$/, '')
|
|
64
|
+
this.apiKey = opts.apiKey
|
|
65
|
+
this.sessionId = opts.sessionId
|
|
66
|
+
this.metadata = opts.metadata
|
|
67
|
+
this.maxBatchSize = opts.maxBatchSize ?? 50
|
|
68
|
+
this.redactKeys = opts.redactKeys ?? []
|
|
69
|
+
this.onTrigger = opts.onTrigger
|
|
70
|
+
|
|
71
|
+
const intervalMs = opts.batchIntervalMs ?? 2000
|
|
72
|
+
this.timer = setInterval(() => { this.flush().catch(() => {}) }, intervalMs)
|
|
73
|
+
// Allow the process to exit even if the timer is still scheduled
|
|
74
|
+
if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
|
|
75
|
+
this.timer.unref()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
enqueue(event: WorkflowEvent): void {
|
|
80
|
+
if (this.shuttingDown) return
|
|
81
|
+
const redacted = this.redactKeys.length > 0
|
|
82
|
+
? { ...event, input: redactPayload(event.input, this.redactKeys), output: redactPayload(event.output, this.redactKeys) }
|
|
83
|
+
: event
|
|
84
|
+
this.buffer.push(redacted as WorkflowEvent)
|
|
85
|
+
if (this.buffer.length >= this.maxBatchSize) {
|
|
86
|
+
this.flush().catch(() => {})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async flush(): Promise<void> {
|
|
91
|
+
if (this.buffer.length === 0) return
|
|
92
|
+
const batch = this.buffer.splice(0, this.buffer.length)
|
|
93
|
+
await this.send(batch, 0)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async send(batch: WorkflowEvent[], attempt: number): Promise<void> {
|
|
97
|
+
if (!this.serverUrl) {
|
|
98
|
+
debugLog(`[elasticdash] Dropping ${batch.length} events: serverUrl is empty`)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
const maxRetries = 3
|
|
102
|
+
const url = `${this.serverUrl}/api/observability/events`
|
|
103
|
+
const headers: Record<string, string> = {
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
'X-Correlation-ID': randomUUID(),
|
|
106
|
+
}
|
|
107
|
+
if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}`
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// Stream the JSON body to avoid buffering large payloads (e.g. events
|
|
111
|
+
// with streamRaw can exceed 100 MB). Node.js fetch (undici) supports
|
|
112
|
+
// Readable as body and uses chunked Transfer-Encoding automatically.
|
|
113
|
+
const body = Readable.from(jsonStreamEvents(this.sessionId, batch, this.metadata))
|
|
114
|
+
|
|
115
|
+
const res = await getOriginalFetch()(url, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers,
|
|
118
|
+
body,
|
|
119
|
+
duplex: 'half',
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
if (res.status === 402) {
|
|
123
|
+
notifyLicenseError(res.status, 'telemetry')
|
|
124
|
+
debugLog(`[elasticdash] Dropping ${batch.length} events: no available license (402)`)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (res.status === 429 || res.status >= 500) {
|
|
129
|
+
if (attempt < maxRetries) {
|
|
130
|
+
const delayMs = Math.pow(2, attempt) * 1000
|
|
131
|
+
debugLog(`[elasticdash] Telemetry flush failed (${res.status}), retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`)
|
|
132
|
+
await new Promise((r) => setTimeout(r, delayMs))
|
|
133
|
+
return this.send(batch, attempt + 1)
|
|
134
|
+
}
|
|
135
|
+
debugLog(`[elasticdash] Dropping ${batch.length} events after ${maxRetries} retries (last status: ${res.status})`)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
debugLog(`[elasticdash] Flushed ${batch.length} events (status ${res.status})`)
|
|
140
|
+
|
|
141
|
+
// Parse response for trigger signal
|
|
142
|
+
if (res.ok && this.onTrigger) {
|
|
143
|
+
try {
|
|
144
|
+
const body = await res.json() as { trigger?: TriggerSignal }
|
|
145
|
+
if (body.trigger && typeof body.trigger.triggerId === 'number' && Array.isArray(body.trigger.steps)) {
|
|
146
|
+
debugLog(`[elasticdash] Trigger received: id=${body.trigger.triggerId} steps=${body.trigger.steps.length} runCount=${body.trigger.runCount}`)
|
|
147
|
+
// Fire-and-forget — don't block the flush pipeline
|
|
148
|
+
this.onTrigger(body.trigger).catch((err) => {
|
|
149
|
+
debugLog(`[elasticdash] Trigger execution failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Response parsing failed — not critical, ignore
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (attempt < maxRetries) {
|
|
158
|
+
const delayMs = Math.pow(2, attempt) * 1000
|
|
159
|
+
debugLog(`[elasticdash] Telemetry flush error, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`)
|
|
160
|
+
await new Promise((r) => setTimeout(r, delayMs))
|
|
161
|
+
return this.send(batch, attempt + 1)
|
|
162
|
+
}
|
|
163
|
+
debugLog(`[elasticdash] Dropping ${batch.length} events after ${maxRetries} retries: ${err instanceof Error ? err.message : String(err)}`)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async shutdown(): Promise<void> {
|
|
168
|
+
if (this.shuttingDown) return
|
|
169
|
+
this.shuttingDown = true
|
|
170
|
+
if (this.timer) {
|
|
171
|
+
clearInterval(this.timer)
|
|
172
|
+
this.timer = null
|
|
173
|
+
}
|
|
174
|
+
await this.flush()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Yields JSON fragments for the events payload without building a single
|
|
180
|
+
* giant string. Each event is serialized individually so the process never
|
|
181
|
+
* holds the full serialized body in memory at once.
|
|
182
|
+
*/
|
|
183
|
+
function* jsonStreamEvents(sessionId: string, events: WorkflowEvent[], metadata?: Record<string, unknown>): Generator<string> {
|
|
184
|
+
const metaPart = metadata ? `,"metadata":${JSON.stringify(metadata)}` : ''
|
|
185
|
+
yield `{"sessionId":${JSON.stringify(sessionId)}${metaPart},"events":[`
|
|
186
|
+
for (let i = 0; i < events.length; i++) {
|
|
187
|
+
if (i > 0) yield ','
|
|
188
|
+
yield JSON.stringify(events[i])
|
|
189
|
+
}
|
|
190
|
+
yield ']}'
|
|
191
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import this file in your AI test files to get:
|
|
3
|
+
* - aiTest / beforeAll / afterAll / beforeEach / afterEach available as globals (TypeScript types + runtime)
|
|
4
|
+
* - Custom matcher types (toHaveLLMStep, toCallTool, toMatchSemanticOutput)
|
|
5
|
+
*
|
|
6
|
+
* The CLI registers matchers at startup, so this import is for TypeScript
|
|
7
|
+
* type awareness only — no double-registration occurs at runtime.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Side-effect: populates globalThis.aiTest + brings declare global into scope
|
|
11
|
+
import './core/registry.js'
|
|
12
|
+
|
|
13
|
+
// Side-effect: brings declare module 'expect' augmentation into scope
|
|
14
|
+
import './matchers/index.js'
|
|
15
|
+
|
|
16
|
+
export {}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global registry for tools defined via edTool().
|
|
3
|
+
*
|
|
4
|
+
* Tools registered here are discoverable for reruns regardless of which
|
|
5
|
+
* module they live in. The helper also applies wrapTool() so telemetry
|
|
6
|
+
* fires automatically — symmetric with the Python @ed_tool decorator.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { wrapTool } from './interceptors/tool.js'
|
|
10
|
+
|
|
11
|
+
export interface RegisteredTool {
|
|
12
|
+
name: string
|
|
13
|
+
fn: (...args: unknown[]) => unknown | Promise<unknown>
|
|
14
|
+
wrapped: (...args: unknown[]) => Promise<unknown>
|
|
15
|
+
isAsync: boolean
|
|
16
|
+
signature: string
|
|
17
|
+
sourceFile: string | null
|
|
18
|
+
lineNumber: number | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const _registry: Map<string, RegisteredTool> = new Map()
|
|
22
|
+
|
|
23
|
+
export function getRegisteredTools(): RegisteredTool[] {
|
|
24
|
+
return Array.from(_registry.values())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getRegisteredTool(name: string): RegisteredTool | undefined {
|
|
28
|
+
return _registry.get(name)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clearToolRegistry(): void {
|
|
32
|
+
_registry.clear()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function inferCallerLocation(): { file: string | null; line: number | null } {
|
|
36
|
+
const stack = new Error().stack
|
|
37
|
+
if (!stack) return { file: null, line: null }
|
|
38
|
+
const lines = stack.split('\n')
|
|
39
|
+
for (let i = 2; i < lines.length; i++) {
|
|
40
|
+
const m = lines[i].match(/\((.*?):(\d+):\d+\)|at\s+(.*?):(\d+):\d+/)
|
|
41
|
+
if (!m) continue
|
|
42
|
+
const file = m[1] ?? m[3]
|
|
43
|
+
const line = parseInt(m[2] ?? m[4], 10)
|
|
44
|
+
if (file && !file.includes('tool-registry')) {
|
|
45
|
+
return { file: file.replace(/^file:\/\//, ''), line: isNaN(line) ? null : line }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { file: null, line: null }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function inferSignature(fn: Function): string {
|
|
52
|
+
const src = fn.toString()
|
|
53
|
+
const m = src.match(/^[^(]*\(([^)]*)\)/)
|
|
54
|
+
if (!m) return '()'
|
|
55
|
+
const params = m[1]
|
|
56
|
+
.split(',')
|
|
57
|
+
.map(p => p.trim().split(/[\s=:]/)[0])
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
return `(${params.join(', ')})`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Register a function as a rerunnable tool.
|
|
64
|
+
*
|
|
65
|
+
* export const myTool = edTool('my_tool', async (query: string) => { ... })
|
|
66
|
+
*
|
|
67
|
+
* The returned function is the telemetry-wrapped version, so calling it from
|
|
68
|
+
* normal code paths still produces traces. The CLI `run-tool` command and the
|
|
69
|
+
* ElasticDash MCP `run_tool` tool will resolve the original by name.
|
|
70
|
+
*/
|
|
71
|
+
export function edTool<Args extends unknown[], R>(
|
|
72
|
+
name: string,
|
|
73
|
+
fn: (...args: Args) => R | Promise<R>,
|
|
74
|
+
): (...args: Args) => Promise<R> {
|
|
75
|
+
const isAsync = fn.constructor.name === 'AsyncFunction'
|
|
76
|
+
const signature = inferSignature(fn)
|
|
77
|
+
const { file, line } = inferCallerLocation()
|
|
78
|
+
|
|
79
|
+
const wrapped = wrapTool(name, fn as (...args: unknown[]) => Promise<R>) as unknown as (...args: Args) => Promise<R>
|
|
80
|
+
|
|
81
|
+
_registry.set(name, {
|
|
82
|
+
name,
|
|
83
|
+
fn: fn as RegisteredTool['fn'],
|
|
84
|
+
wrapped: wrapped as RegisteredTool['wrapped'],
|
|
85
|
+
isAsync,
|
|
86
|
+
signature,
|
|
87
|
+
sourceFile: file,
|
|
88
|
+
lineNumber: line,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return wrapped
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const defineTool = edTool
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Mark this process as an Elasticdash worker before anything else runs
|
|
2
|
+
;(globalThis as any).__ELASTICDASH_WORKER__ = true
|
|
3
|
+
|
|
4
|
+
// Ensure .env is loaded in the worker subprocess
|
|
5
|
+
import 'dotenv/config'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* tool-runner-worker.ts
|
|
9
|
+
*
|
|
10
|
+
* Subprocess entry point for running a single tool function in an isolated
|
|
11
|
+
* Node.js process, guaranteeing no stale ESM/tsx module cache.
|
|
12
|
+
*
|
|
13
|
+
* Protocol (via stdin/stdout):
|
|
14
|
+
* stdin — one JSON line: { toolsModulePath, toolName, args, frozenEvents? }
|
|
15
|
+
* stdout — prefixed result line: __ELASTICDASH_RESULT__:{...json...}
|
|
16
|
+
*
|
|
17
|
+
* When frozenEvents are provided, sets up an HttpRunContext so that all
|
|
18
|
+
* freezable actions within the tool (wrapAI, wrapDB, and fetch-intercepted
|
|
19
|
+
* HTTP calls) will replay from frozen data instead of hitting real services.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { pathToFileURL } from 'node:url'
|
|
23
|
+
|
|
24
|
+
const RESULT_PREFIX = '__ELASTICDASH_RESULT__:'
|
|
25
|
+
|
|
26
|
+
function writeResult(result: unknown): Promise<void> {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
process.stdout.write(RESULT_PREFIX + JSON.stringify(result) + '\n', (err) =>
|
|
29
|
+
err ? reject(err) : resolve()
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface FrozenEvent {
|
|
35
|
+
id: number
|
|
36
|
+
type: string
|
|
37
|
+
name: string
|
|
38
|
+
input: unknown
|
|
39
|
+
output: unknown
|
|
40
|
+
timestamp: number
|
|
41
|
+
durationMs: number | null
|
|
42
|
+
streamed?: boolean
|
|
43
|
+
streamRaw?: string | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Set up frozen event replay context.
|
|
48
|
+
*
|
|
49
|
+
* For HTTP events: uses **URL-based matching** via a fetch wrapper, which is
|
|
50
|
+
* robust against ID counter drift.
|
|
51
|
+
*
|
|
52
|
+
* For AI and DB events: uses the HttpRunContext frozen map with sequential
|
|
53
|
+
* ID-based matching. All freezable event types (AI + DB) are included in the
|
|
54
|
+
* map and re-indexed in original execution order, so the shared nextId()
|
|
55
|
+
* counter stays aligned across interleaved wrapAI and wrapDB calls.
|
|
56
|
+
*/
|
|
57
|
+
async function setupFrozenContext(frozenEvents: FrozenEvent[]): Promise<void> {
|
|
58
|
+
if (frozenEvents.length === 0) return
|
|
59
|
+
|
|
60
|
+
// Always install URL-based fetch replay for HTTP events — this is the
|
|
61
|
+
// reliable matching strategy for tool reruns in subprocesses.
|
|
62
|
+
installFrozenFetchFallback(frozenEvents)
|
|
63
|
+
|
|
64
|
+
// Set up HttpRunContext for all freezable event types (AI + DB).
|
|
65
|
+
// Both wrapAI and wrapDB call httpCtx.nextId() sequentially, so we must
|
|
66
|
+
// include all freezable types in the frozen map, re-indexed in original
|
|
67
|
+
// execution order, to keep the sequential counter aligned.
|
|
68
|
+
const freezableEvents = frozenEvents
|
|
69
|
+
.filter(e => e.type === 'ai' || e.type === 'db')
|
|
70
|
+
.sort((a, b) => a.id - b.id) // preserve original execution order
|
|
71
|
+
if (freezableEvents.length > 0) {
|
|
72
|
+
try {
|
|
73
|
+
const telemetryPush = await import('./interceptors/telemetry-push.js')
|
|
74
|
+
const workflowEvents = freezableEvents.map((e, i) => ({
|
|
75
|
+
id: i + 1,
|
|
76
|
+
type: e.type as 'ai' | 'db',
|
|
77
|
+
name: e.name,
|
|
78
|
+
input: e.input,
|
|
79
|
+
output: e.output,
|
|
80
|
+
timestamp: e.timestamp,
|
|
81
|
+
durationMs: e.durationMs ?? 0,
|
|
82
|
+
streamed: e.streamed,
|
|
83
|
+
streamRaw: e.streamRaw,
|
|
84
|
+
}))
|
|
85
|
+
telemetryPush.setHttpRunContext('frozen-rerun', '')
|
|
86
|
+
const ctx = telemetryPush.getHttpRunContext()
|
|
87
|
+
if (ctx) {
|
|
88
|
+
for (const e of workflowEvents) {
|
|
89
|
+
ctx.frozenEvents.set(e.id, e as any)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Frozen replay not available — tool will hit real services
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Saved original fetch so we can restore after the tool runs. */
|
|
99
|
+
let savedOriginalFetch: typeof globalThis.fetch | null = null
|
|
100
|
+
|
|
101
|
+
function restoreFrozenFetch(): void {
|
|
102
|
+
if (savedOriginalFetch) {
|
|
103
|
+
globalThis.fetch = savedOriginalFetch
|
|
104
|
+
savedOriginalFetch = null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Intercept fetch for HTTP frozen events using URL-based matching.
|
|
110
|
+
* This is the primary replay mechanism for tool reruns — it matches
|
|
111
|
+
* by request URL rather than sequential ID, which is robust against
|
|
112
|
+
* ID counter drift from wrapTool/wrapAI consuming IDs.
|
|
113
|
+
*/
|
|
114
|
+
function installFrozenFetchFallback(frozenEvents: FrozenEvent[]): void {
|
|
115
|
+
const httpEvents = frozenEvents.filter(e => e.type === 'http')
|
|
116
|
+
|
|
117
|
+
const frozenUrls = httpEvents.map(e => {
|
|
118
|
+
const inp = e.input as Record<string, unknown> | null
|
|
119
|
+
return inp && typeof inp === 'object' && typeof inp.url === 'string' ? inp.url : '(no url)'
|
|
120
|
+
})
|
|
121
|
+
process.stderr.write(`[elasticdash-worker] Frozen HTTP events: ${httpEvents.length}, URLs: ${JSON.stringify(frozenUrls)}\n`)
|
|
122
|
+
|
|
123
|
+
savedOriginalFetch = globalThis.fetch
|
|
124
|
+
const originalFetch = globalThis.fetch
|
|
125
|
+
const usageCounts = new Map<string, number>()
|
|
126
|
+
|
|
127
|
+
globalThis.fetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
128
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url
|
|
129
|
+
|
|
130
|
+
const matches = httpEvents.filter(e => {
|
|
131
|
+
const frozenUrl = (e.input && typeof e.input === 'object' && 'url' in (e.input as Record<string, unknown>))
|
|
132
|
+
? (e.input as Record<string, unknown>).url
|
|
133
|
+
: null
|
|
134
|
+
return frozenUrl === url
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
if (matches.length > 0) {
|
|
138
|
+
const callCount = usageCounts.get(url) || 0
|
|
139
|
+
const match = matches[Math.min(callCount, matches.length - 1)]
|
|
140
|
+
usageCounts.set(url, callCount + 1)
|
|
141
|
+
|
|
142
|
+
process.stderr.write(`[elasticdash-worker] Frozen replay: ${url}\n`)
|
|
143
|
+
const body = typeof match.output === 'string' ? match.output : JSON.stringify(match.output)
|
|
144
|
+
return new Response(body, {
|
|
145
|
+
status: 200,
|
|
146
|
+
headers: { 'Content-Type': 'application/json', 'X-Frozen': 'true' }
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
process.stderr.write(`[elasticdash-worker] Fetch pass-through (no frozen match): ${url}\n`)
|
|
151
|
+
try {
|
|
152
|
+
return await originalFetch(input, init)
|
|
153
|
+
} catch (e) {
|
|
154
|
+
process.stderr.write(`[elasticdash-worker] Fetch FAILED for ${url}: ${(e as Error).stack || (e as Error).message || String(e)}\n`)
|
|
155
|
+
throw e
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function main() {
|
|
161
|
+
const originalExit = process.exit.bind(process)
|
|
162
|
+
|
|
163
|
+
// Prevent the SDK's tryAutoInitHttpContext from triggering full observability
|
|
164
|
+
// or dashboard-mode initialization in this subprocess. The subprocess should
|
|
165
|
+
// only execute the tool — observability/dashboard contexts are set up
|
|
166
|
+
// explicitly via setupFrozenContext when frozen events are provided.
|
|
167
|
+
// Without this, inherited env vars cause initObservability() to install
|
|
168
|
+
// interceptors, socket connections, and heartbeats that conflict with
|
|
169
|
+
// the frozen replay setup and can cause "Invalid URL" errors from
|
|
170
|
+
// Langfuse/OTel trying to connect to servers during tool execution.
|
|
171
|
+
delete process.env.ELASTICDASH_API_URL
|
|
172
|
+
delete process.env.ELASTICDASH_SERVER
|
|
173
|
+
|
|
174
|
+
let raw = ''
|
|
175
|
+
for await (const chunk of process.stdin) {
|
|
176
|
+
raw += chunk
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let payload: { toolsModulePath: string; toolName: string; args: unknown[]; frozenEvents?: FrozenEvent[] }
|
|
180
|
+
try {
|
|
181
|
+
payload = JSON.parse(raw)
|
|
182
|
+
} catch (e) {
|
|
183
|
+
await writeResult({ ok: false, error: `Invalid JSON input: ${(e as Error).message}` })
|
|
184
|
+
originalExit(1)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { toolsModulePath, toolName, args, frozenEvents } = payload
|
|
189
|
+
|
|
190
|
+
// Set up frozen event context before running the tool.
|
|
191
|
+
// Uses URL-based matching for HTTP events (reliable for observability reruns)
|
|
192
|
+
// and ID-based matching for DB events.
|
|
193
|
+
const hasFrozen = frozenEvents && frozenEvents.length > 0
|
|
194
|
+
if (hasFrozen) {
|
|
195
|
+
await setupFrozenContext(frozenEvents)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
let mod: any
|
|
200
|
+
try {
|
|
201
|
+
mod = await import(pathToFileURL(toolsModulePath).href)
|
|
202
|
+
} catch (importErr) {
|
|
203
|
+
const ie = importErr as Error
|
|
204
|
+
await writeResult({ ok: false, error: `Failed to import tool module: ${ie.stack || ie.message}` })
|
|
205
|
+
originalExit(1)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Registry first: covers tools defined via edTool() anywhere in the project,
|
|
210
|
+
// as long as their containing module is reachable from toolsModulePath's
|
|
211
|
+
// import graph. Falls back to ed_tools-style module export lookup.
|
|
212
|
+
let fn: ((...a: unknown[]) => unknown) | undefined
|
|
213
|
+
try {
|
|
214
|
+
const reg = await import('./tool-registry.js')
|
|
215
|
+
const registered = reg.getRegisteredTool(toolName)
|
|
216
|
+
if (registered) fn = registered.wrapped
|
|
217
|
+
} catch {
|
|
218
|
+
// Registry module not available (older SDK build); fall through to export lookup.
|
|
219
|
+
}
|
|
220
|
+
if (!fn) {
|
|
221
|
+
const exported = mod[toolName]
|
|
222
|
+
if (typeof exported === 'function') fn = exported
|
|
223
|
+
}
|
|
224
|
+
if (typeof fn !== 'function') {
|
|
225
|
+
await writeResult({ ok: false, error: `"${toolName}" not found via edTool() registry or as an exported function in the module.` })
|
|
226
|
+
originalExit(1)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const currentOutput = await fn(...args)
|
|
231
|
+
await writeResult({ ok: true, currentOutput })
|
|
232
|
+
originalExit(0)
|
|
233
|
+
} catch (e) {
|
|
234
|
+
const err = e as Error
|
|
235
|
+
const errorMsg = err.stack || err.message || String(e)
|
|
236
|
+
process.stderr.write(`[elasticdash-worker] Tool execution failed:\n${errorMsg}\n`)
|
|
237
|
+
await writeResult({ ok: false, error: errorMsg })
|
|
238
|
+
originalExit(1)
|
|
239
|
+
} finally {
|
|
240
|
+
if (hasFrozen) restoreFrozenFetch()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
main()
|