rterm-backend 2.9.8 → 2.9.10

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": "rterm-backend",
3
- "version": "2.9.8",
4
- "description": "rterm-backend \u2014 the headless AI-native backend for RTerm (dual-published as neuralOS). 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.10",
4
+ "description": "rterm-backend \u2014 the headless AI-native backend for RTerm (dual-published as neuralOS). 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.10: AgentSpan Phase 2 \u2014 export/register RTerm playbooks as Conductor workflows + durable task delegation (9 bridge tools, 7 plugins total).",
5
5
  "main": "bin/gybackend.cjs",
6
6
  "bin": {
7
7
  "gybackend": "bin/gybackend.cjs",
@@ -64,6 +64,10 @@
64
64
  "apm",
65
65
  "dem",
66
66
  "etw",
67
+ "agentspan",
68
+ "conductor",
69
+ "durable-agents",
70
+ "playbooks",
67
71
  "monitoring"
68
72
  ]
69
73
  }
@@ -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
+ })