elasticdash-test 0.1.17 → 0.1.18-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/capture/event.d.ts +5 -1
  2. package/dist/capture/event.d.ts.map +1 -1
  3. package/dist/cli.js +100 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/evaluators/llm-judge.js +17 -14
  6. package/dist/evaluators/types.d.ts +1 -0
  7. package/dist/execution/tool-runner.d.ts +26 -0
  8. package/dist/execution/tool-runner.d.ts.map +1 -0
  9. package/dist/execution/tool-runner.js +270 -0
  10. package/dist/execution/tool-runner.js.map +1 -0
  11. package/dist/http.d.ts +2 -0
  12. package/dist/http.d.ts.map +1 -1
  13. package/dist/http.js +2 -0
  14. package/dist/http.js.map +1 -1
  15. package/dist/index.cjs +4310 -2672
  16. package/dist/index.d.ts +10 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +7 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/interceptors/ai-interceptor.d.ts.map +1 -1
  21. package/dist/interceptors/ai-interceptor.js +97 -4
  22. package/dist/interceptors/ai-interceptor.js.map +1 -1
  23. package/dist/interceptors/db-auto.d.ts.map +1 -1
  24. package/dist/interceptors/db-auto.js +116 -24
  25. package/dist/interceptors/db-auto.js.map +1 -1
  26. package/dist/interceptors/db.d.ts +5 -0
  27. package/dist/interceptors/db.d.ts.map +1 -1
  28. package/dist/interceptors/db.js +93 -15
  29. package/dist/interceptors/db.js.map +1 -1
  30. package/dist/interceptors/http.d.ts.map +1 -1
  31. package/dist/interceptors/http.js +125 -93
  32. package/dist/interceptors/http.js.map +1 -1
  33. package/dist/interceptors/telemetry-push.d.ts +15 -0
  34. package/dist/interceptors/telemetry-push.d.ts.map +1 -1
  35. package/dist/interceptors/telemetry-push.js +96 -13
  36. package/dist/interceptors/telemetry-push.js.map +1 -1
  37. package/dist/interceptors/tool.d.ts.map +1 -1
  38. package/dist/interceptors/tool.js +42 -5
  39. package/dist/interceptors/tool.js.map +1 -1
  40. package/dist/interceptors/workflow-ai.d.ts.map +1 -1
  41. package/dist/interceptors/workflow-ai.js +46 -2
  42. package/dist/interceptors/workflow-ai.js.map +1 -1
  43. package/dist/observability.d.ts +69 -0
  44. package/dist/observability.d.ts.map +1 -0
  45. package/dist/observability.js +242 -0
  46. package/dist/observability.js.map +1 -0
  47. package/dist/portal-executor.d.ts +30 -0
  48. package/dist/portal-executor.d.ts.map +1 -0
  49. package/dist/portal-executor.js +304 -0
  50. package/dist/portal-executor.js.map +1 -0
  51. package/dist/portal-server.d.ts +3 -0
  52. package/dist/portal-server.d.ts.map +1 -0
  53. package/dist/portal-server.js +265 -0
  54. package/dist/portal-server.js.map +1 -0
  55. package/dist/telemetry-batcher.d.ts +43 -0
  56. package/dist/telemetry-batcher.d.ts.map +1 -0
  57. package/dist/telemetry-batcher.js +111 -0
  58. package/dist/telemetry-batcher.js.map +1 -0
  59. package/dist/trigger-executor.d.ts +12 -0
  60. package/dist/trigger-executor.d.ts.map +1 -0
  61. package/dist/trigger-executor.js +83 -0
  62. package/dist/trigger-executor.js.map +1 -0
  63. package/dist/types/portal.d.ts +64 -0
  64. package/dist/types/portal.d.ts.map +1 -0
  65. package/dist/types/portal.js +2 -0
  66. package/dist/types/portal.js.map +1 -0
  67. package/dist/utils/debug.d.ts +3 -0
  68. package/dist/utils/debug.d.ts.map +1 -0
  69. package/dist/utils/debug.js +8 -0
  70. package/dist/utils/debug.js.map +1 -0
  71. package/dist/utils/redact.d.ts +7 -0
  72. package/dist/utils/redact.d.ts.map +1 -0
  73. package/dist/utils/redact.js +26 -0
  74. package/dist/utils/redact.js.map +1 -0
  75. package/package.json +9 -1
  76. package/src/capture/event.ts +5 -1
  77. package/src/cli.ts +109 -0
  78. package/src/execution/tool-runner.ts +304 -0
  79. package/src/http.ts +2 -0
  80. package/src/index.ts +14 -0
  81. package/src/interceptors/ai-interceptor.ts +110 -4
  82. package/src/interceptors/db-auto.ts +121 -25
  83. package/src/interceptors/db.ts +92 -17
  84. package/src/interceptors/http.ts +145 -107
  85. package/src/interceptors/telemetry-push.ts +113 -13
  86. package/src/interceptors/tool.ts +42 -5
  87. package/src/interceptors/workflow-ai.ts +49 -2
  88. package/src/observability.ts +281 -0
  89. package/src/portal-executor.ts +335 -0
  90. package/src/portal-server.ts +290 -0
  91. package/src/telemetry-batcher.ts +143 -0
  92. package/src/trigger-executor.ts +121 -0
  93. package/src/types/portal.ts +67 -0
  94. package/src/utils/debug.ts +8 -0
  95. package/src/utils/redact.ts +25 -0
@@ -0,0 +1,290 @@
1
+ import express from 'express'
2
+ import http from 'node:http'
3
+ import { executePortalTask } from './portal-executor.js'
4
+ import { scanTools } from './execution/tool-runner.js'
5
+ import type { ToolInfo } from './execution/tool-runner.js'
6
+ import type {
7
+ PortalTask,
8
+ PortalTaskResult,
9
+ PortalServerOptions,
10
+ PortalServerHandle,
11
+ PortalStatus,
12
+ } from './types/portal.js'
13
+ import { debugLog } from './utils/debug.js'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Origin allowlist
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function extractHost(url: string): string | null {
20
+ try {
21
+ return new URL(url).host
22
+ } catch {
23
+ return null
24
+ }
25
+ }
26
+
27
+ function buildAllowedHosts(backendUrl: string, extra?: string[]): Set<string> {
28
+ const hosts = new Set<string>()
29
+ // Always allow localhost variants
30
+ hosts.add('localhost')
31
+ hosts.add('127.0.0.1')
32
+ hosts.add('::1')
33
+ // Allow the configured backend
34
+ const backendHost = extractHost(backendUrl)
35
+ if (backendHost) hosts.add(backendHost)
36
+ // Allow explicit extra origins
37
+ if (extra) {
38
+ for (const origin of extra) {
39
+ const h = extractHost(origin)
40
+ if (h) hosts.add(h)
41
+ else hosts.add(origin) // treat raw hostname as-is
42
+ }
43
+ }
44
+ return hosts
45
+ }
46
+
47
+ function isAllowedOrigin(req: express.Request, allowedHosts: Set<string>): boolean {
48
+ // Determine the caller's host from multiple headers
49
+ // 1. Origin header (set by browsers and some HTTP clients)
50
+ const origin = req.headers.origin
51
+ if (origin) {
52
+ const h = extractHost(origin)
53
+ return h !== null && isHostAllowed(h, allowedHosts)
54
+ }
55
+ // 2. Referer header fallback
56
+ const referer = req.headers.referer
57
+ if (referer) {
58
+ const h = extractHost(referer)
59
+ return h !== null && isHostAllowed(h, allowedHosts)
60
+ }
61
+ // 3. X-Forwarded-For / remote IP — check if it's a local request
62
+ const remoteIp = req.ip ?? req.socket?.remoteAddress ?? ''
63
+ if (remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1') {
64
+ return true
65
+ }
66
+ // 4. No origin info — this is a direct server-to-server call.
67
+ // If API key auth is configured and passes, allow it.
68
+ // If no API key is configured, reject unknown-origin requests.
69
+ return false
70
+ }
71
+
72
+ function isHostAllowed(host: string, allowedHosts: Set<string>): boolean {
73
+ // Exact match (includes port, e.g. "localhost:4573")
74
+ if (allowedHosts.has(host)) return true
75
+ // Match without port
76
+ const hostWithoutPort = host.split(':')[0]
77
+ if (allowedHosts.has(hostWithoutPort)) return true
78
+ // Match subdomains (e.g. "api.elasticdash.com" matches "elasticdash.com")
79
+ for (const allowed of allowedHosts) {
80
+ if (hostWithoutPort.endsWith('.' + allowed)) return true
81
+ }
82
+ return false
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Server
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export async function startPortalServer(options: PortalServerOptions): Promise<PortalServerHandle> {
90
+ const port = options.port ?? 4574
91
+ const backendUrl = options.backendUrl.replace(/\/$/, '')
92
+ const apiKey = options.apiKey
93
+ const cwd = options.cwd ?? process.cwd()
94
+
95
+ // Build allowed-origin set from backendUrl + explicit allowlist + localhost
96
+ const allowedHosts = buildAllowedHosts(backendUrl, options.allowedOrigins)
97
+ console.log(`[elasticdash portal] Allowed origins: ${[...allowedHosts].join(', ')}`)
98
+
99
+ // Scan tools at startup
100
+ const tools: ToolInfo[] = scanTools(cwd)
101
+ console.log(`[elasticdash portal] Scanned ${tools.length} tools: ${tools.map(t => t.name).join(', ') || '(none)'}`)
102
+
103
+ // Queue state
104
+ const queue: PortalTask[] = []
105
+ let processing: string | null = null
106
+ let completed = 0
107
+ let failed = 0
108
+ let draining = false
109
+
110
+ // -------------------------------------------------------------------------
111
+ // Queue processor
112
+ // -------------------------------------------------------------------------
113
+
114
+ async function processQueue(): Promise<void> {
115
+ if (draining || processing) return
116
+ if (queue.length === 0) return
117
+
118
+ const task = queue.shift()!
119
+ processing = task.taskId
120
+ console.log(`[elasticdash portal] Processing task ${task.taskId} (type=${task.type}, name=${task.name}) — ${queue.length} remaining`)
121
+
122
+ const result = await executePortalTask(task, cwd, tools)
123
+
124
+ if (result.ok) {
125
+ completed++
126
+ console.log(`[elasticdash portal] Task ${task.taskId} completed (${result.durationMs}ms)`)
127
+ } else {
128
+ failed++
129
+ console.log(`[elasticdash portal] Task ${task.taskId} failed: ${result.error}`)
130
+ }
131
+
132
+ processing = null
133
+
134
+ // Deliver result to backend
135
+ await deliverResult(result)
136
+
137
+ // Process next task
138
+ processQueue().catch(() => {})
139
+ }
140
+
141
+ async function deliverResult(result: PortalTaskResult): Promise<void> {
142
+ const url = `${backendUrl}/api/portal/results/${result.taskId}`
143
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
144
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
145
+
146
+ for (let attempt = 0; attempt < 3; attempt++) {
147
+ try {
148
+ const res = await fetch(url, {
149
+ method: 'POST',
150
+ headers,
151
+ body: JSON.stringify(result),
152
+ })
153
+ if (res.ok || res.status < 500) {
154
+ debugLog(`[elasticdash portal] Result delivered for task ${result.taskId} (status ${res.status})`)
155
+ return
156
+ }
157
+ debugLog(`[elasticdash portal] Result delivery failed (status ${res.status}), attempt ${attempt + 1}/3`)
158
+ } catch (e) {
159
+ debugLog(`[elasticdash portal] Result delivery error, attempt ${attempt + 1}/3: ${e instanceof Error ? e.message : String(e)}`)
160
+ }
161
+ if (attempt < 2) {
162
+ await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000))
163
+ }
164
+ }
165
+ console.warn(`[elasticdash portal] WARNING: Failed to deliver result for task ${result.taskId} after 3 retries`)
166
+ }
167
+
168
+ // -------------------------------------------------------------------------
169
+ // Security middleware: origin allowlist + API key auth
170
+ // -------------------------------------------------------------------------
171
+
172
+ function securityMiddleware(req: express.Request, res: express.Response, next: express.NextFunction): void {
173
+ // Check API key first — a valid key overrides origin checks
174
+ if (apiKey) {
175
+ const authHeader = req.headers.authorization
176
+ if (authHeader === `Bearer ${apiKey}`) return next()
177
+ }
178
+
179
+ // Check origin allowlist
180
+ if (isAllowedOrigin(req, allowedHosts)) {
181
+ // If API key is configured but not provided, still reject
182
+ if (apiKey) {
183
+ res.status(401).json({ ok: false, error: 'unauthorized — valid API key required' })
184
+ return
185
+ }
186
+ return next()
187
+ }
188
+
189
+ // Origin not in allowlist and no valid API key
190
+ const origin = req.headers.origin ?? req.headers.referer ?? req.ip ?? 'unknown'
191
+ console.warn(`[elasticdash portal] Blocked request from disallowed origin: ${origin}`)
192
+ res.status(403).json({ ok: false, error: 'forbidden — origin not in allowlist' })
193
+ }
194
+
195
+ // -------------------------------------------------------------------------
196
+ // Express app
197
+ // -------------------------------------------------------------------------
198
+
199
+ const app = express()
200
+ app.use(express.json({ limit: '10mb' }))
201
+
202
+ // POST /api/portal/tasks — enqueue a single task
203
+ app.post('/api/portal/tasks', securityMiddleware, (req, res) => {
204
+ const task = req.body as PortalTask
205
+ if (!task?.taskId || !task?.type || !task?.name) {
206
+ res.status(400).json({ ok: false, error: 'Missing required fields: taskId, type, name' })
207
+ return
208
+ }
209
+ queue.push(task)
210
+ const position = queue.length
211
+ debugLog(`[elasticdash portal] Task ${task.taskId} enqueued at position ${position}`)
212
+ res.status(202).json({ ok: true, taskId: task.taskId, position })
213
+ processQueue().catch(() => {})
214
+ })
215
+
216
+ // POST /api/portal/tasks/batch — enqueue multiple tasks
217
+ app.post('/api/portal/tasks/batch', securityMiddleware, (req, res) => {
218
+ const body = req.body as { tasks?: PortalTask[] }
219
+ if (!Array.isArray(body?.tasks) || body.tasks.length === 0) {
220
+ res.status(400).json({ ok: false, error: 'Missing or empty tasks array' })
221
+ return
222
+ }
223
+ const results: Array<{ taskId: string; position: number }> = []
224
+ for (const task of body.tasks) {
225
+ if (!task?.taskId || !task?.type || !task?.name) continue
226
+ queue.push(task)
227
+ results.push({ taskId: task.taskId, position: queue.length })
228
+ }
229
+ debugLog(`[elasticdash portal] Batch enqueued ${results.length} tasks`)
230
+ res.status(202).json({ ok: true, tasks: results })
231
+ processQueue().catch(() => {})
232
+ })
233
+
234
+ // GET /api/portal/status — health check
235
+ app.get('/api/portal/status', (_req, res) => {
236
+ const status: PortalStatus = {
237
+ ok: true,
238
+ queueLength: queue.length,
239
+ processing,
240
+ completed,
241
+ failed,
242
+ }
243
+ res.json(status)
244
+ })
245
+
246
+ // DELETE /api/portal/tasks/:taskId — cancel a pending task
247
+ app.delete('/api/portal/tasks/:taskId', securityMiddleware, (req, res) => {
248
+ const taskId = req.params.taskId
249
+ const index = queue.findIndex(t => t.taskId === taskId)
250
+ if (index === -1) {
251
+ res.status(404).json({ ok: false, error: 'Task not found in queue (may be already processing or completed)' })
252
+ return
253
+ }
254
+ queue.splice(index, 1)
255
+ res.json({ ok: true, taskId })
256
+ })
257
+
258
+ // -------------------------------------------------------------------------
259
+ // Start server
260
+ // -------------------------------------------------------------------------
261
+
262
+ const server = http.createServer(app)
263
+
264
+ await new Promise<void>((resolve, reject) => {
265
+ server.on('error', reject)
266
+ server.listen(port, () => resolve())
267
+ })
268
+
269
+ const url = `http://localhost:${port}`
270
+
271
+ return {
272
+ port,
273
+ url,
274
+ close: async () => {
275
+ draining = true
276
+ // Wait for current task to finish if processing
277
+ if (processing) {
278
+ console.log(`[elasticdash portal] Waiting for current task ${processing} to finish...`)
279
+ await new Promise<void>((resolve) => {
280
+ const check = setInterval(() => {
281
+ if (!processing) { clearInterval(check); resolve() }
282
+ }, 200)
283
+ })
284
+ }
285
+ await new Promise<void>((resolve, reject) => {
286
+ server.close((err) => err ? reject(err) : resolve())
287
+ })
288
+ },
289
+ }
290
+ }
@@ -0,0 +1,143 @@
1
+ import type { WorkflowEvent } from './capture/event.js'
2
+ import { debugLog } from './utils/debug.js'
3
+ import { redactPayload } from './utils/redact.js'
4
+
5
+ export interface TriggerStep {
6
+ eventId: number
7
+ eventType: 'ai' | 'tool'
8
+ eventName: string
9
+ originalEventDbId: number
10
+ input: unknown
11
+ model?: string
12
+ provider?: string
13
+ }
14
+
15
+ export interface TriggerSignal {
16
+ triggerId: number
17
+ runCount: number
18
+ steps: TriggerStep[]
19
+ }
20
+
21
+ export interface TelemetryBatcherOptions {
22
+ serverUrl: string
23
+ apiKey?: string
24
+ sessionId: string
25
+ serviceId: string
26
+ batchIntervalMs?: number
27
+ maxBatchSize?: number
28
+ redactKeys?: string[]
29
+ onTrigger?: (trigger: TriggerSignal) => Promise<void>
30
+ }
31
+
32
+ export class TelemetryBatcher {
33
+ private buffer: WorkflowEvent[] = []
34
+ private timer: ReturnType<typeof setInterval> | null = null
35
+ private readonly serverUrl: string
36
+ private readonly apiKey: string | undefined
37
+ private readonly sessionId: string
38
+ private readonly serviceId: string
39
+ private readonly maxBatchSize: number
40
+ private readonly redactKeys: string[]
41
+ private readonly onTrigger: ((trigger: TriggerSignal) => Promise<void>) | undefined
42
+ private shuttingDown = false
43
+
44
+ constructor(opts: TelemetryBatcherOptions) {
45
+ this.serverUrl = opts.serverUrl.replace(/\/$/, '')
46
+ this.apiKey = opts.apiKey
47
+ this.sessionId = opts.sessionId
48
+ this.serviceId = opts.serviceId
49
+ this.maxBatchSize = opts.maxBatchSize ?? 50
50
+ this.redactKeys = opts.redactKeys ?? []
51
+ this.onTrigger = opts.onTrigger
52
+
53
+ const intervalMs = opts.batchIntervalMs ?? 2000
54
+ this.timer = setInterval(() => { this.flush().catch(() => {}) }, intervalMs)
55
+ // Allow the process to exit even if the timer is still scheduled
56
+ if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
57
+ this.timer.unref()
58
+ }
59
+ }
60
+
61
+ enqueue(event: WorkflowEvent): void {
62
+ if (this.shuttingDown) return
63
+ const redacted = this.redactKeys.length > 0
64
+ ? { ...event, input: redactPayload(event.input, this.redactKeys), output: redactPayload(event.output, this.redactKeys) }
65
+ : event
66
+ this.buffer.push(redacted as WorkflowEvent)
67
+ if (this.buffer.length >= this.maxBatchSize) {
68
+ this.flush().catch(() => {})
69
+ }
70
+ }
71
+
72
+ async flush(): Promise<void> {
73
+ if (this.buffer.length === 0) return
74
+ const batch = this.buffer.splice(0, this.buffer.length)
75
+ await this.send(batch, 0)
76
+ }
77
+
78
+ private async send(batch: WorkflowEvent[], attempt: number): Promise<void> {
79
+ const maxRetries = 3
80
+ const url = `${this.serverUrl}/api/observability/events`
81
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
82
+ if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}`
83
+
84
+ try {
85
+ const res = await fetch(url, {
86
+ method: 'POST',
87
+ headers,
88
+ body: JSON.stringify({
89
+ sessionId: this.sessionId,
90
+ serviceId: this.serviceId,
91
+ events: batch,
92
+ }),
93
+ })
94
+
95
+ if (res.status === 429 || res.status >= 500) {
96
+ if (attempt < maxRetries) {
97
+ const delayMs = Math.pow(2, attempt) * 1000
98
+ debugLog(`[elasticdash] Telemetry flush failed (${res.status}), retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`)
99
+ await new Promise((r) => setTimeout(r, delayMs))
100
+ return this.send(batch, attempt + 1)
101
+ }
102
+ debugLog(`[elasticdash] Dropping ${batch.length} events after ${maxRetries} retries (last status: ${res.status})`)
103
+ return
104
+ }
105
+
106
+ debugLog(`[elasticdash] Flushed ${batch.length} events (status ${res.status})`)
107
+
108
+ // Parse response for trigger signal
109
+ if (res.ok && this.onTrigger) {
110
+ try {
111
+ const body = await res.json() as { trigger?: TriggerSignal }
112
+ if (body.trigger && typeof body.trigger.triggerId === 'number' && Array.isArray(body.trigger.steps)) {
113
+ debugLog(`[elasticdash] Trigger received: id=${body.trigger.triggerId} steps=${body.trigger.steps.length} runCount=${body.trigger.runCount}`)
114
+ // Fire-and-forget — don't block the flush pipeline
115
+ this.onTrigger(body.trigger).catch((err) => {
116
+ debugLog(`[elasticdash] Trigger execution failed: ${err instanceof Error ? err.message : String(err)}`)
117
+ })
118
+ }
119
+ } catch {
120
+ // Response parsing failed — not critical, ignore
121
+ }
122
+ }
123
+ } catch (err) {
124
+ if (attempt < maxRetries) {
125
+ const delayMs = Math.pow(2, attempt) * 1000
126
+ debugLog(`[elasticdash] Telemetry flush error, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`)
127
+ await new Promise((r) => setTimeout(r, delayMs))
128
+ return this.send(batch, attempt + 1)
129
+ }
130
+ debugLog(`[elasticdash] Dropping ${batch.length} events after ${maxRetries} retries: ${err instanceof Error ? err.message : String(err)}`)
131
+ }
132
+ }
133
+
134
+ async shutdown(): Promise<void> {
135
+ if (this.shuttingDown) return
136
+ this.shuttingDown = true
137
+ if (this.timer) {
138
+ clearInterval(this.timer)
139
+ this.timer = null
140
+ }
141
+ await this.flush()
142
+ }
143
+ }
@@ -0,0 +1,121 @@
1
+ import type { TriggerSignal } from './telemetry-batcher.js'
2
+ import { executePortalTask, checkToolAvailability, checkAIAvailability } from './portal-executor.js'
3
+ import { scanTools } from './execution/tool-runner.js'
4
+ import { debugLog } from './utils/debug.js'
5
+
6
+ interface StepRunResult {
7
+ runIndex: number
8
+ input: unknown
9
+ output: unknown
10
+ durationMs: number
11
+ error?: string
12
+ usageInputTokens?: number
13
+ usageOutputTokens?: number
14
+ usageTotalTokens?: number
15
+ }
16
+
17
+ interface StepResult {
18
+ originalEventDbId: number
19
+ eventType: string
20
+ eventName: string
21
+ available: boolean
22
+ unavailableReason?: string
23
+ runs: StepRunResult[]
24
+ }
25
+
26
+ /**
27
+ * Executes a trigger received from the backend's event batch response.
28
+ *
29
+ * For each step:
30
+ * 1. Pre-validates availability (tool exists? API key set?)
31
+ * 2. If unavailable: reports `available: false` with reason, skips execution
32
+ * 3. If available: re-executes `runCount` times, collects results
33
+ * 4. POSTs all results to `POST /api/observability/triggers/:triggerId/results`
34
+ */
35
+ export async function executeTrigger(
36
+ serverUrl: string,
37
+ apiKey: string | undefined,
38
+ trigger: TriggerSignal,
39
+ ): Promise<void> {
40
+ const cwd = process.cwd()
41
+ const tools = scanTools(cwd)
42
+
43
+ debugLog(`[elasticdash] Executing trigger ${trigger.triggerId}: ${trigger.steps.length} steps × ${trigger.runCount} runs`)
44
+
45
+ const stepResults: StepResult[] = []
46
+
47
+ for (const step of trigger.steps) {
48
+ // Pre-validate availability
49
+ const availability = step.eventType === 'ai'
50
+ ? checkAIAvailability(step.provider, step.model ?? step.eventName)
51
+ : checkToolAvailability(step.eventName, cwd, tools)
52
+
53
+ if (!availability.available) {
54
+ debugLog(`[elasticdash] Trigger ${trigger.triggerId} step=${step.eventName} unavailable: ${availability.reason}`)
55
+ stepResults.push({
56
+ originalEventDbId: step.originalEventDbId,
57
+ eventType: step.eventType,
58
+ eventName: step.eventName,
59
+ available: false,
60
+ unavailableReason: availability.reason,
61
+ runs: [],
62
+ })
63
+ continue
64
+ }
65
+
66
+ // Execute runs
67
+ const runs: StepRunResult[] = []
68
+
69
+ for (let i = 0; i < trigger.runCount; i++) {
70
+ const result = await executePortalTask(
71
+ {
72
+ taskId: `trigger-${trigger.triggerId}-${step.eventName}-${i}`,
73
+ type: step.eventType === 'ai' ? 'ai' : 'tool',
74
+ name: step.eventName,
75
+ input: step.input,
76
+ model: step.eventType === 'ai' ? (step.model ?? step.eventName) : undefined,
77
+ provider: step.provider,
78
+ },
79
+ cwd,
80
+ tools,
81
+ )
82
+
83
+ runs.push({
84
+ runIndex: i,
85
+ input: step.input,
86
+ output: result.output,
87
+ durationMs: result.durationMs,
88
+ error: result.error,
89
+ usageInputTokens: result.usage?.inputTokens,
90
+ usageOutputTokens: result.usage?.outputTokens,
91
+ usageTotalTokens: result.usage?.totalTokens,
92
+ })
93
+
94
+ debugLog(`[elasticdash] Trigger ${trigger.triggerId} step=${step.eventName} run=${i} ok=${result.ok}`)
95
+ }
96
+
97
+ stepResults.push({
98
+ originalEventDbId: step.originalEventDbId,
99
+ eventType: step.eventType,
100
+ eventName: step.eventName,
101
+ available: true,
102
+ runs,
103
+ })
104
+ }
105
+
106
+ // POST results to backend
107
+ const url = `${serverUrl.replace(/\/$/, '')}/api/observability/triggers/${trigger.triggerId}/results`
108
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
109
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
110
+
111
+ try {
112
+ const res = await fetch(url, {
113
+ method: 'POST',
114
+ headers,
115
+ body: JSON.stringify({ steps: stepResults }),
116
+ })
117
+ debugLog(`[elasticdash] Trigger ${trigger.triggerId} results posted (status ${res.status})`)
118
+ } catch (err) {
119
+ debugLog(`[elasticdash] Trigger ${trigger.triggerId} results POST failed: ${err instanceof Error ? err.message : String(err)}`)
120
+ }
121
+ }
@@ -0,0 +1,67 @@
1
+ export interface PortalTask {
2
+ /** Unique task ID assigned by the backend */
3
+ taskId: string
4
+ /** What to rerun */
5
+ type: 'tool' | 'ai'
6
+ /** Tool name or model name */
7
+ name: string
8
+ /** Tool arguments or LLM prompt/messages */
9
+ input: unknown
10
+ /** Model name (for AI tasks) */
11
+ model?: string
12
+ /** LLM provider: openai, anthropic, gemini, grok, etc. */
13
+ provider?: string
14
+ /** LLM generation parameters */
15
+ modelParameters?: {
16
+ temperature?: number
17
+ max_tokens?: number
18
+ }
19
+ /** Passthrough metadata (test group ID, expectation IDs, etc.) */
20
+ metadata?: Record<string, unknown>
21
+ }
22
+
23
+ export interface PortalTaskResult {
24
+ taskId: string
25
+ ok: boolean
26
+ output: unknown
27
+ error?: string
28
+ durationMs: number
29
+ usage?: {
30
+ inputTokens?: number
31
+ outputTokens?: number
32
+ totalTokens?: number
33
+ }
34
+ /** Echoed from the original task */
35
+ metadata?: Record<string, unknown>
36
+ }
37
+
38
+ export interface PortalServerOptions {
39
+ /** Port to listen on (default 4574) */
40
+ port?: number
41
+ /** Backend URL to POST results to */
42
+ backendUrl: string
43
+ /** Auth token for incoming and outgoing requests */
44
+ apiKey?: string
45
+ /** Project root directory (default process.cwd()) */
46
+ cwd?: string
47
+ /**
48
+ * Allowed origin domains that may send requests to this portal.
49
+ * By default only the `backendUrl` domain and localhost are allowed.
50
+ * Provide additional origins (e.g. 'https://app.elasticdash.com') to extend.
51
+ */
52
+ allowedOrigins?: string[]
53
+ }
54
+
55
+ export interface PortalServerHandle {
56
+ port: number
57
+ url: string
58
+ close: () => Promise<void>
59
+ }
60
+
61
+ export interface PortalStatus {
62
+ ok: boolean
63
+ queueLength: number
64
+ processing: string | null
65
+ completed: number
66
+ failed: number
67
+ }
@@ -0,0 +1,8 @@
1
+ const DEBUG_KEY = 'ELASTICDASH_DEBUG'
2
+
3
+ /** Log only when ELASTICDASH_DEBUG=1 is set. Drop-in replacement for console.log in interceptors. */
4
+ export function debugLog(...args: unknown[]): void {
5
+ if (typeof process !== 'undefined' && process.env?.[DEBUG_KEY] === '1') {
6
+ console.log(...args)
7
+ }
8
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Deep-clones a value and replaces any object property whose key matches
3
+ * one of `keys` (case-insensitive) with "[REDACTED]".
4
+ * Returns the original value when `keys` is empty.
5
+ */
6
+ export function redactPayload(value: unknown, keys: string[]): unknown {
7
+ if (keys.length === 0) return value
8
+ const lowerKeys = new Set(keys.map((k) => k.toLowerCase()))
9
+ return redact(value, lowerKeys)
10
+ }
11
+
12
+ function redact(value: unknown, keys: Set<string>): unknown {
13
+ if (value === null || value === undefined) return value
14
+ if (typeof value !== 'object') return value
15
+
16
+ if (Array.isArray(value)) {
17
+ return value.map((item) => redact(item, keys))
18
+ }
19
+
20
+ const out: Record<string, unknown> = {}
21
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
22
+ out[k] = keys.has(k.toLowerCase()) ? '[REDACTED]' : redact(v, keys)
23
+ }
24
+ return out
25
+ }