neuralos 2.9.8 → 2.9.9

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/bin/gybackend.cjs CHANGED
@@ -368394,6 +368394,10 @@ var DEFAULT_BACKEND_SETTINGS = {
368394
368394
  cloud: {
368395
368395
  accounts: []
368396
368396
  },
368397
+ agentspan: {
368398
+ serverUrl: "",
368399
+ enabled: true
368400
+ },
368397
368401
  gateway: {
368398
368402
  ws: {
368399
368403
  access: "localhost",
@@ -368449,6 +368453,7 @@ function pickBackendSnapshot(raw) {
368449
368453
  alerts: raw.alerts,
368450
368454
  oncall: raw.oncall,
368451
368455
  cloud: raw.cloud,
368456
+ agentspan: raw.agentspan,
368452
368457
  gateway: raw.gateway,
368453
368458
  layout: raw.layout,
368454
368459
  recursionLimit: raw.recursionLimit,
@@ -368527,6 +368532,7 @@ function normalizeBackendSettings(settings) {
368527
368532
  next.alerts = normalizeAlertsSettings(next.alerts);
368528
368533
  next.oncall = normalizeOncallSettings(next.oncall);
368529
368534
  next.cloud = normalizeCloudSettings(next.cloud);
368535
+ next.agentspan = normalizeAgentspanSettings(next.agentspan);
368530
368536
  next.schemaVersion = BACKEND_SETTINGS_SCHEMA_VERSION;
368531
368537
  return next;
368532
368538
  }
@@ -368689,6 +368695,16 @@ function normalizeCloudSettings(raw) {
368689
368695
  }
368690
368696
  return { accounts };
368691
368697
  }
368698
+ function normalizeAgentspanSettings(raw) {
368699
+ const src = isObject5(raw) ? raw : {};
368700
+ const serverUrl = typeof src.serverUrl === "string" ? src.serverUrl.trim() : "";
368701
+ const authSecretRef = typeof src.authSecretRef === "string" && src.authSecretRef.trim() ? src.authSecretRef.trim() : void 0;
368702
+ return {
368703
+ ...serverUrl ? { serverUrl } : {},
368704
+ ...authSecretRef ? { authSecretRef } : {},
368705
+ enabled: src.enabled !== false
368706
+ };
368707
+ }
368692
368708
  function migrateBackendToV3(settings) {
368693
368709
  const next = { ...settings };
368694
368710
  delete next.language;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "neuralos",
3
- "version": "2.9.8",
4
- "description": "neuralOS — the headless AI-native backend for RTerm. run RTerm-as-a-service (AI agent, SSH/WinRM/Serial/local terminals, fleet orchestration, advanced automation, SRE observability, Netdata, AWS APerf, plugin system, governance/audit, Prometheus/OTel metrics export, secrets vault, on-call paging, AI cost budgets, GitOps, cloud inventory, APM/DEM/Infra/ETW ingestion). The RTerm desktop app stays RTerm; neuralOS is the standalone backend daemon. Dual-published as rterm-backend. v2.9.8: backend typecheck fully green (ESM-safe native loaders for node-pty/ssh2) + release notes generated from CHANGELOG.",
3
+ "version": "2.9.9",
4
+ "description": "neuralOS — the headless AI-native backend for RTerm. run RTerm-as-a-service (AI agent, SSH/WinRM/Serial/local terminals, fleet orchestration, advanced automation, SRE observability, Netdata, AWS APerf, plugin system, governance/audit, Prometheus/OTel metrics export, secrets vault, on-call paging, AI cost budgets, GitOps, cloud inventory, APM/DEM/Infra/ETW ingestion, AgentSpan/Conductor durable-agent bridge). The RTerm desktop app stays RTerm; neuralOS is the standalone backend daemon. Dual-published as rterm-backend. v2.9.9: AgentSpan/Conductor integration durable, crash-resilient agent execution (agentspan-bridge plugin, 7 plugins total).",
5
5
  "main": "bin/gybackend.cjs",
6
6
  "bin": {
7
7
  "neuralos": "bin/gybackend.cjs",
@@ -63,6 +63,9 @@
63
63
  "apm",
64
64
  "dem",
65
65
  "etw",
66
+ "agentspan",
67
+ "conductor",
68
+ "durable-agents",
66
69
  "monitoring"
67
70
  ]
68
71
  }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * agentspan-bridge.extreme.spec.ts — exhaustive offline tests for the
3
+ * AgentSpan/Conductor bridge: the dependency-free HTTP client (URL building,
4
+ * auth headers, error mapping, every endpoint) and the plugin glue (config
5
+ * resolution, auth blob parsing, status/row normalization, tool wiring,
6
+ * unreachable-server resilience, trigger match). No network — fetch is mocked.
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { ConductorClient, ConductorApiError, authHeaders, joinUrl, DEFAULT_BASE_URL } from './conductorClient.mjs'
11
+ import {
12
+ register,
13
+ resolveConfig,
14
+ parseAuthBlob,
15
+ buildClient,
16
+ summarizeStatus,
17
+ toExecutionRows,
18
+ isFailedExecution,
19
+ } from './index.mjs'
20
+
21
+ // ─── mock fetch ─────────────────────────────────────────────────────────────
22
+ /** A scriptable fetch mock: records calls, returns queued/mapped responses. */
23
+ function mockFetch(respond) {
24
+ const calls = []
25
+ const fn = async (url, init) => {
26
+ calls.push({ url, init })
27
+ const r = typeof respond === 'function' ? respond(url, init, calls.length) : respond
28
+ return { ok: r.ok !== false && (r.status ?? 200) < 400, status: r.status ?? 200, text: async () => r.text ?? (r.json !== undefined ? JSON.stringify(r.json) : '') }
29
+ }
30
+ fn.calls = calls
31
+ return fn
32
+ }
33
+
34
+ // ─── conductorClient: URL + auth header building ───────────────────────────
35
+ test('joinUrl joins base+path with a single slash', () => {
36
+ assert.equal(joinUrl('http://h:6767/', '/api/agent/start'), 'http://h:6767/api/agent/start')
37
+ assert.equal(joinUrl('http://h:6767', 'api/agent/start'), 'http://h:6767/api/agent/start')
38
+ assert.equal(joinUrl(undefined, '/x'), `${DEFAULT_BASE_URL}/x`)
39
+ })
40
+
41
+ test('authHeaders only sends X-Auth-* when both key+secret present', () => {
42
+ assert.deepEqual(authHeaders(undefined), { 'content-type': 'application/json', accept: 'application/json' })
43
+ assert.deepEqual(authHeaders({ key: 'k' }), { 'content-type': 'application/json', accept: 'application/json' })
44
+ const h = authHeaders({ key: 'k', secret: 's' })
45
+ assert.equal(h['X-Auth-Key'], 'k')
46
+ assert.equal(h['X-Auth-Secret'], 's')
47
+ })
48
+
49
+ test('ConductorClient requires a fetchImpl', () => {
50
+ assert.throws(() => new ConductorClient({}), /fetchImpl/)
51
+ })
52
+
53
+ // ─── conductorClient: endpoints ────────────────────────────────────────────
54
+ test('health() maps actuator health to {ok,status} and never throws', async () => {
55
+ const up = new ConductorClient({ fetchImpl: mockFetch({ json: { status: 'UP' } }) })
56
+ assert.deepEqual(await up.health(), { ok: true, status: 'UP', raw: { status: 'UP' } })
57
+ const down = new ConductorClient({ fetchImpl: mockFetch(() => { throw new Error('ECONNREFUSED') }) })
58
+ const h = await down.health()
59
+ assert.equal(h.ok, false)
60
+ assert.equal(h.status, 'DOWN')
61
+ assert.match(h.error, /ECONNREFUSED/)
62
+ })
63
+
64
+ test('runAgent posts to /api/agent/start and extracts executionId', async () => {
65
+ const f = mockFetch({ json: { executionId: 'exec-123' } })
66
+ const c = new ConductorClient({ fetchImpl: f })
67
+ const r = await c.runAgent({ name: 'a' }, 'hello')
68
+ assert.equal(r.executionId, 'exec-123')
69
+ const call = f.calls[0]
70
+ assert.equal(call.url, `${DEFAULT_BASE_URL}/api/agent/start`)
71
+ assert.equal(call.init.method, 'POST')
72
+ const body = JSON.parse(call.init.body)
73
+ assert.deepEqual(body.agent, { name: 'a' })
74
+ assert.equal(body.input, 'hello')
75
+ })
76
+
77
+ test('runAgent falls back to workflowId/id when executionId absent', async () => {
78
+ const c = new ConductorClient({ fetchImpl: mockFetch({ json: { workflowId: 'wf-9' } }) })
79
+ assert.equal((await c.runAgent({ name: 'a' })).executionId, 'wf-9')
80
+ })
81
+
82
+ test('agentStatus/Respond/Stop hit the lifecycle endpoints + require id', async () => {
83
+ const f = mockFetch({ json: { status: 'RUNNING' } })
84
+ const c = new ConductorClient({ fetchImpl: f })
85
+ await assert.rejects(() => c.agentStatus(), /executionId/)
86
+ await c.agentStatus('e1')
87
+ await c.agentRespond('e1', { approved: true })
88
+ await c.agentStop('e1')
89
+ const urls = f.calls.map((x) => `${x.init.method} ${x.url}`)
90
+ assert.ok(urls.includes(`GET ${DEFAULT_BASE_URL}/api/agent/e1`))
91
+ assert.ok(urls.includes(`POST ${DEFAULT_BASE_URL}/api/agent/e1/respond`))
92
+ assert.ok(urls.includes(`POST ${DEFAULT_BASE_URL}/api/agent/e1/stop`))
93
+ })
94
+
95
+ test('startWorkflow builds the right path + returns the id string', async () => {
96
+ const f = mockFetch({ text: 'wf-abc' })
97
+ const c = new ConductorClient({ fetchImpl: f })
98
+ const id = await c.startWorkflow('cleanup', { host: 'web-1' }, { version: 3 })
99
+ assert.equal(id, 'wf-abc')
100
+ assert.match(f.calls[0].url, /\/api\/workflow\/cleanup\?version=3$/)
101
+ })
102
+
103
+ test('getWorkflow/terminate/retry/search hit the engine surface', async () => {
104
+ const f = mockFetch({ json: { results: [] } })
105
+ const c = new ConductorClient({ fetchImpl: f })
106
+ await c.getWorkflow('w1')
107
+ await c.terminateWorkflow('w1', 'done')
108
+ await c.retryWorkflow('w1')
109
+ await c.searchWorkflows('status:FAILED', 5)
110
+ const urls = f.calls.map((x) => `${x.init.method} ${x.url}`)
111
+ assert.ok(urls.some((u) => u.startsWith(`GET ${DEFAULT_BASE_URL}/api/workflow/w1?includeTasks=`)))
112
+ assert.ok(urls.some((u) => u.startsWith(`DELETE ${DEFAULT_BASE_URL}/api/workflow/w1?reason=done`)))
113
+ assert.ok(urls.includes(`POST ${DEFAULT_BASE_URL}/api/workflow/w1/retry`))
114
+ assert.ok(urls.some((u) => u.includes('/api/workflow/search?') && u.includes('status%3AFAILED')))
115
+ })
116
+
117
+ test('non-2xx responses raise ConductorApiError with status + body', async () => {
118
+ // health() swallows errors into {ok:false}; other methods raise ConductorApiError.
119
+ const up = new ConductorClient({ fetchImpl: mockFetch({ ok: false, status: 500, text: 'boom' }) })
120
+ const h = await up.health()
121
+ assert.equal(h.ok, false)
122
+ const c2 = new ConductorClient({ fetchImpl: mockFetch({ ok: false, status: 500, text: 'boom' }) })
123
+ await assert.rejects(() => c2.getWorkflow('w1'), ConductorApiError)
124
+ const c3 = new ConductorClient({ fetchImpl: mockFetch({ ok: false, status: 404, text: 'nope' }) })
125
+ await assert.rejects(() => c3.agentStatus('x'), ConductorApiError)
126
+ })
127
+
128
+ // ─── plugin glue: config + auth ────────────────────────────────────────────
129
+ test('resolveConfig prefers settings, falls back to env, strips trailing slash', () => {
130
+ const c = resolveConfig({ settings: { agentspan: { serverUrl: 'http://srv:6767/', authSecretRef: 'as-auth' } } }, {})
131
+ assert.equal(c.serverUrl, 'http://srv:6767')
132
+ assert.equal(c.authSecretRef, 'as-auth')
133
+ const env = resolveConfig({}, { AGENTSPAN_SERVER_URL: 'http://env:6767/' })
134
+ assert.equal(env.serverUrl, 'http://env:6767')
135
+ assert.equal(resolveConfig({}, {}).serverUrl, DEFAULT_BASE_URL)
136
+ })
137
+
138
+ test('parseAuthBlob parses KEY=VAL lines into {key,secret}', () => {
139
+ assert.deepEqual(parseAuthBlob('AGENTSPAN_AUTH_KEY=k\nAGENTSPAN_AUTH_SECRET=s'), { key: 'k', secret: 's' })
140
+ assert.deepEqual(parseAuthBlob('AUTH_KEY=a\nAUTH_SECRET=b'), { key: 'a', secret: 'b' })
141
+ assert.equal(parseAuthBlob('AGENTSPAN_AUTH_KEY=only'), undefined)
142
+ assert.equal(parseAuthBlob(''), undefined)
143
+ assert.equal(parseAuthBlob(undefined), undefined)
144
+ })
145
+
146
+ test('buildClient wires auth from ctx.getSecret + configures baseUrl', () => {
147
+ const ctx = {
148
+ settings: { agentspan: { serverUrl: 'http://srv:6767', authSecretRef: 'as-auth' } },
149
+ getSecret: (k) => (k === 'as-auth' ? 'AGENTSPAN_AUTH_KEY=k\nAGENTSPAN_AUTH_SECRET=s' : undefined),
150
+ }
151
+ const { client, config } = buildClient(ctx, mockFetch({ json: {} }))
152
+ assert.equal(config.serverUrl, 'http://srv:6767')
153
+ assert.equal(client.auth.key, 'k')
154
+ // missing secret → no auth, no crash
155
+ const noSecret = buildClient({ settings: { agentspan: { authSecretRef: 'nope' } }, getSecret: () => undefined }, mockFetch({ json: {} }))
156
+ assert.equal(noSecret.client.auth, undefined)
157
+ })
158
+
159
+ // ─── plugin glue: normalization helpers ────────────────────────────────────
160
+ test('summarizeStatus normalizes agent + workflow payloads', () => {
161
+ const a = summarizeStatus({ executionId: 'e1', agentName: 'bot', status: 'RUNNING', tasks: [{ status: 'COMPLETED' }, { status: 'FAILED' }] })
162
+ assert.equal(a.status, 'RUNNING')
163
+ assert.equal(a.taskCount, 2)
164
+ assert.equal(a.completedTasks, 1)
165
+ assert.equal(a.failedTasks, 1)
166
+ const w = summarizeStatus({ workflowId: 'w1', workflowName: 'cleanup', status: 'COMPLETED', reasonForIncompletion: undefined })
167
+ assert.equal(w.name, 'cleanup')
168
+ assert.equal(w.status, 'COMPLETED')
169
+ assert.deepEqual(summarizeStatus(null), { status: 'UNKNOWN' })
170
+ })
171
+
172
+ test('toExecutionRows handles results/workflows/array shapes', () => {
173
+ assert.equal(toExecutionRows({ results: [{ workflowId: 'a', workflowName: 'x', status: 'RUNNING' }] }).length, 1)
174
+ assert.equal(toExecutionRows({ workflows: [{ id: 'b', name: 'y', status: 'FAILED' }] })[0].status, 'FAILED')
175
+ assert.equal(toExecutionRows([{ workflowId: 'c' }]).length, 1)
176
+ assert.deepEqual(toExecutionRows({}), [])
177
+ })
178
+
179
+ test('isFailedExecution matches terminal-failure statuses only', () => {
180
+ assert.ok(isFailedExecution('FAILED'))
181
+ assert.ok(isFailedExecution('terminated'))
182
+ assert.ok(isFailedExecution('TIMED_OUT'))
183
+ assert.ok(!isFailedExecution('RUNNING'))
184
+ assert.ok(!isFailedExecution('COMPLETED'))
185
+ })
186
+
187
+ // ─── plugin registration + tool behavior (mocked server) ──────────────────
188
+ /** Build a ctx with register* capture + a mocked client injected. */
189
+ function makeCtx(fetchImpl, settings = {}) {
190
+ const tools = new Map()
191
+ const triggers = []
192
+ const panels = []
193
+ const logs = []
194
+ const ctx = {
195
+ settings: { agentspan: settings },
196
+ registerTool: (t) => tools.set(t.name, t),
197
+ registerTrigger: (t) => triggers.push(t),
198
+ registerPanel: (p) => panels.push(p),
199
+ log: (l) => logs.push(l),
200
+ }
201
+ // inject the mocked fetch by overriding buildClient's realFetch via a hack:
202
+ // we re-register with a patched client below.
203
+ return { tools, triggers, panels, logs, ctx }
204
+ }
205
+
206
+ test('register wires 6 tools, 1 trigger, 1 panel', () => {
207
+ const { tools, triggers, panels, ctx } = makeCtx(null, { serverUrl: 'http://x:6767' })
208
+ register(ctx)
209
+ assert.equal(tools.size, 6)
210
+ for (const n of ['agentspan_health', 'agentspan_run', 'agentspan_status', 'agentspan_approve', 'agentspan_list', 'agentspan_stop']) assert.ok(tools.has(n), `missing ${n}`)
211
+ assert.equal(triggers.length, 1)
212
+ assert.equal(panels.length, 1)
213
+ })
214
+
215
+ test('agentspan_health returns error+hint when server unreachable (no throw)', async () => {
216
+ const { tools, ctx } = makeCtx(null, { serverUrl: 'http://down:6767' })
217
+ // force a failing fetch by monkey-patching global fetch
218
+ const realFetch = globalThis.fetch
219
+ globalThis.fetch = async () => { throw new Error('ECONNREFUSED') }
220
+ register(ctx)
221
+ const r = await tools.get('agentspan_health').handler({})
222
+ assert.equal(r.error && true, true)
223
+ assert.match(r.hint, /AgentSpan server running/)
224
+ globalThis.fetch = realFetch
225
+ })
226
+
227
+ test('agentspan_run (agentConfig) returns executionId + uiUrl from a live mock server', async () => {
228
+ const { tools, ctx } = makeCtx(null, { serverUrl: DEFAULT_BASE_URL })
229
+ const realFetch = globalThis.fetch
230
+ globalThis.fetch = async (url, init) => ({
231
+ ok: true, status: 200,
232
+ text: async () => JSON.stringify({ executionId: 'exec-42' }),
233
+ })
234
+ register(ctx)
235
+ const r = await tools.get('agentspan_run').handler({ agentConfig: { name: 'a' }, prompt: 'hi' })
236
+ assert.equal(r.executionId, 'exec-42')
237
+ assert.match(r.uiUrl, /\/execution\/exec-42$/)
238
+ globalThis.fetch = realFetch
239
+ })
240
+
241
+ test('agentspan_run (workflow) returns workflowId; needs agentConfig-or-workflow', async () => {
242
+ const { tools, ctx } = makeCtx(null, { serverUrl: DEFAULT_BASE_URL })
243
+ const realFetch = globalThis.fetch
244
+ globalThis.fetch = async () => ({ ok: true, status: 200, text: async () => 'wf-7' })
245
+ register(ctx)
246
+ const r = await tools.get('agentspan_run').handler({ workflow: 'cleanup', input: { h: 1 } })
247
+ assert.equal(r.workflowId, 'wf-7')
248
+ const bad = await tools.get('agentspan_run').handler({})
249
+ assert.match(bad.error, /agentConfig or workflow/)
250
+ globalThis.fetch = realFetch
251
+ })
252
+
253
+ test('agentspan_status falls back to workflow engine when agent surface 404s', async () => {
254
+ const { tools, ctx } = makeCtx(null, { serverUrl: DEFAULT_BASE_URL })
255
+ const realFetch = globalThis.fetch
256
+ globalThis.fetch = async (url) => {
257
+ if (url.includes('/api/agent/')) return { ok: false, status: 404, text: async () => 'not an agent' }
258
+ return { ok: true, status: 200, text: async () => JSON.stringify({ workflowId: 'w1', workflowName: 'cleanup', status: 'COMPLETED', tasks: [] }) }
259
+ }
260
+ register(ctx)
261
+ const r = await tools.get('agentspan_status').handler({ executionId: 'w1' })
262
+ assert.equal(r.kind, 'workflow')
263
+ assert.equal(r.status, 'COMPLETED')
264
+ globalThis.fetch = realFetch
265
+ })
266
+
267
+ test('agentspan_approve responds + reports new status; requires id', async () => {
268
+ const { tools, ctx } = makeCtx(null, { serverUrl: DEFAULT_BASE_URL })
269
+ const realFetch = globalThis.fetch
270
+ const posted = []
271
+ globalThis.fetch = async (url, init) => {
272
+ if (init.method === 'POST' && url.includes('/respond')) { posted.push(url); return { ok: true, status: 200, text: async () => '' } }
273
+ return { ok: true, status: 200, text: async () => JSON.stringify({ executionId: 'e1', status: 'RUNNING' }) }
274
+ }
275
+ register(ctx)
276
+ const bad = await tools.get('agentspan_approve').handler({})
277
+ assert.match(bad.error, /executionId/)
278
+ const r = await tools.get('agentspan_approve').handler({ executionId: 'e1', output: { approved: true } })
279
+ assert.equal(r.responded, true)
280
+ assert.ok(posted[0].includes('/api/agent/e1/respond'))
281
+ globalThis.fetch = realFetch
282
+ })
283
+
284
+ test('agentspan_list returns normalized execution rows', async () => {
285
+ const { tools, ctx } = makeCtx(null, { serverUrl: DEFAULT_BASE_URL })
286
+ const realFetch = globalThis.fetch
287
+ globalThis.fetch = async () => ({ ok: true, status: 200, text: async () => JSON.stringify({ results: [{ workflowId: 'a', workflowName: 'x', status: 'RUNNING' }, { workflowId: 'b', workflowName: 'y', status: 'FAILED' }] }) })
288
+ register(ctx)
289
+ const r = await tools.get('agentspan_list').handler({})
290
+ assert.equal(r.count, 2)
291
+ assert.equal(r.executions[1].status, 'FAILED')
292
+ globalThis.fetch = realFetch
293
+ })
294
+
295
+ test('agentspan_stop tries agent stop then terminates workflow; requires id', async () => {
296
+ const { tools, ctx } = makeCtx(null, { serverUrl: DEFAULT_BASE_URL })
297
+ const realFetch = globalThis.fetch
298
+ const calls = []
299
+ globalThis.fetch = async (url, init) => {
300
+ calls.push(`${init.method} ${url}`)
301
+ if (url.includes('/api/agent/') && url.includes('/stop')) return { ok: false, status: 404, text: async () => 'no' }
302
+ return { ok: true, status: 200, text: async () => '' }
303
+ }
304
+ register(ctx)
305
+ const bad = await tools.get('agentspan_stop').handler({})
306
+ assert.match(bad.error, /executionId/)
307
+ const r = await tools.get('agentspan_stop').handler({ executionId: 'w1' })
308
+ assert.equal(r.stopped, true)
309
+ assert.ok(calls.some((c) => c.startsWith(`DELETE ${DEFAULT_BASE_URL}/api/workflow/w1`)))
310
+ globalThis.fetch = realFetch
311
+ })
312
+
313
+ test('trigger fires only for agentspan FAILED events', () => {
314
+ const { triggers, ctx } = makeCtx(null, {})
315
+ register(ctx)
316
+ const t = triggers[0]
317
+ assert.ok(t.match({ source: 'agentspan', status: 'FAILED' }))
318
+ assert.ok(!t.match({ source: 'agentspan', status: 'RUNNING' }))
319
+ assert.ok(!t.match({ source: 'netdata', status: 'FAILED' }))
320
+ })
321
+
322
+ test('panel renders an executions table', () => {
323
+ const { panels, ctx } = makeCtx(null, { serverUrl: 'http://x:6767' })
324
+ register(ctx)
325
+ const html = panels[0].render([{ name: 'cleanup', id: 'e1', status: 'RUNNING', startTime: 'now' }])
326
+ assert.match(html, /cleanup/)
327
+ assert.match(html, /http:\/\/x:6767/)
328
+ })
@@ -0,0 +1,166 @@
1
+ /**
2
+ * conductorClient.mjs — a minimal, dependency-free HTTP client for the
3
+ * AgentSpan / Netflix-Conductor REST API.
4
+ *
5
+ * AgentSpan server runs on Conductor (default http://localhost:6767) and
6
+ * exposes both its own `/api/agent/*` lifecycle surface and Conductor's
7
+ * `/api/workflow/*` engine surface. This client is pure + injectable (a
8
+ * `fetchImpl` is passed in) so it is fully unit-testable offline and has no
9
+ * runtime network dependency baked in — the plugin wires the real `fetch`.
10
+ *
11
+ * Auth: AgentSpan standalone auth uses X-Auth-Key / X-Auth-Secret headers
12
+ * (AGENTSPAN_AUTH_KEY / AGENTSPAN_AUTH_SECRET). Conductor OSS uses no auth by
13
+ * default. The client sends the headers only when both are provided.
14
+ */
15
+
16
+ export const DEFAULT_BASE_URL = 'http://localhost:6767'
17
+
18
+ /** Build the auth headers for a request (only when both key+secret are set). */
19
+ export function authHeaders(auth) {
20
+ const h = { 'content-type': 'application/json', accept: 'application/json' }
21
+ if (auth && auth.key && auth.secret) {
22
+ h['X-Auth-Key'] = auth.key
23
+ h['X-Auth-Secret'] = auth.secret
24
+ }
25
+ return h
26
+ }
27
+
28
+ /** Join a base URL + path safely (single slash). */
29
+ export function joinUrl(base, path) {
30
+ const b = String(base || DEFAULT_BASE_URL).replace(/\/+$/, '')
31
+ const p = String(path || '').startsWith('/') ? String(path) : `/${path}`
32
+ return `${b}${p}`
33
+ }
34
+
35
+ /** Parse a response body as JSON when possible, else return raw text. */
36
+ async function parseBody(res) {
37
+ const text = await res.text()
38
+ if (!text) return null
39
+ try { return JSON.parse(text) } catch { return text }
40
+ }
41
+
42
+ export class ConductorApiError extends Error {
43
+ constructor(status, path, body) {
44
+ super(`conductor ${status} ${path}: ${typeof body === 'string' ? body.slice(0, 300) : JSON.stringify(body)?.slice(0, 300)}`)
45
+ this.status = status
46
+ this.path = path
47
+ this.body = body
48
+ }
49
+ }
50
+
51
+ export class ConductorClient {
52
+ /**
53
+ * @param {{ baseUrl?: string, auth?: {key?:string, secret?:string}, fetchImpl: Function }} opts
54
+ * fetchImpl(url, {method, headers, body}) -> Promise<{ok,status,text:()=>Promise<string>}>
55
+ */
56
+ constructor(opts = {}) {
57
+ if (typeof opts.fetchImpl !== 'function') throw new Error('ConductorClient needs a fetchImpl')
58
+ this.baseUrl = opts.baseUrl || DEFAULT_BASE_URL
59
+ this.auth = opts.auth
60
+ this.fetchImpl = opts.fetchImpl
61
+ }
62
+
63
+ async request(method, path, body) {
64
+ const url = joinUrl(this.baseUrl, path)
65
+ const res = await this.fetchImpl(url, {
66
+ method,
67
+ headers: authHeaders(this.auth),
68
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
69
+ })
70
+ const parsed = await parseBody(res)
71
+ if (!res.ok) throw new ConductorApiError(res.status, `${method} ${path}`, parsed)
72
+ return parsed
73
+ }
74
+
75
+ // ── Health ────────────────────────────────────────────────────────────────
76
+ /** AgentSpan/Conductor health (Spring Actuator). */
77
+ async health() {
78
+ try {
79
+ const r = await this.request('GET', '/actuator/health')
80
+ return { ok: true, status: r?.status ?? 'UP', raw: r }
81
+ } catch (e) {
82
+ return { ok: false, status: 'DOWN', error: e.message }
83
+ }
84
+ }
85
+
86
+ // ── Agent lifecycle (AgentSpan /api/agent) ───────────────────────────────
87
+ /** Compile + register + start an agent from an AgentConfig; returns { executionId }. */
88
+ async runAgent(agentConfig, prompt) {
89
+ const body = prompt !== undefined ? { agent: agentConfig, input: prompt } : agentConfig
90
+ const r = await this.request('POST', '/api/agent/start', body)
91
+ return { executionId: r?.executionId ?? r?.workflowId ?? r?.id ?? null, raw: r }
92
+ }
93
+
94
+ /** Compile only (returns the WorkflowDef without executing). */
95
+ async compileAgent(agentConfig) {
96
+ return this.request('POST', '/api/agent/compile', agentConfig)
97
+ }
98
+
99
+ /** Detailed status of a running/finished execution. */
100
+ async agentStatus(executionId) {
101
+ if (!executionId) throw new Error('agentStatus needs an executionId')
102
+ return this.request('GET', `/api/agent/${encodeURIComponent(executionId)}`)
103
+ }
104
+
105
+ /** Human-in-the-loop respond: complete a paused HUMAN task + resume. */
106
+ async agentRespond(executionId, output) {
107
+ if (!executionId) throw new Error('agentRespond needs an executionId')
108
+ return this.request('POST', `/api/agent/${encodeURIComponent(executionId)}/respond`, output ?? {})
109
+ }
110
+
111
+ /** Stop (cancel) an execution. */
112
+ async agentStop(executionId) {
113
+ if (!executionId) throw new Error('agentStop needs an executionId')
114
+ return this.request('POST', `/api/agent/${encodeURIComponent(executionId)}/stop`, {})
115
+ }
116
+
117
+ /** Server-sent event log for an execution. */
118
+ async agentEvents(executionId) {
119
+ if (!executionId) throw new Error('agentEvents needs an executionId')
120
+ return this.request('GET', `/api/agent/events/${encodeURIComponent(executionId)}`)
121
+ }
122
+
123
+ /** List agent definitions registered on the server. */
124
+ async listAgentDefinitions() {
125
+ return this.request('GET', '/api/agent/definitions')
126
+ }
127
+
128
+ // ── Workflow engine (Conductor /api/workflow) ────────────────────────────
129
+ /** Start a named Conductor workflow; returns the workflowId string. */
130
+ async startWorkflow(name, input, opts = {}) {
131
+ if (!name) throw new Error('startWorkflow needs a workflow name')
132
+ const q = new URLSearchParams()
133
+ if (opts.version !== undefined) q.set('version', String(opts.version))
134
+ if (opts.correlationId) q.set('correlationId', String(opts.correlationId))
135
+ const path = `/api/workflow/${encodeURIComponent(name)}${q.size ? `?${q}` : ''}`
136
+ const r = await this.request('POST', path, input ?? {})
137
+ return typeof r === 'string' ? r : (r?.workflowId ?? r?.id ?? null)
138
+ }
139
+
140
+ /** Get a workflow execution (status + tasks). */
141
+ async getWorkflow(workflowId, includeTasks = true) {
142
+ if (!workflowId) throw new Error('getWorkflow needs a workflowId')
143
+ return this.request('GET', `/api/workflow/${encodeURIComponent(workflowId)}?includeTasks=${includeTasks}`)
144
+ }
145
+
146
+ /** Terminate a workflow execution. */
147
+ async terminateWorkflow(workflowId, reason) {
148
+ if (!workflowId) throw new Error('terminateWorkflow needs a workflowId')
149
+ const q = reason ? `?reason=${encodeURIComponent(reason)}` : ''
150
+ return this.request('DELETE', `/api/workflow/${encodeURIComponent(workflowId)}${q}`)
151
+ }
152
+
153
+ /** Retry a failed workflow from the last failed task (durable resume). */
154
+ async retryWorkflow(workflowId) {
155
+ if (!workflowId) throw new Error('retryWorkflow needs a workflowId')
156
+ return this.request('POST', `/api/workflow/${encodeURIComponent(workflowId)}/retry`, {})
157
+ }
158
+
159
+ /** Search workflow executions (freeText query, e.g. status:FAILED). */
160
+ async searchWorkflows(query = '*', size = 20) {
161
+ const q = new URLSearchParams({ freeText: query, size: String(size), sort: 'startTime:DESC' })
162
+ return this.request('GET', `/api/workflow/search?${q}`)
163
+ }
164
+ }
165
+
166
+ export default ConductorClient
@@ -0,0 +1,275 @@
1
+ /**
2
+ * agentspan-bridge plugin — run durable, crash-resilient agents + workflows on
3
+ * an AgentSpan (Netflix Conductor) server from RTerm.
4
+ *
5
+ * What this adds that RTerm doesn't already have:
6
+ * - Durable agent execution: a crashed/restarted run resumes from the last
7
+ * completed step (Conductor engine), not just a ledger entry.
8
+ * - Plan-execute determinism (LLM plans once → immutable sub-workflow).
9
+ * - Enterprise event triggers (Kafka/SQS/AMQP/DB) via the Conductor server.
10
+ * - A live visual execution UI (served by the AgentSpan server).
11
+ *
12
+ * Config (Settings → agentspan block, resolved here from the RTerm settings
13
+ * service via ctx.settings if present, else env):
14
+ * serverUrl — e.g. http://localhost:6767 (default)
15
+ * authSecretRef — vault key holding "AUTH_KEY=...\nAUTH_SECRET=..." (optional;
16
+ * only needed when the AgentSpan server has standalone auth on)
17
+ *
18
+ * The client is dependency-free (conductorClient.mjs) and the plugin never
19
+ * crashes RTerm when the AgentSpan server is down — every tool returns a clear
20
+ * "server unreachable" result instead of throwing.
21
+ */
22
+
23
+ import { ConductorClient, DEFAULT_BASE_URL, joinUrl } from './conductorClient.mjs'
24
+
25
+ // ─── config resolution ──────────────────────────────────────────────────────
26
+
27
+ /** Read the agentspan config block from RTerm settings (ctx.settings) or env. */
28
+ export function resolveConfig(ctx = {}, env = process.env) {
29
+ const s = (typeof ctx.getSettings === 'function' ? ctx.getSettings() : ctx.settings) || {}
30
+ const block = s.agentspan || {}
31
+ return {
32
+ serverUrl: (block.serverUrl || env.AGENTSPAN_SERVER_URL || DEFAULT_BASE_URL).replace(/\/+$/, ''),
33
+ authSecretRef: block.authSecretRef || env.AGENTSPAN_AUTH_SECRET_REF || undefined,
34
+ enabled: block.enabled !== false,
35
+ }
36
+ }
37
+
38
+ /** Parse a vault "KEY=VAL" blob into an {key, secret} auth object. */
39
+ export function parseAuthBlob(blob) {
40
+ if (!blob || typeof blob !== 'string') return undefined
41
+ const out = {}
42
+ for (const line of blob.split(/\r?\n/)) {
43
+ const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
44
+ if (m) out[m[1]] = m[2].trim()
45
+ }
46
+ const key = out.AGENTSPAN_AUTH_KEY || out.AUTH_KEY || out.KEY
47
+ const secret = out.AGENTSPAN_AUTH_SECRET || out.AUTH_SECRET || out.SECRET
48
+ return key && secret ? { key, secret } : undefined
49
+ }
50
+
51
+ /** Real fetch adapter (matches conductorClient's expected {ok,status,text}). */
52
+ async function realFetch(url, init) {
53
+ const res = await fetch(url, { method: init.method, headers: init.headers, body: init.body })
54
+ return { ok: res.ok, status: res.status, text: () => res.text() }
55
+ }
56
+
57
+ /** Build a configured ConductorClient from ctx (settings + vault). */
58
+ export function buildClient(ctx = {}, fetchImpl = realFetch) {
59
+ const cfg = resolveConfig(ctx)
60
+ let auth
61
+ if (cfg.authSecretRef && typeof ctx.getSecret === 'function') {
62
+ try {
63
+ const blob = ctx.getSecret(cfg.authSecretRef)
64
+ auth = parseAuthBlob(blob)
65
+ } catch { auth = undefined }
66
+ }
67
+ return { client: new ConductorClient({ baseUrl: cfg.serverUrl, auth, fetchImpl }), config: cfg }
68
+ }
69
+
70
+ // ─── formatting helpers (pure) ─────────────────────────────────────────────
71
+
72
+ /** Normalize an execution/workflow status payload into a compact summary. */
73
+ export function summarizeStatus(payload) {
74
+ if (!payload || typeof payload !== 'object') return { status: 'UNKNOWN' }
75
+ const status = payload.status ?? payload.workflowStatus ?? payload.state ?? 'UNKNOWN'
76
+ const name = payload.workflowName ?? payload.name ?? payload.agentName ?? payload.workflowType
77
+ const id = payload.workflowId ?? payload.executionId ?? payload.id
78
+ const tasks = Array.isArray(payload.tasks) ? payload.tasks : []
79
+ const failed = tasks.filter((t) => String(t.status).toUpperCase().includes('FAIL')).length
80
+ const done = tasks.filter((t) => String(t.status).toUpperCase() === 'COMPLETED').length
81
+ return {
82
+ id,
83
+ name,
84
+ status: String(status).toUpperCase(),
85
+ taskCount: tasks.length,
86
+ completedTasks: done,
87
+ failedTasks: failed,
88
+ startTime: payload.startTime ?? payload.createTime,
89
+ endTime: payload.endTime ?? payload.updateTime,
90
+ reason: payload.reasonForIncompletion ?? payload.failedReason,
91
+ }
92
+ }
93
+
94
+ /** Normalize a search/list result into rows for the panel + agentspan_list. */
95
+ export function toExecutionRows(payload) {
96
+ const results = payload?.results ?? payload?.workflows ?? (Array.isArray(payload) ? payload : [])
97
+ if (!Array.isArray(results)) return []
98
+ return results.map((w) => ({
99
+ id: w.workflowId ?? w.executionId ?? w.id,
100
+ name: w.workflowName ?? w.name ?? w.workflowType,
101
+ status: String(w.status ?? w.workflowStatus ?? '').toUpperCase(),
102
+ startTime: w.startTime ?? w.createTime,
103
+ endTime: w.endTime ?? w.updateTime,
104
+ }))
105
+ }
106
+
107
+ /** Map an execution status to a trigger event (fires on FAILED). */
108
+ export function isFailedExecution(status) {
109
+ const s = String(status || '').toUpperCase()
110
+ return s === 'FAILED' || s === 'TERMINATED' || s === 'TIMED_OUT'
111
+ }
112
+
113
+ // ─── unreachable-server helper ─────────────────────────────────────────────
114
+
115
+ async function guarded(fn, log) {
116
+ try {
117
+ return await fn()
118
+ } catch (e) {
119
+ const msg = e?.message ?? String(e)
120
+ log?.(`[agentspan] ${msg}`)
121
+ return { error: msg, hint: 'Is the AgentSpan server running? (agentspan server start, default http://localhost:6767). Configure the URL in Settings → agentspan.' }
122
+ }
123
+ }
124
+
125
+ // ─── plugin entry ───────────────────────────────────────────────────────────
126
+
127
+ export function register(ctx) {
128
+ const { registerTool, registerTrigger, registerPanel, log } = ctx
129
+ const { client, config } = buildClient(ctx)
130
+
131
+ // Tool: agentspan_health — check the server is reachable + its status.
132
+ registerTool({
133
+ name: 'agentspan_health',
134
+ description: 'Check whether the AgentSpan/Conductor server is reachable and healthy. Returns the server URL, health status, and whether auth is configured.',
135
+ params: {},
136
+ handler: async () => guarded(async () => {
137
+ const h = await client.health()
138
+ if (!h.ok) {
139
+ return {
140
+ serverUrl: config.serverUrl,
141
+ authConfigured: Boolean(config.authSecretRef),
142
+ ...h,
143
+ hint: 'Is the AgentSpan server running? (agentspan server start, default http://localhost:6767). Configure the URL in Settings → agentspan.',
144
+ }
145
+ }
146
+ return { serverUrl: config.serverUrl, authConfigured: Boolean(config.authSecretRef), ...h }
147
+ }, log),
148
+ })
149
+
150
+ // Tool: agentspan_run — run a durable agent (AgentConfig) or named workflow.
151
+ registerTool({
152
+ name: 'agentspan_run',
153
+ description: 'Start a durable, crash-resilient agent or workflow on the AgentSpan server. Pass either an `agentConfig` (compiled+started via /api/agent/start) or a `workflow` name (Conductor /api/workflow/{name}). Returns the executionId/workflowId — the run survives process restarts and resumes from the last completed step.',
154
+ params: {
155
+ agentConfig: { type: 'object', description: 'An AgentConfig JSON (agent tree) to compile+run durably', optional: true },
156
+ workflow: { type: 'string', description: 'Name of a registered Conductor workflow to start', optional: true },
157
+ input: { type: 'object', description: 'Input payload for the agent/workflow', optional: true },
158
+ prompt: { type: 'string', description: 'Natural-language prompt for the agent', optional: true },
159
+ },
160
+ handler: async (p) => guarded(async () => {
161
+ if (p?.agentConfig) {
162
+ const r = await client.runAgent(p.agentConfig, p.prompt ?? p.input)
163
+ return { kind: 'agent', executionId: r.executionId, serverUrl: config.serverUrl, uiUrl: joinUrl(config.serverUrl, `/execution/${r.executionId}`), raw: r.raw }
164
+ }
165
+ if (p?.workflow) {
166
+ const id = await client.startWorkflow(p.workflow, p.input ?? {})
167
+ return { kind: 'workflow', workflowId: id, serverUrl: config.serverUrl, uiUrl: joinUrl(config.serverUrl, `/execution/${id}`) }
168
+ }
169
+ return { error: 'agentspan_run needs either agentConfig or workflow' }
170
+ }, log),
171
+ })
172
+
173
+ // Tool: agentspan_status — detailed status of an execution/workflow.
174
+ registerTool({
175
+ name: 'agentspan_status',
176
+ description: 'Get the detailed status of a durable execution (agent or workflow) by id — current status, per-task progress, failed tasks, and failure reason if any.',
177
+ params: { executionId: { type: 'string', description: 'The executionId or workflowId' } },
178
+ handler: async (p) => guarded(async () => {
179
+ if (!p?.executionId) return { error: 'agentspan_status needs executionId' }
180
+ // Try the agent lifecycle surface first, fall back to the workflow engine.
181
+ try {
182
+ const a = await client.agentStatus(p.executionId)
183
+ return { kind: 'agent', ...summarizeStatus(a), raw: a }
184
+ } catch {
185
+ const w = await client.getWorkflow(p.executionId)
186
+ return { kind: 'workflow', ...summarizeStatus(w) }
187
+ }
188
+ }, log),
189
+ })
190
+
191
+ // Tool: agentspan_approve — human-in-the-loop respond to a paused HUMAN task.
192
+ registerTool({
193
+ name: 'agentspan_approve',
194
+ description: 'Respond to a paused human-in-the-loop task in a durable execution (completes the HUMAN task and resumes the run). Pass the executionId and an `output` object (e.g. {approved:true, comment:"..."}).',
195
+ params: {
196
+ executionId: { type: 'string', description: 'The executionId with a paused HUMAN task' },
197
+ output: { type: 'object', description: 'The response output map (e.g. {approved:true})' },
198
+ },
199
+ handler: async (p) => guarded(async () => {
200
+ if (!p?.executionId) return { error: 'agentspan_approve needs executionId' }
201
+ await client.agentRespond(p.executionId, p.output ?? {})
202
+ const s = await client.agentStatus(p.executionId).catch(() => null)
203
+ return { responded: true, executionId: p.executionId, ...(s ? summarizeStatus(s) : {}) }
204
+ }, log),
205
+ })
206
+
207
+ // Tool: agentspan_list — list recent executions (optionally filtered).
208
+ registerTool({
209
+ name: 'agentspan_list',
210
+ description: 'List recent durable executions on the AgentSpan server. Optional `query` (Conductor freeText, e.g. "status:FAILED" or "workflowName:cleanup") and `size`. Returns id/name/status/start/end rows.',
211
+ params: {
212
+ query: { type: 'string', description: 'Conductor freeText query (default *)', optional: true },
213
+ size: { type: 'number', description: 'Max results (default 20)', optional: true },
214
+ },
215
+ handler: async (p) => guarded(async () => {
216
+ const r = await client.searchWorkflows(p?.query ?? '*', p?.size ?? 20)
217
+ const rows = toExecutionRows(r)
218
+ return { count: rows.length, executions: rows }
219
+ }, log),
220
+ })
221
+
222
+ // Tool: agentspan_stop — terminate a running execution.
223
+ registerTool({
224
+ name: 'agentspan_stop',
225
+ description: 'Stop (terminate) a running durable execution by id. Optionally pass a `reason`.',
226
+ params: {
227
+ executionId: { type: 'string', description: 'The executionId/workflowId to stop' },
228
+ reason: { type: 'string', description: 'Optional termination reason', optional: true },
229
+ },
230
+ handler: async (p) => guarded(async () => {
231
+ if (!p?.executionId) return { error: 'agentspan_stop needs executionId' }
232
+ try {
233
+ await client.agentStop(p.executionId)
234
+ } catch {
235
+ await client.terminateWorkflow(p.executionId, p.reason)
236
+ }
237
+ return { stopped: true, executionId: p.executionId }
238
+ }, log),
239
+ })
240
+
241
+ // Trigger: agentspan_execution_failed — fires when a durable execution fails.
242
+ registerTrigger({
243
+ name: 'agentspan_execution_failed',
244
+ description: 'Fires when an AgentSpan/Conductor durable execution transitions to FAILED/TERMINATED/TIMED_OUT. Use for auto-remediation or re-run playbooks.',
245
+ match: (event) => {
246
+ if (event?.source !== 'agentspan') return false
247
+ return isFailedExecution(event.status)
248
+ },
249
+ action: 'propose-change',
250
+ })
251
+
252
+ // Panel: agentspan-executions — live feed of durable executions.
253
+ registerPanel({
254
+ name: 'agentspan-executions',
255
+ title: 'AgentSpan Executions',
256
+ render: (data) => {
257
+ const rows = (Array.isArray(data) ? data : []).map((e) =>
258
+ `<tr><td>${e.name ?? ''}</td><td>${e.id ?? ''}</td><td>${e.status ?? ''}</td><td>${e.startTime ?? ''}</td></tr>`
259
+ ).join('')
260
+ return `<div class="agentspan-executions"><h3>AgentSpan Durable Executions</h3><p>Server: ${config.serverUrl}</p><table><thead><tr><th>Name</th><th>Execution</th><th>Status</th><th>Started</th></tr></thead><tbody>${rows}</tbody></table></div>`
261
+ },
262
+ })
263
+
264
+ log(`[agentspan] agentspan-bridge registered: 6 tools, 1 trigger, 1 panel (server=${config.serverUrl})`)
265
+ }
266
+
267
+ export default {
268
+ register,
269
+ resolveConfig,
270
+ parseAuthBlob,
271
+ buildClient,
272
+ summarizeStatus,
273
+ toExecutionRows,
274
+ isFailedExecution,
275
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "agentspan-bridge",
3
+ "version": "1.0.0",
4
+ "description": "AgentSpan/Conductor bridge for RTerm — run durable, crash-resilient agents and workflows on an AgentSpan (Conductor) server from RTerm. Adds durable agent execution (resume-from-step on crash), plan-execute determinism, enterprise event triggers, and a live execution feed to RTerm's agent + dashboard. Server URL + optional auth are configured in Settings (agentspan block); secrets via the vault.",
5
+ "entry": "index.mjs",
6
+ "tools": [
7
+ "agentspan_run",
8
+ "agentspan_status",
9
+ "agentspan_approve",
10
+ "agentspan_list",
11
+ "agentspan_stop",
12
+ "agentspan_health"
13
+ ],
14
+ "triggers": [
15
+ "agentspan_execution_failed"
16
+ ],
17
+ "panels": [
18
+ "agentspan-executions"
19
+ ],
20
+ "permissions": [
21
+ "exec",
22
+ "readLedger:metrics"
23
+ ]
24
+ }