rterm-backend 3.1.0 → 3.1.2
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 +6829 -1440
- package/package.json +14 -3
- package/plugins/agentspan-bridge/agentspan-bridge.extreme.spec.ts +328 -0
- package/plugins/agentspan-bridge/agentspan-bridge.phase2.extreme.spec.ts +282 -0
- package/plugins/netdata-rterm/netdata-rterm.extreme.spec.ts +252 -0
- package/plugins/web-intel/web-intel.extreme.spec.ts +263 -0
package/package.json
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rterm-backend",
|
|
3
|
-
"version": "3.1.
|
|
4
|
-
"description": "Headless AI-native backend for RTerm / neuralOS — v3.1.
|
|
3
|
+
"version": "3.1.2",
|
|
4
|
+
"description": "Headless AI-native backend for RTerm / neuralOS — v3.1.2: comprehensive NATS event mesh (token/user-pass/NKey/JWT/creds/TLS auth, JetStream streams+consumers, Key-Value, request/reply, queue groups, headers, lifecycle). Transports (SSH/serial/local) and SQLite install automatically.",
|
|
5
5
|
"main": "bin/gybackend.cjs",
|
|
6
6
|
"bin": { "gybackend": "bin/gybackend.cjs" },
|
|
7
7
|
"license": "MIT",
|
|
8
|
-
"engines": { "node": ">=18" }
|
|
8
|
+
"engines": { "node": ">=18" },
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@nats-io/jetstream": "^3.4.0",
|
|
11
|
+
"@nats-io/kv": "^3.4.0",
|
|
12
|
+
"@nats-io/transport-node": "^3.4.0",
|
|
13
|
+
"better-sqlite3": "^12.11.1"
|
|
14
|
+
},
|
|
15
|
+
"optionalDependencies": {
|
|
16
|
+
"serialport": "^12.0.0",
|
|
17
|
+
"ssh2": "^1.17.0",
|
|
18
|
+
"node-pty": "1.2.0-beta.3"
|
|
19
|
+
}
|
|
9
20
|
}
|
|
@@ -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 9 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, 9)
|
|
210
|
+
for (const n of ['agentspan_health', 'agentspan_run', 'agentspan_status', 'agentspan_approve', 'agentspan_list', 'agentspan_stop', 'agentspan_export_playbook', 'agentspan_register_playbook', 'agentspan_delegate']) 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,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentspan-bridge.phase2.extreme.spec.ts — exhaustive offline tests for the
|
|
3
|
+
* Phase-2 additions: playbookToWorkflowDef mapper (step→task mapping, DAG
|
|
4
|
+
* edges, wait/rollback, retries), the conductorClient registerWorkflowDef /
|
|
5
|
+
* getWorkflowDef methods, and the new tools (agentspan_export_playbook,
|
|
6
|
+
* agentspan_register_playbook, agentspan_delegate) with mocked fetch. No network.
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { ConductorClient, DEFAULT_BASE_URL } from './conductorClient.mjs'
|
|
11
|
+
import {
|
|
12
|
+
playbookToWorkflowDef,
|
|
13
|
+
stepToTask,
|
|
14
|
+
rollbackToTask,
|
|
15
|
+
taskRef,
|
|
16
|
+
} from './playbookToWorkflowDef.mjs'
|
|
17
|
+
import { register, findPlaybook, buildDelegateAgentConfig } from './index.mjs'
|
|
18
|
+
|
|
19
|
+
// ─── mock fetch ─────────────────────────────────────────────────────────────
|
|
20
|
+
function mockFetch(respond) {
|
|
21
|
+
const calls = []
|
|
22
|
+
const fn = async (url, init) => {
|
|
23
|
+
calls.push({ url, init })
|
|
24
|
+
const r = typeof respond === 'function' ? respond(url, init) : respond
|
|
25
|
+
return { ok: r.ok !== false && (r.status ?? 200) < 400, status: r.status ?? 200, text: async () => r.text ?? (r.json !== undefined ? JSON.stringify(r.json) : '') }
|
|
26
|
+
}
|
|
27
|
+
fn.calls = calls
|
|
28
|
+
return fn
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const samplePlaybook = {
|
|
32
|
+
id: 'pb-1',
|
|
33
|
+
name: 'nightly backup',
|
|
34
|
+
description: 'backup the core switch',
|
|
35
|
+
steps: [
|
|
36
|
+
{ id: 'st-1', name: 'prep', kind: 'command', command: 'term length 0' },
|
|
37
|
+
{ id: 'st-2', name: 'collect', kind: 'script', scriptId: 'scr-9' },
|
|
38
|
+
{ id: 'st-3', name: 'settle', kind: 'wait', waitSeconds: 5 },
|
|
39
|
+
{ id: 'st-4', name: 'apply', kind: 'command', command: 'apply acl', rollback: { kind: 'command', command: 'no acl' }, dependsOn: ['st-1', 'st-2'] },
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── taskRef ────────────────────────────────────────────────────────────────
|
|
44
|
+
test('taskRef sanitizes to Conductor-safe refs', () => {
|
|
45
|
+
assert.equal(taskRef('st-1'), 'st_1')
|
|
46
|
+
assert.equal(taskRef('apply acl!'), 'apply_acl_')
|
|
47
|
+
assert.equal(taskRef(undefined, 'fallback'), 'fallback')
|
|
48
|
+
assert.equal(taskRef(''), 'step')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// ─── stepToTask ─────────────────────────────────────────────────────────────
|
|
52
|
+
test('command step → HTTP run_command task with command + validate', () => {
|
|
53
|
+
const t = stepToTask({ id: 'st-1', kind: 'command', command: 'show run', validate: { expect: 'ok' } }, 0, {})
|
|
54
|
+
assert.equal(t.type, 'HTTP')
|
|
55
|
+
assert.equal(t.taskReferenceName, 'st_1')
|
|
56
|
+
const req = t.inputParameters.http_request
|
|
57
|
+
assert.equal(req.method, 'POST')
|
|
58
|
+
assert.equal(req.body.kind, 'run_command')
|
|
59
|
+
assert.equal(req.body.command, 'show run')
|
|
60
|
+
assert.deepEqual(req.body.validate, { expect: 'ok' })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('script step → SIMPLE script-reference task (scriptId, no inline body)', () => {
|
|
64
|
+
const t = stepToTask({ id: 'st-2', kind: 'script', scriptId: 'scr-9', name: 'collect' }, 1, {})
|
|
65
|
+
assert.equal(t.type, 'SIMPLE')
|
|
66
|
+
assert.equal(t.inputParameters.kind, 'rterm_script')
|
|
67
|
+
assert.equal(t.inputParameters.scriptId, 'scr-9')
|
|
68
|
+
assert.equal(t.inputParameters.name, 'collect')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('wait step → Conductor WAIT task with duration', () => {
|
|
72
|
+
const t = stepToTask({ id: 'st-3', kind: 'wait', waitSeconds: 7 }, 2, {})
|
|
73
|
+
assert.equal(t.type, 'WAIT')
|
|
74
|
+
assert.equal(t.inputParameters.duration, 7)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('onError=continue sets retryCount; stop (default) is 0', () => {
|
|
78
|
+
const cont = stepToTask({ id: 'a', kind: 'command', command: 'x', onError: 'continue' }, 0, { continueRetryCount: 3 })
|
|
79
|
+
assert.equal(cont.retryCount, 3)
|
|
80
|
+
const stop = stepToTask({ id: 'b', kind: 'command', command: 'x' }, 1, {})
|
|
81
|
+
assert.equal(stop.retryCount, 0)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// ─── rollbackToTask ─────────────────────────────────────────────────────────
|
|
85
|
+
test('rollback command → optional compensating HTTP task', () => {
|
|
86
|
+
const t = rollbackToTask({ kind: 'command', command: 'no acl' }, 'st_4', 0)
|
|
87
|
+
assert.equal(t.type, 'HTTP')
|
|
88
|
+
assert.equal(t.optional, true)
|
|
89
|
+
assert.equal(t.inputParameters.http_request.body.compensating, true)
|
|
90
|
+
assert.match(t.taskReferenceName, /^rollback_st_4_/)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('rollback script → optional compensating SIMPLE task', () => {
|
|
94
|
+
const t = rollbackToTask({ kind: 'script', scriptId: 'undo' }, 'st_1', 1)
|
|
95
|
+
assert.equal(t.type, 'SIMPLE')
|
|
96
|
+
assert.equal(t.inputParameters.scriptId, 'undo')
|
|
97
|
+
assert.equal(t.optional, true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// ─── playbookToWorkflowDef: full mapping ────────────────────────────────────
|
|
101
|
+
test('maps all 4 steps + rollback compensating task in order', () => {
|
|
102
|
+
const def = playbookToWorkflowDef(samplePlaybook, { execUri: 'http://gw:17888/rpc/exec' })
|
|
103
|
+
assert.equal(def.name, 'nightly_backup')
|
|
104
|
+
assert.equal(def.version, 1)
|
|
105
|
+
assert.equal(def.schemaVersion, 2)
|
|
106
|
+
assert.equal(def.restartable, true)
|
|
107
|
+
// 4 step tasks + 1 JOIN (st-4 has 2 deps) + 1 rollback = 6
|
|
108
|
+
const types = def.tasks.map((t) => t.type)
|
|
109
|
+
assert.equal(types.filter((x) => x === 'JOIN').length, 1, 'one JOIN for the 2-dep step')
|
|
110
|
+
assert.equal(types.filter((x) => x === 'WAIT').length, 1, 'one WAIT')
|
|
111
|
+
const rollback = def.tasks[def.tasks.length - 1]
|
|
112
|
+
assert.equal(rollback.optional, true, 'last task is the compensating rollback')
|
|
113
|
+
assert.equal(rollback.inputParameters.http_request.body.command, 'no acl')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('JOIN carries the dependsOn edges (fan-in)', () => {
|
|
117
|
+
const def = playbookToWorkflowDef(samplePlaybook, {})
|
|
118
|
+
const join = def.tasks.find((t) => t.type === 'JOIN')
|
|
119
|
+
assert.deepEqual(join.joinOn, ['st_1', 'st_2'])
|
|
120
|
+
// the dependent task (st-4) appears after the JOIN in order
|
|
121
|
+
const joinIdx = def.tasks.indexOf(join)
|
|
122
|
+
const st4 = def.tasks.find((t) => t.taskReferenceName === 'st_4')
|
|
123
|
+
assert.ok(def.tasks.indexOf(st4) > joinIdx, 'dependent task comes after its JOIN')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('linear playbook (no dependsOn) emits no JOINs', () => {
|
|
127
|
+
const pb = { name: 'linear', steps: [
|
|
128
|
+
{ id: 'a', kind: 'command', command: '1' },
|
|
129
|
+
{ id: 'b', kind: 'command', command: '2' },
|
|
130
|
+
{ id: 'c', kind: 'wait', waitSeconds: 1 },
|
|
131
|
+
] }
|
|
132
|
+
const def = playbookToWorkflowDef(pb, {})
|
|
133
|
+
assert.equal(def.tasks.filter((t) => t.type === 'JOIN').length, 0)
|
|
134
|
+
assert.equal(def.tasks.length, 3)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('multiple rollbacks run in reverse step order (undo newest first)', () => {
|
|
138
|
+
const pb = { name: 'multi', steps: [
|
|
139
|
+
{ id: 'a', kind: 'command', command: 'a1', rollback: { kind: 'command', command: 'undo-a' } },
|
|
140
|
+
{ id: 'b', kind: 'command', command: 'b1', rollback: { kind: 'command', command: 'undo-b' } },
|
|
141
|
+
] }
|
|
142
|
+
const def = playbookToWorkflowDef(pb, {})
|
|
143
|
+
const rbs = def.tasks.filter((t) => t.optional)
|
|
144
|
+
assert.equal(rbs.length, 2)
|
|
145
|
+
assert.equal(rbs[0].inputParameters.http_request.body.command, 'undo-b', 'newest rollback first')
|
|
146
|
+
assert.equal(rbs[1].inputParameters.http_request.body.command, 'undo-a')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('execUri flows into the command tasks + inputTemplate', () => {
|
|
150
|
+
const def = playbookToWorkflowDef(samplePlaybook, { execUri: 'http://gw:9000/exec' })
|
|
151
|
+
const cmdTask = def.tasks.find((t) => t.type === 'HTTP')
|
|
152
|
+
assert.equal(cmdTask.inputParameters.http_request.uri, 'http://gw:9000/exec')
|
|
153
|
+
assert.equal(def.inputTemplate.rtermExecUri, 'http://gw:9000/exec')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('rejects a playbook without steps', () => {
|
|
157
|
+
assert.throws(() => playbookToWorkflowDef({ name: 'x' }), /steps array/)
|
|
158
|
+
assert.throws(() => playbookToWorkflowDef(null), /steps array/)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// ─── conductorClient: registerWorkflowDef / getWorkflowDef ─────────────────
|
|
162
|
+
test('registerWorkflowDef POSTs an array to /api/metadata/workflow', async () => {
|
|
163
|
+
const f = mockFetch({ json: {} })
|
|
164
|
+
const c = new ConductorClient({ fetchImpl: f })
|
|
165
|
+
const def = playbookToWorkflowDef(samplePlaybook, {})
|
|
166
|
+
await c.registerWorkflowDef(def)
|
|
167
|
+
const call = f.calls[0]
|
|
168
|
+
assert.equal(call.url, `${DEFAULT_BASE_URL}/api/metadata/workflow`)
|
|
169
|
+
assert.equal(call.init.method, 'POST')
|
|
170
|
+
const body = JSON.parse(call.init.body)
|
|
171
|
+
assert.ok(Array.isArray(body), 'body is an array of defs')
|
|
172
|
+
assert.equal(body[0].name, 'nightly_backup')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('registerWorkflowDef accepts an array + rejects empty', async () => {
|
|
176
|
+
const c = new ConductorClient({ fetchImpl: mockFetch({ json: {} }) })
|
|
177
|
+
await assert.rejects(() => c.registerWorkflowDef(), /WorkflowDef/)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('getWorkflowDef GETs by name (+version)', async () => {
|
|
181
|
+
const f = mockFetch({ json: { name: 'x', version: 2 } })
|
|
182
|
+
const c = new ConductorClient({ fetchImpl: f })
|
|
183
|
+
await c.getWorkflowDef('nightly_backup', 2)
|
|
184
|
+
assert.match(f.calls[0].url, /\/api\/metadata\/workflow\/nightly_backup\?version=2$/)
|
|
185
|
+
await assert.rejects(() => c.getWorkflowDef(), /name/)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// ─── index helpers: findPlaybook / buildDelegateAgentConfig ────────────────
|
|
189
|
+
test('findPlaybook resolves from AutomationManager then settings, by id or name', () => {
|
|
190
|
+
const pb = { id: 'pb-1', name: 'nightly' }
|
|
191
|
+
const viaAm = findPlaybook({ automationManager: { getPlaybook: (x) => (x === 'pb-1' ? pb : undefined) } }, 'pb-1')
|
|
192
|
+
assert.equal(viaAm.name, 'nightly')
|
|
193
|
+
const viaSettings = findPlaybook({ settings: { automation: { playbooks: [pb] } } }, 'nightly')
|
|
194
|
+
assert.equal(viaSettings.id, 'pb-1')
|
|
195
|
+
assert.equal(findPlaybook({ settings: { automation: { playbooks: [] } } }, 'ghost'), undefined)
|
|
196
|
+
assert.equal(findPlaybook({}, undefined), undefined)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('buildDelegateAgentConfig builds a valid durable AgentConfig', () => {
|
|
200
|
+
const c = buildDelegateAgentConfig('mybot', 'do the thing', { model: 'anthropic/claude-sonnet-4.6' })
|
|
201
|
+
assert.equal(c.name, 'mybot')
|
|
202
|
+
assert.equal(c.model, 'anthropic/claude-sonnet-4.6')
|
|
203
|
+
assert.equal(c.input, 'do the thing')
|
|
204
|
+
const def = buildDelegateAgentConfig(undefined, 't')
|
|
205
|
+
assert.equal(def.name, 'rterm_delegate')
|
|
206
|
+
assert.equal(def.model, 'openai/gpt-4o')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// ─── new tools (mocked server) ─────────────────────────────────────────────
|
|
210
|
+
function makeCtx(playbooks, fetchImpl) {
|
|
211
|
+
const tools = new Map()
|
|
212
|
+
const triggers = []
|
|
213
|
+
const panels = []
|
|
214
|
+
const ctx = {
|
|
215
|
+
settings: { agentspan: { serverUrl: DEFAULT_BASE_URL }, automation: { playbooks } },
|
|
216
|
+
registerTool: (t) => tools.set(t.name, t),
|
|
217
|
+
registerTrigger: (t) => triggers.push(t),
|
|
218
|
+
registerPanel: (p) => panels.push(p),
|
|
219
|
+
log: () => {},
|
|
220
|
+
}
|
|
221
|
+
return { tools, ctx, fetchImpl }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
test('registers 9 tools now (6 phase-1 + 3 phase-2)', () => {
|
|
225
|
+
const { tools, ctx } = makeCtx([], null)
|
|
226
|
+
register(ctx)
|
|
227
|
+
assert.equal(tools.size, 9)
|
|
228
|
+
for (const n of ['agentspan_export_playbook', 'agentspan_register_playbook', 'agentspan_delegate']) assert.ok(tools.has(n), `missing ${n}`)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('agentspan_export_playbook returns the mapped def without registering', async () => {
|
|
232
|
+
const posted = []
|
|
233
|
+
const realFetch = globalThis.fetch
|
|
234
|
+
globalThis.fetch = async (url, init) => { posted.push(url); return { ok: true, status: 200, text: async () => '{}' } }
|
|
235
|
+
const { tools, ctx } = makeCtx([samplePlaybook], null)
|
|
236
|
+
register(ctx)
|
|
237
|
+
const r = await tools.get('agentspan_export_playbook').handler({ playbook: 'nightly backup' })
|
|
238
|
+
assert.equal(r.name, 'nightly_backup')
|
|
239
|
+
assert.ok(r.taskCount >= 5)
|
|
240
|
+
assert.ok(r.def.tasks.some((t) => t.type === 'WAIT'))
|
|
241
|
+
assert.equal(posted.length, 0, 'export is pure — no HTTP calls')
|
|
242
|
+
const missing = await tools.get('agentspan_export_playbook').handler({ playbook: 'ghost' })
|
|
243
|
+
assert.match(missing.error, /not found/)
|
|
244
|
+
globalThis.fetch = realFetch
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('agentspan_register_playbook registers the def on the server', async () => {
|
|
248
|
+
const calls = []
|
|
249
|
+
const realFetch = globalThis.fetch
|
|
250
|
+
globalThis.fetch = async (url, init) => {
|
|
251
|
+
calls.push({ url, method: init.method })
|
|
252
|
+
return { ok: true, status: 200, text: async () => '{}' }
|
|
253
|
+
}
|
|
254
|
+
const { tools, ctx } = makeCtx([samplePlaybook], null)
|
|
255
|
+
register(ctx)
|
|
256
|
+
const r = await tools.get('agentspan_register_playbook').handler({ playbook: 'nightly backup' })
|
|
257
|
+
assert.equal(r.registered, true)
|
|
258
|
+
assert.equal(r.name, 'nightly_backup')
|
|
259
|
+
assert.equal(r.runWith.args.workflow, 'nightly_backup')
|
|
260
|
+
assert.ok(calls.some((c) => c.url.endsWith('/api/metadata/workflow') && c.method === 'POST'))
|
|
261
|
+
globalThis.fetch = realFetch
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('agentspan_delegate builds an AgentConfig and returns executionId + followUp', async () => {
|
|
265
|
+
const realFetch = globalThis.fetch
|
|
266
|
+
let postedBody
|
|
267
|
+
globalThis.fetch = async (url, init) => {
|
|
268
|
+
if (init.method === 'POST') postedBody = JSON.parse(init.body)
|
|
269
|
+
return { ok: true, status: 200, text: async () => JSON.stringify({ executionId: 'exec-del-1' }) }
|
|
270
|
+
}
|
|
271
|
+
const { tools, ctx } = makeCtx([], null)
|
|
272
|
+
register(ctx)
|
|
273
|
+
const bad = await tools.get('agentspan_delegate').handler({})
|
|
274
|
+
assert.match(bad.error, /prompt/)
|
|
275
|
+
const r = await tools.get('agentspan_delegate').handler({ prompt: 'investigate the disk-full on web-01', model: 'openai/gpt-5.6-sol' })
|
|
276
|
+
assert.equal(r.delegated, true)
|
|
277
|
+
assert.equal(r.executionId, 'exec-del-1')
|
|
278
|
+
assert.match(r.uiUrl, /\/execution\/exec-del-1$/)
|
|
279
|
+
assert.equal(postedBody.model, 'openai/gpt-5.6-sol')
|
|
280
|
+
assert.equal(postedBody.input, 'investigate the disk-full on web-01')
|
|
281
|
+
globalThis.fetch = realFetch
|
|
282
|
+
})
|