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/package.json CHANGED
@@ -1,9 +1,20 @@
1
1
  {
2
2
  "name": "rterm-backend",
3
- "version": "3.1.0",
4
- "description": "Headless AI-native backend for RTerm / neuralOS — v3.1.0: systematic bug-hunt audit, all 12 candidates confirmed-not-a-bug.",
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
+ })