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,281 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { TelemetryBatcher } from './telemetry-batcher.js'
3
+ import {
4
+ setObservabilityContext,
5
+ getObservabilityContext,
6
+ clearObservabilityContext,
7
+ pushTelemetryEvent,
8
+ } from './interceptors/telemetry-push.js'
9
+ import type { ObservabilityContext } from './interceptors/telemetry-push.js'
10
+ import { installAIInterceptor } from './interceptors/ai-interceptor.js'
11
+ import { interceptFetch } from './interceptors/http.js'
12
+ import { installDBAutoInterceptor } from './interceptors/db-auto.js'
13
+ import { executeTrigger } from './trigger-executor.js'
14
+ import { scanTools, scanWorkflows } from './execution/tool-runner.js'
15
+ import { debugLog } from './utils/debug.js'
16
+
17
+ export interface ObservabilityOptions {
18
+ serverUrl?: string
19
+ apiKey?: string
20
+ serviceId?: string
21
+ sessionId?: string
22
+ batchIntervalMs?: number
23
+ maxBatchSize?: number
24
+ heartbeatIntervalMs?: number
25
+ sampleRate?: number
26
+ redactKeys?: string[]
27
+ }
28
+
29
+ export interface ObservabilityHandle {
30
+ sessionId: string
31
+ shutdown: () => Promise<void>
32
+ }
33
+
34
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null
35
+ let shutdownRegistered = false
36
+
37
+ /**
38
+ * Initialise active observability mode. All `wrapTool` / `wrapAI` calls will
39
+ * automatically record and stream trace events to the configured backend.
40
+ *
41
+ * Reads from environment variables as fallback:
42
+ * - `ELASTICDASH_API_URL` — backend API URL (required)
43
+ * - `ELASTICDASH_API_KEY` — project auth token
44
+ * - `ELASTICDASH_SERVICE_ID` — service/app identifier
45
+ * - `ELASTICDASH_SESSION_ID` — session identifier (auto-generated if omitted)
46
+ */
47
+ export function initObservability(options?: ObservabilityOptions): ObservabilityHandle {
48
+ const serverUrl = options?.serverUrl ?? process.env.ELASTICDASH_API_URL ?? ''
49
+ if (!serverUrl) {
50
+ throw new Error('[elasticdash] initObservability: serverUrl is required (set ELASTICDASH_API_URL or pass serverUrl option)')
51
+ }
52
+
53
+ const apiKey = options?.apiKey ?? process.env.ELASTICDASH_API_KEY
54
+ const serviceId = options?.serviceId ?? process.env.ELASTICDASH_SERVICE_ID ?? 'unknown-service'
55
+ const sessionId = options?.sessionId ?? process.env.ELASTICDASH_SESSION_ID ?? randomUUID()
56
+ const sampleRate = options?.sampleRate ?? 1.0
57
+ const redactKeys = options?.redactKeys ?? []
58
+ const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 30_000
59
+
60
+ const batcher = new TelemetryBatcher({
61
+ serverUrl,
62
+ apiKey,
63
+ sessionId,
64
+ serviceId,
65
+ batchIntervalMs: options?.batchIntervalMs,
66
+ maxBatchSize: options?.maxBatchSize,
67
+ redactKeys,
68
+ onTrigger: async (trigger) => {
69
+ await executeTrigger(serverUrl, apiKey, trigger)
70
+ },
71
+ })
72
+
73
+ let counter = 0
74
+ const traceId = `${serviceId}::${Date.now()}::${randomUUID().slice(0, 8)}`
75
+ const ctx: ObservabilityContext = {
76
+ sessionId,
77
+ serviceId,
78
+ serverUrl,
79
+ apiKey,
80
+ batcher,
81
+ nextId: () => ++counter,
82
+ sampleRate,
83
+ redactKeys,
84
+ traceId,
85
+ }
86
+
87
+ setObservabilityContext(ctx)
88
+ installAIInterceptor()
89
+ interceptFetch()
90
+ installDBAutoInterceptor().catch(() => {})
91
+
92
+ // Heartbeat
93
+ heartbeatTimer = setInterval(() => {
94
+ batcher.enqueue({
95
+ id: ctx.nextId(),
96
+ type: 'side_effect',
97
+ name: '__heartbeat__',
98
+ input: { sessionId, serviceId },
99
+ output: { uptime: process.uptime() },
100
+ timestamp: Date.now(),
101
+ durationMs: 0,
102
+ schemaVersion: 1,
103
+ })
104
+ }, heartbeatIntervalMs)
105
+ if (heartbeatTimer && typeof heartbeatTimer === 'object' && 'unref' in heartbeatTimer) {
106
+ heartbeatTimer.unref()
107
+ }
108
+
109
+ // Register process exit hooks (once)
110
+ if (!shutdownRegistered) {
111
+ shutdownRegistered = true
112
+ const onExit = () => { shutdownObservability().catch(() => {}) }
113
+ process.once('beforeExit', onExit)
114
+ process.once('SIGTERM', onExit)
115
+ process.once('SIGINT', onExit)
116
+ }
117
+
118
+ debugLog(`[elasticdash] Observability initialised — sessionId=${sessionId} serviceId=${serviceId} server=${serverUrl}`)
119
+
120
+ // Push workflow/tool catalog to backend (fire-and-forget)
121
+ pushCatalog(serverUrl, apiKey).catch(() => {})
122
+
123
+ return {
124
+ sessionId,
125
+ shutdown: () => shutdownObservability(),
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Scan ed_workflows.ts and ed_tools.ts, then POST the catalog to the backend
131
+ * so it registers the real function names instead of falling back to serviceId.
132
+ */
133
+ async function pushCatalog(serverUrl: string, apiKey?: string): Promise<void> {
134
+ const cwd = process.cwd()
135
+ const workflows = scanWorkflows(cwd)
136
+ const tools = scanTools(cwd)
137
+
138
+ if (workflows.length === 0 && tools.length === 0) return
139
+
140
+ const body: Record<string, unknown> = {}
141
+ if (workflows.length > 0) body.workflows = workflows.map(w => ({ name: w.name }))
142
+ if (tools.length > 0) body.tools = tools.map(t => ({ name: t.name }))
143
+
144
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
145
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
146
+
147
+ try {
148
+ const res = await fetch(`${serverUrl.replace(/\/$/, '')}/api/observability/catalog`, {
149
+ method: 'POST',
150
+ headers,
151
+ body: JSON.stringify(body),
152
+ })
153
+ debugLog(`[elasticdash] Catalog pushed: ${workflows.length} workflows, ${tools.length} tools (status ${res.status})`)
154
+ } catch (err) {
155
+ debugLog(`[elasticdash] Catalog push failed: ${err instanceof Error ? err.message : String(err)}`)
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Gracefully shut down observability: flush remaining events, send session_end,
161
+ * stop heartbeat, and clear context.
162
+ */
163
+ export async function shutdownObservability(): Promise<void> {
164
+ const ctx = getObservabilityContext()
165
+ if (!ctx) return
166
+
167
+ // Stop heartbeat
168
+ if (heartbeatTimer) {
169
+ clearInterval(heartbeatTimer)
170
+ heartbeatTimer = null
171
+ }
172
+
173
+ // Send session_end event
174
+ ctx.batcher.enqueue({
175
+ id: ctx.nextId(),
176
+ type: 'side_effect',
177
+ name: '__session_end__',
178
+ input: { sessionId: ctx.sessionId, serviceId: ctx.serviceId },
179
+ output: { uptime: process.uptime() },
180
+ timestamp: Date.now(),
181
+ durationMs: 0,
182
+ schemaVersion: 1,
183
+ })
184
+
185
+ // Flush and shut down the batcher
186
+ await ctx.batcher.shutdown()
187
+
188
+ clearObservabilityContext()
189
+ debugLog(`[elasticdash] Observability shut down — sessionId=${ctx.sessionId}`)
190
+ }
191
+
192
+ /**
193
+ * Start a new trace for the current request/operation.
194
+ *
195
+ * Call this at the start of each request handler to group all tool/AI events
196
+ * under one trace, identified by the workflow name.
197
+ *
198
+ * The generated traceId encodes the workflow name and timestamp for easy
199
+ * parsing by the backend: `{workflowName}::{timestamp}::{shortId}`
200
+ *
201
+ * @param workflowName — the workflow/function being executed (e.g. 'chatStreamHandler')
202
+ * @returns The new traceId
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * // In a route handler:
207
+ * startTrace('chatStreamHandler')
208
+ * // All subsequent wrapTool/wrapAI calls get traceId = "chatStreamHandler::1712851200000::a1b2c3d4"
209
+ * ```
210
+ */
211
+ export function startTrace(workflowName?: string): string {
212
+ const ctx = getObservabilityContext()
213
+ if (!ctx) throw new Error('[elasticdash] startTrace: observability not initialised')
214
+ const timestamp = Date.now()
215
+ const shortId = randomUUID().slice(0, 8)
216
+ if (workflowName) {
217
+ ctx.traceId = `${workflowName}::${timestamp}::${shortId}`
218
+ } else {
219
+ ctx.traceId = `${ctx.serviceId}::${timestamp}::${shortId}`
220
+ }
221
+ return ctx.traceId
222
+ }
223
+
224
+ /**
225
+ * Wrap a workflow function so that every invocation automatically starts a new
226
+ * trace with the workflow name. All tool/AI calls within the function execution
227
+ * are grouped under that trace.
228
+ *
229
+ * Use this in `ed_workflows.ts` to automatically tag each workflow execution:
230
+ *
231
+ * @example
232
+ * ```ts
233
+ * export const chatStreamHandler = wrapWorkflow('chatStreamHandler', async (input) => {
234
+ * const result = await fetchUser(input.userId)
235
+ * const reply = await generateReply(result)
236
+ * return reply
237
+ * })
238
+ * ```
239
+ */
240
+ export function wrapWorkflow<Args extends unknown[], R>(
241
+ name: string,
242
+ fn: (...args: Args) => Promise<R>,
243
+ ): (...args: Args) => Promise<R> {
244
+ return async (...args: Args): Promise<R> => {
245
+ const ctx = getObservabilityContext()
246
+ if (ctx) {
247
+ startTrace(name)
248
+ const start = Date.now()
249
+ let result: R
250
+ try {
251
+ result = await fn(...args)
252
+ } catch (err) {
253
+ // Emit workflow event with error output
254
+ pushTelemetryEvent({
255
+ id: ctx.nextId(),
256
+ type: 'workflow',
257
+ name,
258
+ input: args.length === 1 ? args[0] : args,
259
+ output: { error: err instanceof Error ? err.message : String(err) },
260
+ timestamp: start,
261
+ durationMs: Date.now() - start,
262
+ schemaVersion: 1,
263
+ })
264
+ throw err
265
+ }
266
+ // Emit workflow event with successful output
267
+ pushTelemetryEvent({
268
+ id: ctx.nextId(),
269
+ type: 'workflow',
270
+ name,
271
+ input: args.length === 1 ? args[0] : args,
272
+ output: result,
273
+ timestamp: start,
274
+ durationMs: Date.now() - start,
275
+ schemaVersion: 1,
276
+ })
277
+ return result
278
+ }
279
+ return fn(...args)
280
+ }
281
+ }
@@ -0,0 +1,335 @@
1
+ import { callProviderLLM } from './matchers/index.js'
2
+ import {
3
+ scanTools,
4
+ resolveRuntimeModule,
5
+ buildToolArgs,
6
+ runToolInSubprocess,
7
+ } from './execution/tool-runner.js'
8
+ import type { ToolInfo } from './execution/tool-runner.js'
9
+ import type { PortalTask, PortalTaskResult } from './types/portal.js'
10
+ import { debugLog } from './utils/debug.js'
11
+
12
+ type SupportedProvider = 'openai' | 'claude' | 'gemini' | 'grok' | 'kimi'
13
+
14
+ /** Provider → required env var name */
15
+ const PROVIDER_API_KEY_ENV: Record<SupportedProvider, string> = {
16
+ openai: 'OPENAI_API_KEY',
17
+ claude: 'ANTHROPIC_API_KEY',
18
+ gemini: 'GEMINI_API_KEY',
19
+ grok: 'GROK_API_KEY',
20
+ kimi: 'KIMI_API_KEY',
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Prompt extraction (mirrors dashboard-server.ts logic)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function normalizeMessageContent(content: unknown): string {
28
+ if (typeof content === 'string') return content
29
+ if (Array.isArray(content)) {
30
+ return content
31
+ .map((part) => {
32
+ if (typeof part === 'string') return part
33
+ if (part && typeof part === 'object' && typeof (part as any).text === 'string') return (part as any).text
34
+ try { return JSON.stringify(part) } catch { return String(part) }
35
+ })
36
+ .join('\n')
37
+ }
38
+ if (content && typeof content === 'object') {
39
+ if (typeof (content as any).text === 'string') return (content as any).text
40
+ try { return JSON.stringify(content) } catch { return String(content) }
41
+ }
42
+ return content == null ? '' : String(content)
43
+ }
44
+
45
+ function extractPromptFromInput(input: unknown): { prompt: string; systemPrompt?: string } {
46
+ if (typeof input === 'string') return { prompt: input }
47
+
48
+ const messages = Array.isArray(input)
49
+ ? input
50
+ : input && typeof input === 'object' && Array.isArray((input as any).messages)
51
+ ? (input as any).messages
52
+ : null
53
+
54
+ if (messages && messages.length > 0) {
55
+ const systemParts: string[] = []
56
+ const promptParts: string[] = []
57
+ for (const message of messages as Array<any>) {
58
+ const role = typeof message?.role === 'string' ? message.role : 'user'
59
+ const content = normalizeMessageContent(message?.content).trim()
60
+ if (!content) continue
61
+ if (role === 'system') systemParts.push(content)
62
+ else promptParts.push(`${role}: ${content}`)
63
+ }
64
+ return {
65
+ prompt: promptParts.join('\n\n') || systemParts.join('\n\n') || JSON.stringify(input),
66
+ systemPrompt: systemParts.length > 0 ? systemParts.join('\n\n') : undefined,
67
+ }
68
+ }
69
+
70
+ if (input && typeof input === 'object' && typeof (input as any).prompt === 'string') {
71
+ return {
72
+ prompt: (input as any).prompt,
73
+ systemPrompt: typeof (input as any).systemPrompt === 'string' ? (input as any).systemPrompt : undefined,
74
+ }
75
+ }
76
+
77
+ try { return { prompt: JSON.stringify(input) } }
78
+ catch { return { prompt: String(input ?? '') } }
79
+ }
80
+
81
+ function inferProvider(task: PortalTask): SupportedProvider {
82
+ const provider = task.provider?.toLowerCase()
83
+ if (provider === 'openai' || provider === 'claude' || provider === 'gemini' || provider === 'grok' || provider === 'kimi') {
84
+ return provider
85
+ }
86
+ const model = (task.model ?? task.name)?.toLowerCase() ?? ''
87
+ if (model.includes('claude')) return 'claude'
88
+ if (model.includes('gemini')) return 'gemini'
89
+ if (model.includes('grok')) return 'grok'
90
+ if (model.includes('kimi')) return 'kimi'
91
+ return 'openai'
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Availability checks (used by trigger-executor for pre-validation)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ export interface AvailabilityResult {
99
+ available: boolean
100
+ reason?: string
101
+ }
102
+
103
+ /**
104
+ * Check if a tool is available for execution.
105
+ * Returns { available: true } or { available: false, reason: "..." }.
106
+ */
107
+ export function checkToolAvailability(
108
+ name: string,
109
+ cwd: string,
110
+ tools: ToolInfo[],
111
+ ): AvailabilityResult {
112
+ if (!name) {
113
+ return { available: false, reason: 'Missing tool name.' }
114
+ }
115
+ const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools')
116
+ if (!toolsModulePath) {
117
+ return { available: false, reason: 'Cannot find ed_tools.ts/js in workspace root.' }
118
+ }
119
+ const toolInfo = tools.find(t => t.name === name)
120
+ if (!toolInfo) {
121
+ const available = tools.map(t => t.name).join(', ') || '(none found)'
122
+ return { available: false, reason: `Tool not found: "${name}". Available tools: ${available}` }
123
+ }
124
+ return { available: true }
125
+ }
126
+
127
+ /**
128
+ * Check if an AI provider is available for execution (API key present).
129
+ * Returns { available: true } or { available: false, reason: "..." }.
130
+ */
131
+ export function checkAIAvailability(
132
+ provider?: string,
133
+ model?: string,
134
+ ): AvailabilityResult {
135
+ const resolved = inferProviderFromArgs(provider, model)
136
+ const supportedProviders: SupportedProvider[] = ['openai', 'claude', 'gemini', 'grok', 'kimi']
137
+ if (!supportedProviders.includes(resolved)) {
138
+ return { available: false, reason: `Unsupported AI provider: "${provider ?? model}". Supported: ${supportedProviders.join(', ')}` }
139
+ }
140
+ const envVar = PROVIDER_API_KEY_ENV[resolved]
141
+ if (!process.env[envVar]) {
142
+ if (resolved === 'gemini' && process.env.GOOGLE_API_KEY) {
143
+ return { available: true }
144
+ }
145
+ return { available: false, reason: `Missing API key for provider "${resolved}". Expected environment variable: ${envVar}` }
146
+ }
147
+ return { available: true }
148
+ }
149
+
150
+ function inferProviderFromArgs(provider?: string, model?: string): SupportedProvider {
151
+ const p = provider?.toLowerCase()
152
+ if (p === 'openai' || p === 'claude' || p === 'gemini' || p === 'grok' || p === 'kimi') return p
153
+ const m = (model ?? '')?.toLowerCase()
154
+ if (m.includes('claude')) return 'claude'
155
+ if (m.includes('gemini')) return 'gemini'
156
+ if (m.includes('grok')) return 'grok'
157
+ if (m.includes('kimi')) return 'kimi'
158
+ return 'openai'
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Public API
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Execute a portal task (tool or AI rerun). Returns a structured result with
167
+ * output, duration, usage, and error information.
168
+ *
169
+ * Error handling:
170
+ * - Tool not found in ed_tools → ok:false with descriptive error
171
+ * - ed_tools.ts/js missing → ok:false with descriptive error
172
+ * - AI provider unsupported → ok:false with error
173
+ * - AI provider API key missing → ok:false with error naming the expected env var
174
+ * - Subprocess crash → ok:false with stderr excerpt
175
+ * - Any other exception → ok:false with error message
176
+ */
177
+ export async function executePortalTask(
178
+ task: PortalTask,
179
+ cwd: string,
180
+ tools: ToolInfo[],
181
+ ): Promise<PortalTaskResult> {
182
+ const start = Date.now()
183
+ try {
184
+ if (task.type === 'tool') {
185
+ return await executeToolTask(task, cwd, tools)
186
+ }
187
+ if (task.type === 'ai') {
188
+ return await executeAITask(task)
189
+ }
190
+ return {
191
+ taskId: task.taskId,
192
+ ok: false,
193
+ output: null,
194
+ error: `Unknown task type: ${task.type}`,
195
+ durationMs: Date.now() - start,
196
+ metadata: task.metadata,
197
+ }
198
+ } catch (e) {
199
+ return {
200
+ taskId: task.taskId,
201
+ ok: false,
202
+ output: null,
203
+ error: e instanceof Error ? e.message : String(e),
204
+ durationMs: Date.now() - start,
205
+ metadata: task.metadata,
206
+ }
207
+ }
208
+ }
209
+
210
+ async function executeToolTask(
211
+ task: PortalTask,
212
+ cwd: string,
213
+ tools: ToolInfo[],
214
+ ): Promise<PortalTaskResult> {
215
+ const start = Date.now()
216
+
217
+ if (!task.name) {
218
+ return { taskId: task.taskId, ok: false, output: null, error: 'Missing tool name on task.', durationMs: 0, metadata: task.metadata }
219
+ }
220
+
221
+ // Check tool exists in the scanned tool list
222
+ const toolInfo = tools.find(t => t.name === task.name)
223
+ if (!toolInfo) {
224
+ const available = tools.map(t => t.name).join(', ') || '(none found)'
225
+ return {
226
+ taskId: task.taskId, ok: false, output: null,
227
+ error: `Tool not found: "${task.name}". Available tools: ${available}`,
228
+ durationMs: 0, metadata: task.metadata,
229
+ }
230
+ }
231
+
232
+ // Resolve ed_tools module
233
+ const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools')
234
+ if (!toolsModulePath) {
235
+ return {
236
+ taskId: task.taskId, ok: false, output: null,
237
+ error: 'Cannot find ed_tools.ts/js in workspace root.',
238
+ durationMs: 0, metadata: task.metadata,
239
+ }
240
+ }
241
+
242
+ // Parse input
243
+ let parsedInput = task.input
244
+ if (typeof parsedInput === 'string') {
245
+ try { parsedInput = JSON.parse(parsedInput) } catch { /* use as-is */ }
246
+ }
247
+
248
+ const args = buildToolArgs(parsedInput, toolInfo)
249
+ debugLog(`[elasticdash portal] Executing tool: ${task.name}`, { args })
250
+
251
+ const result = await runToolInSubprocess(toolsModulePath, task.name, args)
252
+ const durationMs = result.currentDurationMs ?? (Date.now() - start)
253
+
254
+ return {
255
+ taskId: task.taskId,
256
+ ok: result.ok,
257
+ output: result.currentOutput ?? null,
258
+ error: result.error,
259
+ durationMs,
260
+ usage: result.currentUsage,
261
+ metadata: task.metadata,
262
+ }
263
+ }
264
+
265
+ async function executeAITask(task: PortalTask): Promise<PortalTaskResult> {
266
+ const start = Date.now()
267
+
268
+ // Infer provider
269
+ const provider = inferProvider(task)
270
+ const supportedProviders: SupportedProvider[] = ['openai', 'claude', 'gemini', 'grok', 'kimi']
271
+ if (!supportedProviders.includes(provider)) {
272
+ return {
273
+ taskId: task.taskId, ok: false, output: null,
274
+ error: `Unsupported AI provider: "${task.provider}". Supported: ${supportedProviders.join(', ')}`,
275
+ durationMs: 0, metadata: task.metadata,
276
+ }
277
+ }
278
+
279
+ // Check API key is available
280
+ const envVar = PROVIDER_API_KEY_ENV[provider]
281
+ if (!process.env[envVar]) {
282
+ // Gemini also accepts GOOGLE_API_KEY
283
+ if (provider === 'gemini' && process.env.GOOGLE_API_KEY) {
284
+ // ok, fallback key available
285
+ } else {
286
+ return {
287
+ taskId: task.taskId, ok: false, output: null,
288
+ error: `Missing API key for provider "${provider}". Expected environment variable: ${envVar}`,
289
+ durationMs: 0, metadata: task.metadata,
290
+ }
291
+ }
292
+ }
293
+
294
+ // Extract prompt from input
295
+ const { prompt, systemPrompt } = extractPromptFromInput(task.input)
296
+ if (!prompt.trim()) {
297
+ return {
298
+ taskId: task.taskId, ok: false, output: null,
299
+ error: 'AI task input is empty; cannot execute.',
300
+ durationMs: 0, metadata: task.metadata,
301
+ }
302
+ }
303
+
304
+ const model = task.model ?? task.name
305
+ const temperature = typeof task.modelParameters?.temperature === 'number' ? task.modelParameters.temperature : 0
306
+ const maxTokens = typeof task.modelParameters?.max_tokens === 'number' ? task.modelParameters.max_tokens : 512
307
+
308
+ debugLog(`[elasticdash portal] Executing AI: provider=${provider} model=${model}`)
309
+
310
+ try {
311
+ const result = await callProviderLLM(
312
+ prompt,
313
+ { provider, model },
314
+ systemPrompt ?? 'You are a helpful assistant.',
315
+ maxTokens,
316
+ temperature,
317
+ )
318
+
319
+ return {
320
+ taskId: task.taskId,
321
+ ok: true,
322
+ output: result.content,
323
+ durationMs: result.durationMs ?? (Date.now() - start),
324
+ usage: result.usage,
325
+ metadata: task.metadata,
326
+ }
327
+ } catch (e) {
328
+ return {
329
+ taskId: task.taskId, ok: false, output: null,
330
+ error: `AI execution failed: ${e instanceof Error ? e.message : String(e)}`,
331
+ durationMs: Date.now() - start,
332
+ metadata: task.metadata,
333
+ }
334
+ }
335
+ }